• 初探富文本之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来实现动态代码执行的。

    Copy
    "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代码的,这其中一个问题就是浏览器并不知道这个组件是什么,例如我们从组件库引入了一个#

    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中是可以正常处理的。

    Copy
    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
    Copy
    <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的组件函数,一旦声明其他函数则抛出解析失败的异常,我们也可以选择移除当前节点。当然仅仅是这些还是不够的,关于安全的相关问题我们后续还需要继续讨论。

    Copy
    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场景下是足够的。

    Copy
    const getCode = () => { const CHUNK = ` `; return "
    " + new Array(1000).fill(CHUNK).join("") + "
    "
    ; }; console.time("babel"); const code = getCode(); const result = compileWithBabel(code); console.timeEnd("babel");
    Copy
    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来实现,还是有一定的学习成本,我们现在还是关注代码的转换能力。

    Copy
    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
    Copy
    <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的结果效率会提高很多,初次加载过后的速度就能够保持比较高的水平了。

    Copy
    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");
    Copy
    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是没有办法做插件来处理代码中间产物的,所以在需要处理代码的情况下,我们需要使用正则表达式自行匹配处理相关代码。

    Copy
    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
    Copy
    <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是不错的选择。

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

    代码构造#

    在上一节我们解决了浏览器无法直接执行React代码的第一个问题,即浏览器不认识形如#

    在这里因为我们后边需要用到new Function以及with语法,所以在这里先回顾一下。通过Function构造函数可以动态创建函数对象,类似于eval可以动态执行代码,然而与具有访问本地作用域的eval不同,Function构造函数创建的函数仅在全局作用域中执行,其语法为new Function(arg0, arg1, /* ... */ argN, functionBody)

    Copy
    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语句。

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

    那么紧接着我们就来解决一下组件的依赖问题,还是以#

    在上边我们解决了依赖的问题,并且对于安全问题做了简述,只不过到目前为止我们都是在处理字符串,还没有将其转换为真正的React组件,所以在这里我们专注于将React组件对象从字符串中生成出来,同样的我们依然使用new Function来执行代码,只不过我们需要将代码字符串拼接成我们想要的形式,由此来将生成的对象带出来,例如#

    在上文中我们解决了编译代码、组件依赖、构建代码的问题,并且最终得到了组件的实例,在本节中我们主要讨论如何将组件渲染到页面上,这部分实际上是比较简单的,我们可以选择几种方式来实现最终的渲染。

    Render#

    React中我们渲染组件通常的都是直接使用ReactDOM.render,在这里我们同样可以使用这个方法来完成组件渲染,毕竟在之前我们已经得到了组件的实例,那么我们直接找到一个可以挂载的div,将组件渲染到DOM上即可。

    Copy
    // https://github.com/WindrunnerMax/ReactLive/blob/master/src/index.tsx const code = ``; 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传递给用户代码来执行渲染逻辑也是可以的,只是并不可控不建议这么操作,如果可以完全保证用户的输入是可信的情况,这种渲染方法是可以的。

    Copy
    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的最基本原理。

    Copy
    // 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都可以采用这种方式,这样我们也可以通过白名单的形式来限制用户的访问。当然这个对象的属性将会多达上千,看起来可能并没有那么优雅。

    Copy
    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

    Copy
    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来实现逃逸,当然这个方案早已失效。

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

    此外在FireFox中还提供了一个wrappedJSObject来帮助我们从Content Scripts中访问页面的的window对象,但是这个特性也有可能因为不安全在未来的版本中被移除。那么为什么现在我们可以知道其实际上是同一个浏览器环境呢,除了看源码之外我们也可以通过以下的代码来验证脚本在浏览器的效果,可以看出我们对于window的修改实际上是会同步到unsafeWindow上,证明实际上是同一个引用。

    Copy
    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的访问。

    Copy
    export const renderWithDependency = (code: string, dependency: Sandbox) => { const id = getUniqueId(); dependency.___BRIDGE___ = {}; const bridge = dependency.___BRIDGE___ as Record; 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之后的数组方法中每个方法都会应用这个规则。

    Copy
    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导航修改等,这样就可以做到更加安全的沙箱了。

    Copy
    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环境来执行,由此来获得完全隔离的代码运行环境。

    Copy
    export const withIframeSandbox = (win: Record, 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; 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]; };

    每日一题#

    Copy
    https://github.com/WindrunnerMax/EveryDay

    参考#

    Copy
    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/
  • 相关阅读:
    SpringBoot电商项目进阶Day5
    React-Router路由
    嵌入式Linux应用开发-基础知识-第十八章系统对中断的处理①
    MySQL解决group by分组后未排序问题
    【Java】Java中的零拷贝
    腾讯mini项目-【指标监控服务重构】2023-08-24
    Kafka集群架构设计原理详解
    final关键字
    [Realtek sdk-3.4.14b]RTL8197FH-VG 2.4G to WAN吞吐量低于60%的问题分析及解决方案
    Thread 类的基本用法
  • 原文地址:https://www.cnblogs.com/WindrunnerMax/p/17765552.html