初探富文字之React實時預覽

2023-10-15 15:00:39

初探富文字之React實時預覽

在前文中我們探討了很多關於富文字引擎和協同的能力,在本文中我們更偏向具體的應用元件實現。在一些場景中比如元件庫的檔案編寫時,我們希望能夠有實時預覽的能力,也就是使用者可以在檔案中直接編寫程式碼,然後在頁面中實時預覽,這樣可以讓使用者更加直觀的瞭解元件的使用方式,這也是很多元件庫檔案中都會有的一個功能。那麼我們在本文就側重於React元件的實時預覽,來探討相關能力的實現。文中涉及的相關程式碼都在https://github.com/WindrunnerMax/ReactLive,在富文字檔案中的實現效果可以參考https://windrunnermax.github.io/DocEditor/

描述

首先我們先簡單探討下相關的場景,實際上當前很多元件庫的API檔案都是由Markdown來直接生成的,例如Arco-Design,實際上是通過一個個md檔案來生成的元件應用範例以及API表格,那麼其實我們用的時候也可以發現我們是無法直接在官網編輯程式碼來實時預覽的,這是因為這種方式是直接利用loader來將md檔案根據一定的規則編譯成了jsx語法,這樣實際上就相當於直接用md生成了程式碼,之後就是完整地走了程式碼打包流程。那麼既然有靜態部署的API檔案,肯定也有動態渲染元件的API檔案,例如MUI,其同樣也是通過loader處理md檔案的佔位,將相應的jsx元件通過指定的位置載入進去,只不過其的渲染方式除了靜態編譯完成後還多了動態渲染的能力,官網的程式碼範例就是可以實時編輯的,並且能夠即使預覽效果。

這種小規模的Playground能力應用還是比較廣泛的,其比較小而不至於使用類似於code-sandbox的能力來做完整的演示,基於Markdown來完成檔案對於技術同學來說並不是什麼難事,但是Markdown畢竟不是一個可以廣泛接受的能力,還是需要有一定的學習成本的,富文字能力會相對更容易接受一些,那麼有場景就有需求,我們同樣也會希望能在富文字中實現這種動態渲染元件的能力,這種能力適合做成一種按需載入的第三方外掛的形式。此外,在富文字的實現中可能會有一些非常複雜的場景,例如第三方介面常用的摺疊表格能力,這不是一個常見的場景而且在富文字中實現成本會特別高,尤其體現在實現互動上,ROI會比較低,而實際上公司內部一般都會有自己的API介面平臺,於是利用OpenAPI對接介面平臺直接生成摺疊表格等複雜元件就是一個相對可以接受的方式。上述的兩種場景下實際上都需要動態渲染元件的能力,Playground能力的能力比較好理解,而對接介面平臺需要動態渲染元件的原因是我們的資料結構大概率是無法平齊的,例如某些文字需要加粗,成本最低的方案就是我們直接組裝為<strong />的標籤,併入已有元件庫的摺疊表格中將其渲染出來即可。

我們在這裡也簡單聊一下富文字中實現預覽能力可以參考的方案,預覽塊的結構實際上很簡單,無非是一部分是程式碼塊,在編輯時另一部分可以實時預覽,而在富文字中實現程式碼塊一般都會有比較多的範例,例如使用slate時可以使用decorate的能力,或者可以在quill採用通用的方案,使用prismjs或者lowlight來解析整個程式碼塊,之後將解析出的部分依次作為text的內容並且攜帶解析的屬性放置於資料結構中,在渲染時根據屬性來渲染出相應的樣式即可,甚至於可以直接巢狀程式碼編輯器進去,只不過這樣檔案級別的搜尋替換會比較難做,而且需要注意事件冒泡的處理,而預覽區域主要需要做的是將渲染出的內容標記為Embed/Void,避免選區變換對編輯器的Model造成影響。

那麼接下來我們進入正題,如何動態渲染React元件來完成實時預覽,我們首先來探究一下實現方向,實際上我們可以簡單思考一下,實現一個動態渲染的元件實際上不就是從字串到可執行程式碼嘛,那麼如果在Js中我們能直接執行程式碼中能直接執行程式碼的方法有兩個: evalnew Function,那麼我們肯定是不能用eval的,eval執行的程式碼將在當前作用域中執行,這意味著其可以存取和修改當前作用域中的變數,雖然在嚴格模式下做了一些限制但明顯還是沒那麼安全,這可能導致安全風險和意外的副作用,而new Function建構函式建立的函數有自己的作用域,其只能存取全域性作用域和傳遞給它的引數,從而更容易控制程式碼的執行環境,在後文中安全也是我們需要考慮的問題,所以我們肯定是需要用new Function來實現動態程式碼執行的。

"use strict";

;(() => {
  let a = 1;
  eval("a = 2;")
  console.log(a); // 2
})();

;(() => {
  let a = 1;
  const fn = new Function("a = 2;");
  fn();
  console.log(a); // 1
})();

那麼既然我們有了明確的方向,我們可以接著研究應該如何將React程式碼渲染出來,畢竟瀏覽器是不能直接執行React程式碼的,文中相關的程式碼都在https://github.com/WindrunnerMax/ReactLive中,也可以在Git Pages線上預覽實現效果。

編譯器

前邊我們也提到了,瀏覽器是不能直接執行React程式碼的,這其中一個問題就是瀏覽器並不知道這個元件是什麼,例如我們從元件庫引入了一個<Button />元件,那麼將這個元件交給瀏覽器的時候其並不知道<Button />是什麼語法,當然針對於Button這個元件依賴的問題我們後邊再聊,那麼實際上在我們平時寫React元件的時候,jsx實際上是會編譯成React.createElement的,在17之後可以使用react/jsx-runtimejsx方法,在這裡我們還是使用React.createElement,所以我們現在要做的就是將React字串進行編譯,從jsx轉換為函數呼叫的形式,類似於下面的形式:

<Button className="button-component">
  <div className="div-child"></div>
</Button>

// --->

React.createElement(Button, {
    className: "button-component"
}, React.createElement("div", {
    className: "div-child"
}));

Babel

Babel是一個廣泛使用的Js編譯器,通常用來將最新版本的Js程式碼轉換為瀏覽器可以理解的舊版本程式碼,在這裡我們可以使用Babel來編譯jsx語法。babel-standalone內建了Babel的核心功能和常用外掛,可以直接在瀏覽器中參照,由此直接在瀏覽器中使用babel來轉換Js程式碼。

在這裡實際上我們在這裡用的是babel 6.xbabel-standalone也就是6.x版本的min.js包才791KB,而@babel/standalone也就是7.x版本的min.js包已經2.77MB了,只不過7.x版本會有TS直接型別定義@types/babel__standalone,使用babel-standalone就需要曲線救國了,可以使用@types/babel-core來中轉一下。那麼其實使用Babel非常簡單,我們只需要將程式碼傳進去,設定好相關的presets就可以得到我們想要的程式碼了,當然在這裡我們得到的依舊是程式碼字串,並且實際在使用的時候發現還不能使用<></>語法,畢竟是6年前的包了,在@babel/standalone中是可以正常處理的。

export const DEFAULT_BABEL_OPTION: BabelOptions = {
  presets: ["stage-3", "react", "es2015"],
  plugins: [],
};

export const compileWithBabel = function (code: string, options?: BabelOptions) {
  const result = transform(code, { ...DEFAULT_BABEL_OPTION, ...options });
  return result.code;
};

// https://babel.dev/repl
// https://babel.dev/docs/babel-standalone
<Button className="button-component">
  <div className="div-child"></div>
</Button>

// --->

"use strict";

React.createElement(
  Button,
  { className: "button-component" },
  React.createElement("div", { className: "div-child" })
);

實際上因為我們是接受使用者的輸入來動態地渲染元件的,所以安全問題我們是需要考慮在內的,而使用Babel的一個好處是我們可以比較簡單地註冊外掛,在程式碼解析的時候就可以進行一些處理,例如我們只允許使用者定義名為App的元件函數,一旦宣告其他函數則丟擲解析失敗的異常,我們也可以選擇移除當前節點。當然僅僅是這些還是不夠的,關於安全的相關問題我們後續還需要繼續討論。

import { PluginObj } from "babel-standalone";

export const BabelPluginLimit = (): PluginObj => {
  return {
    name: "babel-plugin-limit",
    visitor: {
      FunctionDeclaration(path) {
        const funcName = path.node.id.name;
        if (funcName !== "App") {
          //   throw new Error("Function Error");
          path.remove();
        }
      },
      JSXIdentifier(path) {
        if (path.node.name === "dangerouslySetInnerHTML") {
          //   throw new Error("Attributes Error");
          path.remove();
        }
      },
    },
  };
};

compileWithBabel(code, { plugins: [ BabelPluginLimit() ] });

另外在這裡我們可以做一個簡單的benchmark,在這裡使用如下程式碼生成了1000Button元件,每個元件巢狀了一個div結構,由此來測試使用babel編譯的速度。從結果可以看出實際速度還是可以的,在小規模的playground場景下是足夠的。

const getCode = () => {
  const CHUNK = `
    <Button className="button-component">
      <div className="div-child"></div>
    </Button>
    `;
  return "<div>" + new Array(1000).fill(CHUNK).join("") + "</div>";
};

console.time("babel");
const code = getCode();
const result = compileWithBabel(code);
console.timeEnd("babel");
babel: 254.635986328125 ms

SWC

SWCSpeedy Web Compiler的簡寫,是一個用Rust編寫的快速TypeScript/JavaScript編譯器,同樣也是同時支援RustJavaScript的庫。SWC是為了解決Web開發中編譯速度較慢的問題而建立的,與傳統的編譯器相比,SWC在編譯速度上表現出色,其能夠利用多個CPU核心,並行處理程式碼,從而顯著提高編譯速度,特別是對於大型專案或包含大量檔案的專案來說,我們之前使用的rspack就是基於SWC實現的。

那麼對於我們來說,使用SWC的主要目的是為了其能夠快速編譯,那麼我們就可以直接使用swc-wasm來實現,其是SWCWebAssembly版本,可以直接在瀏覽器中使用。因為SWC必須要非同步載入才可以,所以我們是需要將整體定義為非同步函數才行,等待載入完成之後我們就可以使用同步的程式碼轉換了,此外使用SWC也是可以寫外掛來處理解析過程中的中間產物的,類似於Babel我們可以寫外掛來限制某些行為,但是需要用Rust來實現,還是有一定的學習成本,我們現在還是關注程式碼的轉換能力。

export const DEFAULT_SWC_OPTIONS: SWCOptions = {
  jsc: {
    parser: { syntax: "ecmascript", jsx: true },
  },
};

let loaded = false;
export const prepare = async () => {
  await initSwc();
  loaded = true;
};

export const compileWithSWC = async (code: string, options?: SWCOptions) => {
  if (!loaded) {
    prepare();
  }
  const result = transformSync(code, { ...DEFAULT_SWC_OPTIONS, ...options });
  return result.code;
};

// https://swc.rs/playground
// https://swc.rs/docs/usage/wasm
<Button className="button-component">
  <div className="div-child"></div>
</Button>

// --->

/*#__PURE__*/ React.createElement(Button, {
    className: "button-component"
}, /*#__PURE__*/ React.createElement("div", {
    className: "div-child"
}));

在這裡我們依然使用1000Button元件與div結構的巢狀來做一個簡單的benchmark。從結果可以看出實際編譯速度是非常快的,主要時間是耗費在初次的wasm載入中,如果是重新整理頁面後不禁用快取直接使用304的結果效率會提高很多,初次載入過後的速度就能夠保持比較高的水平了。

console.time("swc-with-prepare");
await prepare();
console.time("swc");
const code = getCode();
const result = compileWithSWC(code);
console.timeEnd("swc");
console.timeEnd("swc-with-prepare");
swc: 45.98095703125 ms
swc-with-prepare: 701.789306640625 ms

swc: 29.970947265625 ms
swc-with-prepare: 293.3720703125 ms

swc: 35.972900390625 ms
swc-with-prepare: 36.1171875 ms

Sucrase

SucraseBabel的替代品,可以實現超快速的開發構建,其專注於編譯非標準語言擴充套件,例如JSXTypeScriptFlow,由於支援範圍較小,Sucrase可以採用效能更高但可延伸性和可維護性較差的架構,Sucrase的解析器是從Babel的解析器分叉出來的,並將其縮減為Babel解決問題的一個集合中的子集。

同樣的,我們使用Sucrase的目的是提高編譯速度,Sucrase可以直接在瀏覽器中載入,並且包體積比較小,實際上是非常適合我們這種小型Playground場景的。只不過因為使用了非常多的黑科技進行轉換,並沒有類似於Babel有比較長的處理流程,Sucrase是沒有辦法做外掛來處理程式碼中間產物的,所以在需要處理程式碼的情況下,我們需要使用正規表示式自行匹配處理相關程式碼。

export const DEFAULT_SUCRASE_OPTIONS: SucraseOptions = {
  transforms: ["jsx"],
  production: true,
};

export const compileWithSucrase = (code: string, options?: SucraseOptions) => {
  const result = transform(code, { ...DEFAULT_SUCRASE_OPTIONS, ...options });
  return result.code;
};

// https://sucrase.io/
// https://github.com/alangpierce/sucrase
<Button className="button-component">
  <div className="div-child"></div>
</Button>

// --->

React.createElement(Button, { className: "button-component",}
  , React.createElement('div', { className: "div-child",})
)

在這裡我們依然使用1000Button元件與div結構的巢狀來做一個簡單的benchmark,從結果可以看出實際編譯速度是非常快的,整體而言速度遠快於Babel但是略微遜色於SWC,當然SWC需要比較長時間的初始化,所以整體上來說使用Sucrase是不錯的選擇。

console.time("sucrase");
const code = getCode();
const result = compileWithSucrase(code);
console.timeEnd("sucrase");
sucrase: 47.10302734375 ms

程式碼構造

在上一節我們解決了瀏覽器無法直接執行React程式碼的第一個問題,即瀏覽器不認識形如<Button />的程式碼是React元件,我們需要將其編譯成瀏覽器能夠認識的Js程式碼,那麼緊接著在本節中我們需要解決兩個問題,第一個問題是如何讓瀏覽器知道如何找到Button這個物件也就是依賴問題,在我們將<Button />元件編譯為React.createElement(Button, null)之後,並沒有告知瀏覽器Button物件是什麼或者應該從哪裡找到這個物件,第二個問題是我們處理好編譯後的程式碼以及依賴問題之後,我們應該如何構造合適的程式碼,將其放置於new Function中執行,由此得到真正的React元件範例。

Deps/With

在這裡因為我們後邊需要用到new Function以及with語法,所以在這裡先回顧一下。通過Function建構函式可以動態建立函數物件,類似於eval可以動態執行程式碼,然而與具有存取本地作用域的eval不同,Function建構函式建立的函數僅在全域性作用域中執行,其語法為new Function(arg0, arg1, /* ... */ argN, functionBody)

const sum = new Function('a', 'b', 'return a + b');

console.log(sum(1, 2)); // 3

with語句可以將程式碼的作用域設定到一個特定的物件中,其語法為with (expression) statementexpression是一個物件,statement是一個語句或者語句塊。with可以將程式碼的作用域指定到特定的物件中,其內部的變數都是指向該物件的屬性,如果存取某個key時該物件中沒有該屬性,那麼便會繼續沿著作用域檢索直至window,如果在window上還找不到那麼就會拋出ReferenceError異常,由此我們可以藉助with來指定程式碼的作用域,只不過with語句會增加作用域鏈的長度,而且嚴格模式下不允許使用with語句。

with (Math) {
  console.log(PI); // 3.1415926
  console.log(cos(PI)); // -1
  console.log(sin(PI/ 2)); // 1
}

那麼緊接著我們就來解決一下元件的依賴問題,還是以<Button />元件為例在編譯之後我們需要React以及Button這兩個依賴,但是前邊也提到了,new Function是全域性作用域,不會取得當前作用域的值,所以我們需要想辦法將相關的依賴傳遞給我們的程式碼中,以便其能夠正常執行。首先我們可能想到直接將相關變數掛到window上即可,這不就是全域性作用域嘛,當然這個方法可以是可以的,但是不優雅,入侵性太強了,所以我們可以先來看看new Function的語句的引數,看起來所有的引數中只有最後一個引數是函數語句,其他的都是引數,那麼其實這個問題就簡單了,我們先構造一個物件,然後將所有的依賴放置進去,最後在建構函式的時候將物件的所有key作為引數宣告,執行的時候將所有的value作為引數值傳入即可。

const sandbox = {
  React: "React Object",
  Button: "Button Object",
};

const code = `
console.log(React, Button);
`;

const fn = new Function(...Object.keys(sandbox), code.trim());
fn(...Object.values(sandbox)); // React Object Button Object

使用引數的方法實際上是比較不錯的,但是因為用了很多個變數變得並沒有那麼可控,此時如果我們還想做一些額外的功能,例如限制使用者對於window的存取,那麼使用with可能是個更好的選擇,我們先來使用with完成最基本的依賴存取能力。

const sandbox = {
  React: "React Object",
  Button: "Button Object",
};

const code = `
with(sandbox){
    console.log(React, Button);
}
`;

const fn = new Function("sandbox", code.trim());
fn(sandbox); // React Object Button Object

這樣的實現看起來可能會更優雅一些,我們通過一個sandbox變數來承載了所有的依賴,這可以讓存取依賴的行為變得更加可控,實際上我們可能並不想讓使用者的程式碼有如此高的許可權存取全域性的所有物件,例如我們可能想限制使用者對於window的存取,當然我們可以直接將window: {}放在sandbox變數中,因為在沿著作用域向上查詢的時候檢索到window了就不會繼續向上查詢了,但是一個很明顯的問題是我們不可能將所有的全域性物件列舉出來放在引數中,此時我們就需要使用with了,因為使用with的時候我們是會首先存取這個變數的,如果我們能在存取這個變數的時候做個代理,不在白名單的全部返回null就可以了,此時我們還需要請出Proxy物件,我們可以通過with配合Proxy來限制使用者存取,這個我們後邊安全部分再展開。

const sandbox = {
  React: "React Object",
  Button: "Button Object",
  console: console
};

const whitelist = [...Object.keys(sandbox)];

const proxy = new Proxy(sandbox, {
  get(target, prop) {
    if(whitelist.indexOf(prop) > -1){
      return sandbox[prop];
    }else{
      return null;
    }
  },
  has: () => true
});


const code = `
with(sandbox){
  console.log(React, Button, window, document, setTimeout);
}
`;

const fn = new Function("sandbox", code.trim());
fn(proxy); // React Object Button Object null null null

JSX/Fn

在上邊我們解決了依賴的問題,並且對於安全問題做了簡述,只不過到目前為止我們都是在處理字串,還沒有將其轉換為真正的React元件,所以在這裡我們專注於將React元件物件從字串中生成出來,同樣的我們依然使用new Function來執行程式碼,只不過我們需要將程式碼字串拼接成我們想要的形式,由此來將生成的物件帶出來,例如<Button />這個這個元件,經由編譯器編譯之後,我們可以得到React.createElement(Button, null),那麼在建構函式時,如果只是new Function("sandbox", "React.createElement(Button, null)"),即使執行之後我們也是得不到元件範例的,因為這個函數沒有返回值,所以我們需要將其拼接為return React.createElement(Button, null),所以我們就可以得到我們的第一種方法,拼接render來得到返回的元件範例。此外使用者通常可能會同一層級下寫好幾個元件,通常需要我們在最外層巢狀一層div或者React.Fragment

export const renderWithInline = (code: string, dependency: Sandbox) => {
  const fn = new Function("dependency", `with(dependency) { return (${code.trim()})}`);
  return fn(dependency);
};

雖然看起來是能夠實現我們的需求的,只不過需要注意的是,我們必須要開啟編譯器的production等設定,並且要避免使用者的額外輸入例如import語句,否則例如下面的Babel編譯結果,在這種情況下我們使用拼接return的形式顯然就會出現問題,會造成語法錯誤。那麼是不是可以換個思路,直接將return的這部分程式碼也就是return <Button />放在編譯器中編譯,實際上這樣在Sucrase中是可以的,因為其不特別關注於語法,而是會盡可能地編譯,而在Babel中會明顯地丟擲'return' outside of function.異常,在SWC中會丟擲Return statement is not allowed here異常,雖然我們最終的目標是放置於new Function中來建構函式,使用return是合理的,但是編譯器是不會知道這一點的,所以我們還是需要關注下這方面限制。

"use strict";

var _button = require("button");
var _jsxFileName = "/sample.tsx";
/*#__PURE__*/React.createElement(_button.Button, {
  __self: void 0,
  __source: {
    fileName: _jsxFileName,
    lineNumber: 3,
    columnNumber: 1
  }
});

既然這個方式會有諸多的限制,需要關注和適配的地方比較多,那麼我們需要換一個思路,即在編譯程式碼的時候是完全符合語法規則的,並且不需要關注使用者的輸入,只需要將編譯出來的元件帶離出來即可,那麼我們可以利用傳遞的依賴,通過依賴的參照來實現,首先生成一個隨機id,然後設定一個空的物件,將編譯好的元件賦值到這個物件中,在渲染函數的最後通過物件和id將其返回即可。

export const renderWithDependency = (code: string, dependency: Sandbox) => {
  const id = getUniqueId();
  dependency.___BRIDGE___ = {};
  const bridge = dependency.___BRIDGE___ as Record<string, unknown>;
  const fn = new Function(
    "dependency",
    `with(dependency) { ___BRIDGE___["${id}"] = ${code.trim()}; }`
  );
  fn(dependency);
  return bridge[id];
};

在這裡我們依舊使用<Button />元件為例,直接使用Babel編譯的結果來對比一下,可以看出來即使我們沒有開啟production模式,編譯的結果也是符合語法的,並且因為傳遞參照的關係,我們能夠將編譯的元件範例通過___BRIDGE___以及隨機生成id帶出來。

"use strict";

var _jsxFileName = "/sample.tsx";
___BRIDGE___["id-xxx"] = /*#__PURE__*/React.createElement(Button, {
  __self: void 0,
  __source: {
    fileName: _jsxFileName,
    lineNumber: 1,
    columnNumber: 26
  }
});

此外我們還可以相對更完整地開放元件能力,通過約定來固定一個函數的名字例如App,在拼接程式碼的時候使用___BRIDGE___["id-xxx"] = React.createElement(App);,之後使用者便可以可以相對更加自由地對元件實現相關的互動等,例如使用useEffectHooks,這種約定式的方案會更加靈活一些,在應用中也比較常見比如約定式路由等,下面是約定App作為函數名編譯並拼接後的結果,可以放置於new Function並且藉助依賴的參照拿到最終生成的元件範例。

"use strict";

var _jsxFileName = "/sample.tsx";
const App = () => {
  React.useEffect(() => {
    console.log("Effect");
  }, []);
  return /*#__PURE__*/React.createElement(Button, {
    __self: void 0,
    __source: {
      fileName: _jsxFileName,
      lineNumber: 7,
      columnNumber: 10
    }
  });
};
___BRIDGE___["id-xxx"] = React.createElement(App);

渲染元件

在上文中我們解決了編譯程式碼、元件依賴、構建程式碼的問題,並且最終得到了元件的範例,在本節中我們主要討論如何將元件渲染到頁面上,這部分實際上是比較簡單的,我們可以選擇幾種方式來實現最終的渲染。

Render

React中我們渲染元件通常的都是直接使用ReactDOM.render,在這裡我們同樣可以使用這個方法來完成元件渲染,畢竟在之前我們已經得到了元件的範例,那麼我們直接找到一個可以掛載的div,將元件渲染到DOM上即可。

// https://github.com/WindrunnerMax/ReactLive/blob/master/src/index.tsx

const code = `<Button type='primary' onClick={() => alert(111)}>Primary</Button>`;
const el = ref.current;
const sandbox = withSandbox({ React, Button, console, alert });
const compiledCode = compileWithSucrase(code);
const Component = renderWithDependency(compiledCode, sandbox) as JSX.Element;
ReactDOM.render(Component, el);

當然我們也可以換個思路,我們也可以將渲染的能力交予使用者,也就是說我們可以約定使用者可以在程式碼中執行ReactDOM.render,我們可以對這個方法進行一次封裝,使使用者只能將元件渲染到我們固定的DOM結構上,當然我們直接將ReactDOM傳遞給使用者程式碼來執行渲染邏輯也是可以的,只是並不可控不建議這麼操作,如果可以完全保證使用者的輸入是可信的情況,這種渲染方法是可以的。

const INIT_CODE = `
render(<Button type='primary' onClick={() => alert(111)}>Primary</Button>);
`;
const render = (element: JSX.Element) => ReactDOM.render(element, el);
const sandbox = withSandbox({ React, Button, console, alert, render });
const compiledCode = compileWithSucrase(code);
renderWithDependency(compiledCode, sandbox);

SSR

實際上渲染React元件在Markdown編輯器中也是很常見的應用,例如在編輯時的動態渲染以及消費時的靜態渲染元件,當然在消費側時動態渲染元件也就是我們最開始提到的使用場景,那麼Markdown的相關框架通常是支援SSR的,我們當然也需要支援SSR來進行元件的靜態渲染,實際上我們能夠通過動態編譯程式碼來獲得React元件之後,通過ReactDOMServer.renderToString(多返回data-reactid標識,React會認識之前伺服器端渲染的內容, 不會重新渲染DOM節點)或者ReactDOMServer.renderToStaticMarkup來將HTML的標籤生成出來,也就是所謂的脫水,然後將其放置於HTML中返回給使用者端,在使用者端中使用ReactDOM.hydrate來為其注入事件,也就是所謂的注水,這樣就可以實現SSR伺服器端渲染了。下面就是使用express實現的DEMO,實際上也相當於SSR的最基本原理。

// https://codesandbox.io/p/sandbox/ssr-w468kc?file=/index.js:1,36
const express = require("express");
const React= require("react");
const ReactDOMServer = require("react-dom/server");
const { Button } = require("@arco-design/web-react");
const { transform } = require("sucrase");

const code = `<Button type="primary" onClick={() => alert(1)}>Primary</Button>`;
const OPTIONS = { transforms: ["jsx"], production: true };

const App = () => { // 伺服器端的`React`元件
  const ref = React.useRef(null);

  const getDynamicComponent = () => {
    const { code: compiledCode } = transform(`return (${code.trim()});`, OPTIONS);
    const sandbox= { React, Button };
    const withCode = `with(sandbox) { ${compiledCode} }`;
    const Component = new Function("sandbox", withCode)(sandbox);
    return Component;
  }

  return React.createElement("div", { ref }, getDynamicComponent());
}

const app = express();
const content = ReactDOMServer.renderToString(React.createElement(App));
app.use('/', function(req, res, next){
  res.send(
    `<html>
       <head>
         <title>Example</title>
         <link rel="stylesheet" href="https://unpkg.com/@arco-design/[email protected]/dist/css/arco.min.css">
         <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/react/17.0.2/umd/react.production.min.js" type="application/javascript"></script>
         <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/react-dom/17.0.2/umd/react-dom.production.min.js" type="application/javascript"></script>
       </head>
       <body>
         <div id="root">${content}</div>
       </body>
       <script src="https://unpkg.com/@arco-design/[email protected]/dist/arco.min.js"></script>
       <script>
        const App = () => { // 使用者端的\`React\`元件
          const ref = React.useRef(null);
          const getDynamicComponent = () => {
            const compiledCode = 'return ' + 'React.createElement(Button, { type: "primary", onClick: () => alert(1),}, "Primary")';
            const sandbox= { React, Button: arco.Button };
            const withCode = "with(sandbox) { " + compiledCode + " }";
            const Component = new Function("sandbox", withCode)(sandbox);
            return Component;
          }
          return React.createElement("div", { ref }, getDynamicComponent());
        }
        ReactDOM.hydrate(React.createElement(App), document.getElementById("root"));
        </script>
      </html>`
  );
})
app.listen(8080, () => {
  console.log("Listen on port 8080")
});

安全考量

既然我們選擇了動態渲染元件,那麼安全性必然是需要考量的。例如最簡單的一個攻擊形式,我作為使用者在程式碼中編寫了函數能取得當前使用者的Cookie,並且構造了XHR物件或者通過fetchCookie傳送到我的伺服器中,如果此時網站恰好沒有開啟HttpOnly,並且將這段程式碼落庫了,那麼以後每個開啟這個頁面的其他使用者都會將其Cookie傳送到我的伺服器中,這樣我就可以拿到其他使用者的Cookie,這是非常危險的儲存型XSS攻擊,此外上邊也提到了SSR的渲染模式,如果惡意程式碼在伺服器端執行那將是更加危險的操作,所以對於使用者行為的安全考量是非常重要的。

那麼實際上只要接受了使用者輸入並且作為程式碼執行,那麼我們就無法完全保證這個行為是安全的,我們應該注意的是永遠不要相信使用者的輸入,所以實際上最安全的方式就是不讓使用者輸入,當然對於目前這個場景來說是做不到的,那麼我們最好還是要能夠做到使用者是可控範圍的,比如只接受公司內部的輸入來編寫檔案,對外來說只是消費側不會將內容落庫展示到其他使用者面前,這樣就可以很大程度上的避免一些惡意的攻擊。當然即使是這樣,我們依然希望能夠做到安全地執行使用者輸入的程式碼,那麼最常用的方式就是限制使用者對於window等全域性物件的存取。

Deps

在前邊我們也提到過new Function是全域性的作用域,其是不會讀取定義時的作用域變數的,但是由於我們是構造了一個函數,我們完全可以將window中的所有變數都傳遞給這個函數,並且對變數名都賦予null,這樣當在作用域中尋找值時都會直接取得我們傳遞的值而不會繼續向上尋找了,無論是使用引數的形式或者是構造with都可以採用這種方式,這樣我們也可以通過白名單的形式來限制使用者的存取。當然這個物件的屬性將會多達上千,看起來可能並沒有那麼優雅。

const sandbox = Object.keys(Object.getOwnPropertyDescriptors(window))
  .filter(key => key.indexOf("-") === -1)
  .reduce((acc, key) => ({ ...acc, [key]: null }), {});

sandbox.console = console;
const code = "console.log(window, document, XMLHttpRequest, eval, Function);"

const fn = new Function(...Object.keys(sandbox), code.trim());
fn(...Object.values(sandbox)); // null null null null null

const withCode = `with(sandbox) { ${code.trim()} }`;
const withFn = new Function("sandbox", withCode);
withFn(sandbox); // null null null null null

Proxy

Proxy物件能夠為另一個物件建立代理,該代理可以攔截並重新定義該物件的基本操作,例如屬性查詢、賦值、列舉、函數呼叫等等,那麼配合我們之前使用with就可以將所有的物件存取以及賦值全部賦予sandbox,由此來更精確地實現對於物件存取的控制。下面就是我們使用Proxy來實現的一個簡單的沙箱,我們可以通過白名單的形式來限制使用者的存取,如果存取的物件不在白名單中,那麼直接返回null,如果在白名單中,那麼返回物件本身。

在這段實現中,with語句是通過in運運算元來判定存取的欄位是否在物件中,從而決定是否繼續通過作用域鏈往上找,所以我們需要將has控制永遠返回true,由此來阻斷程式碼通過作用域鏈存取全域性物件,此外例如alertsetTimeout等函數必須執行在window作用域下,這些函數都有個特點就是都是非建構函式,不能new且沒有prototype屬性,我們可以用這個特點來進行過濾,在獲取時為其繫結window

export const withSandbox = (dependency: Sandbox) => {
  const top = typeof window === "undefined" ? global : window;
  const whitelist: (keyof Sandbox)[] = [...Object.keys(dependency), ...BUILD_IN_SANDBOX_KEY];
  const proxy = new Proxy(dependency, {
    has: () => true,
    get(_, prop) {
      if (whitelist.indexOf(prop) > -1) {
        const value = dependency[prop];
        if (isFunction(value) && !value.prototype) {
          return value.bind(top);
        }
        return dependency[prop];
      } else {
        return null;
      }
    },
    set(_, prop, newValue) {
      if (whitelist.indexOf(prop) > -1) {
        dependency[prop] = newValue;
      }
      return true;
    },
  });

  return proxy;
};

如果大家用過TamperMonkeyViolentMonkey暴力猴、ScriptCat指令碼貓等相關谷歌外掛的話,可以發現其存在window以及unsafeWindow兩個物件,window物件是一個隔離的安全window環境,而unsafeWindow就是使用者頁面中的window物件。曾經我很長一段時間都認為這些外掛中可以存取的window物件實際上是瀏覽器拓展的Content Scripts提供的window物件,而unsafeWindow是使用者頁面中的window,以至於我用了比較長的時間在探尋如何直接在瀏覽器拓展中的Content Scripts直接獲取使用者頁面的window物件,當然最終還是以失敗告終,這其中比較有意思的是一個逃逸瀏覽器拓展的實現,因為在Content ScriptsInject Scripts是共用DOM的,所以可以通過DOM來實現逃逸,當然這個方案早已失效。

var unsafeWindow;
(function() {
    var div = document.createElement("div");
    div.setAttribute("onclick", "return window");
    unsafeWindow = div.onclick();
})();

此外在FireFox中還提供了一個wrappedJSObject來幫助我們從Content Scripts中存取頁面的的window物件,但是這個特性也有可能因為不安全在未來的版本中被移除。那麼為什麼現在我們可以知道其實際上是同一個瀏覽器環境呢,除了看原始碼之外我們也可以通過以下的程式碼來驗證指令碼在瀏覽器的效果,可以看出我們對於window的修改實際上是會同步到unsafeWindow上,證明實際上是同一個參照。

unsafeWindow.name = "111111";
console.log(window === unsafeWindow); // false
console.log(window); // Proxy {Symbol(Symbol.toStringTag): 'Window'}
console.log(window.onblur); // null
unsafeWindow.onblur = () => 111;
console.log(unsafeWindow); // Window { ... }
console.log(unsafeWindow.name, window.name); // 111111 111111
console.log(window.onblur); // () => 111
const win = new Function("return this")();
console.log(win === unsafeWindow); // true


// TamperMonkey: https://github.com/Tampermonkey/tampermonkey/blob/07f668cd1cabb2939220045839dec4d95d2db0c8/src/content.js#L476 // Not updated for a long time
// ViolentMonkey: https://github.com/violentmonkey/violentmonkey/blob/ecbd94b4e986b18eef34f977445d65cf51fd2e01/src/injected/web/gm-global-wrapper.js#L141
// ScriptCat: https://github.com/scriptscat/scriptcat/blob/0c4374196ebe8b29ae1a9c61353f6ff48d0d8843/src/runtime/content/utils.ts#L175
// wrappedJSObject: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts

如果觀察仔細的話,我們可以看到上邊的驗證程式碼最後兩行我們竟然突破了這些擴充套件的沙盒限制,從而在未@grant unsafeWindow情況下能夠直接存取unsafeWindow,從而我們同樣需要思考這個問題,即使我們限制了使用者的程式碼對於window等物件的存取,但是這樣真的能夠完整的保證安全嗎,很明顯是不夠的,我們還需要對於各種case做處理,從而儘量減少使用者突破沙盒限制的可能,例如在這裡我們需要控制使用者對於this的存取。

export const renderWithDependency = (code: string, dependency: Sandbox) => {
  const id = getUniqueId();
  dependency.___BRIDGE___ = {};
  const bridge = dependency.___BRIDGE___ as Record<string, unknown>;
  const fn = new Function(
    "dependency",
    `with(dependency) { 
      function fn(){  "use strict"; return (${code.trim()}); };
      ___BRIDGE___["${id}"] = fn.call(null);
    }
    `
  );
  fn.call(null, dependency);
  return bridge[id];
};

其實說到with,關於Symbol.unscopables的知識也可以簡單聊下,我們可以關注下面的例子,在第二部分我們在物件的原型鏈新增了一個屬性,而這個屬性跟我們的with變數重名,又恰好這個屬性中的值在with中被存取了,於是造成了我們的值不符合預期的問題,這個問題甚至是在知名框架Ext.js v4.2.1中暴露出來的,於是為了相容這個問題,TC39增加了Symbol.unscopables規則,在ES6之後的陣列方法中每個方法都會應用這個規則。

const value = [];
with(value){
  console.log(value.length); // 0
}

Array.prototype.value = { length: 10 };
with(value){
  console.log(value.length); // 10
}

Array.prototype[Symbol.unscopables].value = true;
with(value){
  console.log(value.length); // 0
}

// https://github.com/rwaldron/tc39-notes/blob/master/meetings/2013-07/july-23.md#43-arrayprototypevalues

Iframe

在上文中我們一直是使用限制使用者存取全域性變數或者是隔離當前環境的方式來實現沙箱,但是實際上我們還可以換個思路,我們可以將使用者的程式碼放置於一個iframe中來執行,這樣我們就可以將使用者的程式碼隔離在一個獨立的環境中,從而實現沙箱的效果,這種方式也是比較常見的,例如CodeSandbox就是使用這種方式來實現的,我們可以直接使用iframecontentWindow來獲取到window物件,然後利用該物件進行使用者程式碼的執行,這樣就可以做到使用者存取環境的隔離了,此外我們還可以通過iframesandbox屬性來限制使用者的行為,例如限制allow-forms表單提交、allow-popups彈窗、allow-top-navigation導航修改等,這樣就可以做到更加安全的沙箱了。

const iframe = document.createElement("iframe");
iframe.src = "about:blank";
iframe.style.position = "fixed";
iframe.style.left = "-10000px";
iframe.style.top = "-10000px";
iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
document.body.appendChild(iframe);
const win = iframe.contentWindow;
document.body.removeChild(iframe);
console.log(win && win !== window && win.parent !== window); // true

那麼同樣的我們也可以為其加一層代理,讓其中的物件存取都是使用iframe中的全域性物件,在找不到的情況下繼續存取原本傳遞的值,並且在編譯函數的時候,我們可以使用這個完全隔離的window環境來執行,由此來獲得完全隔離的程式碼執行環境。

export const withIframeSandbox = (win: Record<string | symbol, unknown>, proto: Sandbox) => {
  const sandbox = Object.create(proto);
  return new Proxy(sandbox, {
    get(_, key) {
      return sandbox[key] || win[key];
    },
    has: () => true,
    set(_, key, newValue) {
      sandbox[key] = newValue;
      return true;
    },
  });
};

export const renderWithIframe = (code: string, dependency: Sandbox) => {
  const id = getUniqueId();
  dependency.___BRIDGE___ = {};
  const bridge = dependency.___BRIDGE___ as Record<string, unknown>;
  const iframe = document.createElement("iframe");
  iframe.src = "about:blank";
  iframe.style.position = "fixed";
  iframe.style.left = "-10000px";
  iframe.style.top = "-10000px";
  iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
  document.body.appendChild(iframe);
  const win = iframe.contentWindow;
  document.body.removeChild(iframe);
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  const sandbox = withIframeSandbox(win || {}, dependency);
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  const fn = new win.Function(
    "dependency",
    `with(dependency) { 
      function fn(){  "use strict"; return (${code.trim()}); };
      ___BRIDGE___["${id}"] = fn.call(null);
    }
    `
  );
  fn.call(null, sandbox);
  return bridge[id];
};

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://swc.rs/docs/usage/wasm
https://zhuanlan.zhihu.com/p/589341143
https://github.com/alangpierce/sucrase
https://babel.dev/docs/babel-standalone
https://github.com/simonguo/react-code-view
https://github.com/LinFeng1997/markdown-it-react-component/