初探富文本之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能力的能力比较好理解,而对接接口平台需要动态渲染组件的原因是我们的数据结构大概率是无法平齐的,例如某些文本需要加粗,成本最低的方案就是我们直接组装为的标签,并入已有组件库的折叠表格中将其渲染出来即可。

我们在这里也简单聊一下富文本中实现预览能力可以参考的方案,预览块的结构实际上很简单,无非是一部分是代码块,在编辑时另一部分可以实时预览,而在富文本中实现代码块一般都会有比较多的示例,例如使用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代码的,这其中一个问题就是浏览器并不知道这个组件是什么,例如我们从组件库引入了一个 `; return "

" + new Array(1000).fill(CHUNK).join("") + "
"; }; 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代码的第一个问题,即浏览器不认识形如`; 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();
`;
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 = ``;
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(
    `
       
         Example
         
         
         
       
       
         
${content}
`
); }) 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/

你可能感兴趣的:(Plugin,react.js,前端,富文本)