初探富文本之React實時預覽

初探富文本之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/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章