沙箱实现
沙箱是低代码项目的运行时环境,负责在浏览器中将低代码项目的代码渲染成最终页面,并与引擎交互,实现页面的搭建与配置。Tango 的沙箱方案是基于源码实现的,而源码的写法多种多样,此外还需要考虑三方依赖的加载运行,因此 Tango 的沙箱需要实现一套完整的代码运行时,甚至需要对 Node API 进行一定的兼容。
Tango 目前采用的沙箱方案是基于 CodeSandbox 中的 Sandpack 实现的。相比传统的 JS Loader 实现的沙箱方案,它提供了更完整的运行时环境,通过 browserfs 等模拟兼容本地环境,再借助 Babel 将 ESM 转译为 CommonJS,可以支持三方依赖在浏览器上运行。此外,Sandpack 提供了一个独立的 iframe 运行代码,可以实现代码的运行时环境隔离,避免污染全局变量。
本文主要引用了 CodeSandbox 如何工作? 上篇 的部分内容,并在此基础上进行了修改。如果你对 CodeSandbox 底层的更多细节感兴趣,可以参考这篇文章。
CodeSandbox 的基本结构
CodeSandbox 是一个在线运行 JavaScript 代码的平台,它通过兼容 Node.js 与 CommonJS 层,借助 Service Worker 等能力在浏览器上实时转译与运行代码。你可以把它的转译能力想象成一个在浏览器上运行的 webpack,不过因为 webpack 实在是太庞大了,因此 CodeSandbox 的作者重新设计了一套运行时转译方案。
这套方案和 webpack 类似,比如它的转译逻辑就和 webpack 的 loader 比较接近。不过由于 CodeSandbox 能自己处理依赖关系与转译,因此它省略掉了 webpack 上的大量功能,主要聚焦于代码的转译上。而且由于 CodeSandbox 能自己处理转译逻辑,因此在初始化依赖时能忽略掉绝大多数的 devDependencies
,从而大幅减少项目的初始化时间与转译时间。
CodeSandbox 可以简化为三个部分:
- Editor:编辑器,供用户修改源码后同步到沙箱(Tango 没有使用,我们只使用了沙箱 Sandpack 的能力)
- Sandbox:代码运行器,是一个独立的 iframe,负责转译和运行
- Packager:包管理器,类似 yarn 和 npm,负责拉取和缓存 npm 依赖
它的工作流程可以简述如下:
- 代码准备:平台引用 Sandpack 组件,通过
postMessage
将 Editor 里的代码传递给沙箱 - 依赖初始化:沙箱处理传入的文件,根据
package.json
的dependencies
调用 Packager 打包服务获取依赖 - 转译代码:解析代码的依赖关系,将依赖的代码通过对应的 Transpiler 转译
- 执行代码:在沙箱中初始化 html 等,然后从代码的入口文件开始执行转译后的代码
- 上述执行周期内和执行完成后,沙箱会抛出事件让平台感知
依赖的初始化
如前所述,由于 CodeSandbox 自行实现了核心的转译逻辑,因此在初始化依赖时可以相对轻量一些,只需获取 dependencies
里的依赖,而忽略掉 devDependencies
以及 @types
开头的依赖。那么 CodeSandbox 是如何获取依赖的呢?CodeSandbox 实现了两套方案,一套是默认的远程在线打包方案,另一套是从 unpkg/jsdelivr 等静态资源获取依赖的兜底方案。
这里我们先来谈谈兜底的实时获取静态资源的方案,它主要分为如下几个步骤:
- 代码传入后,通过
package.json
获取解析项目的入口文件与dependencies
- 从 unpkg 或 jsdelivr 上拉取
/package.json
获取该依赖的入口文件与子依赖 - 从 unpkg 或 jsdelivr 上拉取 meta 信息接口,获取依赖下的文件列表
- 从入口文件开始,解析代码里的
require
语句,逐一递归获取其他需要的所有文件,再根据上述信息生成依赖信息
可见上述的流程在浏览器前端操作的话,会有很多并发请求,时间大部分也浪费在了网络请求上。那么如何优化呢?其实 CodeSandbox 早就想到了。CodeSandbox 最早实现的方案就是在线打包完整依赖,它通过一个 Serverless 服务在线拉取依赖,然后走类似上面的流程一次性返回所有需要的文件:
上述流程在浏览器前端操作时,会有很多并发请求,时间大部分也浪费在了网络请求上。其实 CodeSandbox 早就想到了,它设计了一个 Serverless 服务 dependency-packager 用于在线拉取依赖,然后一次性返回所有需要的文件。这样当 CodeSandbox 需要依赖时,会调 用在线的打包服务从缓存中拿到依赖,减少前端的网络请求阻塞。
dependency-packager 的整体流程大致如下:
- packager 接收到 API 请求,解析 API 地址,取出包名与版本号
- 清理临时文件,然后调用
yarn add
安装依赖到临时目录下 - 获取所有依赖的
package.json
文件,并解析与确定入口文件的路径 - 遍历依赖目录,拿到该依赖下需要通过
require
引入的所有文件 - 将上述文件需要引入的文件路径记录下来
- 根据上述所有信息生成包与包之间的依赖信息:
- peerDependencies:该包在
package.json
里定义的peerDependencies
- dependencyDependencies:该包的关联信息,包括父依赖、子依赖(根据引入的文件路径遍历生成)、依赖的版本与最终安装的版本(根据父子依赖的
package.json
生成) - dependencyAliases:可选,包别名,例如
react
作为preact-compat
的别名
- peerDependencies:该包在
- 将上述所有数据组装为 Manifest,通过接口返回,同时缓存数据到 S3
- 清理临时文件
这样,当 CodeSandbox 需要依赖时,会调用在线的打包服务获取依赖需要的所有文件,减少前端的网络请求阻塞。当然这个方法也并不是一直有效的,例如如果一个依赖比较新而从未缓存过,packager 安装依赖过慢导致无法马上返回结果,或者是服务暂时无法访问,或是在转译的过程中引入了被 packager 忽略的文件,此时会使用之前提到的调用 unpkg 或者 jsdelivr 的兜底方案。
转译与构建
之前我们提到,CodeSandbox 的转译整体逻辑和 webpack 类似,但因为 webpack 过于繁重,所以 CodeSandbox 的作者没有直接使用 webpack 的能力,而是重新实现了一套转译方案,可以将其理解为精简版的 webpack。当 CodeSandbox 开始转译时,会调用 compile()
方法开始转译,整个转译流程有如下几个核心对象,它们的关系如下图:
- Manager:这是沙箱转译流程里的核心对象,负责管理配置信息(Preset)、项目依赖(Manifest)、以及维护项目所有模块(TranspilerModule)
- Manifest:上文的 Packager 最终生成的所有依赖的信息
- TranspiledModule:代码转译后的模块本身,当代码传入时会实例化为该对象。它维护着转译的结果、代码执行的结果、依赖的模块信息,负责驱动具体模块的转译(调用 Transpiler)和执行
- Preset:项目构建模板,例如
vue-cli
、create-react-app
,配置了项目文件的转译规则,就像 webpack 的webpack.config.js
文件 - Transpiler:等价于 Webpack 的 loader,负责对指定类型的文件进行转译,例如 Babel、TypeScript、Sass、Less 等等
- WorkerTranspiler:这是继承自 Transpiler 的一个类,它能调度一个 Worker 池来执行转译任务,从而提高复杂逻辑(如 Babel)转译的性能
整个沙箱生命周期的转译流程大致如下:
沙箱在接收到代码后会调用 compile()
方法,该方法会根据传入的 template
对应到具体的 Preset,然后实例化控制整个生命周期的 Manager 实例并处理依赖。当依赖全部 resolved 后,传入的文件将会被实例化为 TranspiledModule,构成模块的依赖树。接下来,Manager 会逐一转译依赖到的模块,转译完成后再从入口的文件开始执行转译后的代码。
Preset
Preset 是项目构建模板,不同的模板定义了不同的转译规则,例如精简后的 create-react-app
的 preset 有如下定义:
const preset = new Preset(
'create-react-app', ['js', /* ... */], aliases, {
hasDotEnv: true,
processDependencies: async originalDeps => ({
'@babel/core': '^7.3.3',
'@babel/runtime': '^7.3.4',
...originalDeps,
}),
setup: async manager => {
if (initialized) { return; }
preset.resetTranspilers();
preset.registerTranspiler(
module => (
/\.(m|c)?(t|j)sx?$/.test(module.path) &&
!module.path.endsWith('.d.ts')
),
[
{
transpiler: babelTranspiler,
options: { /* ... */ },
}
]
);
preset.registerTranspiler(
module => /\.css$/.test(module.path),
[
{ transpiler: postcssTranspiler },
{
transpiler: stylesTranspiler,
options: { hmrEnabled: isRefresh },
},
]
);
// Try to preload jsx-runtime
manager
.resolveTranspiledModuleAsync('react/jsx-runtime')
.then(x => { x.transpile(manager); });
},
preEvaluate: async manager => {
if (manager.isFirstLoad && manager.reactDevTools) {
await initializeReactDevToolsLatest();
}
},
},
);
可以看到该 Preset 主要做了如下几个事情:
- 在
processDependencies
里可以对代码传入的依赖信息做修改,例如这里就补充了 Babel 的依赖,可以保证一些必须的依赖即便不在dependencies
里也会被引入 - 在
setup
里注册了 Transpiler,例如对 .mjs、.cjs、.js、.jsx、.ts、.tsx 等文件注册了 BabelTranspiler 转译为 CommonJS 模块,对 .sass、.less 等文件注册了对应的 Transpiler 并通过 postcss 处理等 - 在
preEvaluate
里可以在执行前做一些其他操作,例如这里加载了 React DevTools 方便调试
Transpiler
Transpiler 负责对特定文件进行转译,它就像是 webpack 里的 loader。它的定义非常简单:
export abstract class Transpiler {
cacheable: boolean;
name: string;
HMREnabled: boolean;
constructor(name: string) {
this.cacheable = true;
this.name = name;
this.HMREnabled = true;
}
initialize() {}
dispose() {}
cleanModule(loaderContext: LoaderContext) {}
abstract doTranspilation(
code: string,
loaderContext: LoaderContext
): Promise<TranspilerResult> | TranspilerResult;
/* eslint-enable */
transpile(
code: string,
loaderContext: LoaderContext
): Promise<TranspilerResult> | TranspilerResult {
return this.doTranspilation(code, loaderContext);
}
getTranspilerContext(
manager: Manager
): Promise<object> {
return Promise.resolve({
name: this.name,
HMREnabled: this.HMREnabled,
cacheable: this.cacheable,
});
}
}
除了初始化的逻辑外,Transpiler 的核心就是 doTranspilation
方法,它负责对代码进行转译,并返回转译后的结果。它接收两个参数,一个是当前文件的源代码,另一个是当前的上下文,例如当前的文件路径、转译器的配置参数、获取与注册依赖等。
例如下面是 JSONTranspiler
的实现,它负责将 JSON 文件转译为标准的 js export:
import { Transpiler } from 'sandpack-core';
class JSONTranspiler extends Transpiler {
doTranspilation(code: string) {
const result = `
module.exports = JSON.parse(${JSON.stringify(code || '')})
`;
return Promise.resolve({
transpiledCode: result,
});
}
}
const transpiler = new JSONTranspiler('json-loader');
export { JSONTranspiler };
export default transpiler;
或是 ReactSVGTranspiler
的实现,它负责将 svg 文件转译为 React 组件:
import { LoaderContext, Transpiler } from 'sandpack-core';
class ReactSVGTranspiler extends Transpiler {
constructor() {
super('react-svg-loader');
}
async doTranspilation(code: string, loaderContext: LoaderContext) {
const transpiledCode =
`import link from ${JSON.stringify(
`!base64-loader!${loaderContext.path}`
)};` +
`export default link;` +
`export const Url = link;` +
`export { default as ReactComponent } from ${JSON.stringify(
`!babel-loader!svgr-loader!${loaderContext.path}`
)};`;
return {
transpiledCode,
};
}
}
const transpiler = new ReactSVGTranspiler();
export { ReactSVGTranspiler };
export default transpiler;
当需要转译时,Manager 会调用 TranspiledModule 的 transpile()
方法。TranspiledModule 此时会生成 loaderContext 并根据定义的 Preset 获取需要调用的 Transpiler,然后逐一遍历 Transpiler 并执行 transpile()
方法,经过 doTranspilation()
的转译处理后,得到最终转译后的代码。转译完成后,TranspiledModule 会处理当前文件的依赖关系,然后再调用子依赖的 TranspiledModule 的 transpile()
方法逐一转译。
WorkerTranspiler
上面只是一些简单的转译的逻辑,但是对于复杂的转译过程,如果还使用上面的同步方法转译,势必会带来性能问题。所以对于复杂的转译逻辑,可以通过实现 WorkerTranspiler 来提升转译效率。WorkerTranspiler 的定义如下图所示:
WorkerTranspiler 继承自 Transpiler,同时内部还引入了一个 WorkerManager 来负责 worker 的调度。当 Transpiler 实例化 WorkerManager 时,会指定并行的 worker 数量,同时注册一些与 loaderContext 相关的方法,方便在 worker 内也能调用到相关的方法。WorkerManager 内部维护了一个 worker 队列与执行队列,当 WorkerTranspiler 传入转译任务时,WorkerManager 会将其加入执行队列 pendingCalls
中。在转译的生命周期开始与结束时调用 executeRemainingTasks()
方法将队列的方法分配至空闲的 worker 中执行,然后异步返回执行的结果,实现多线程转译。
一个典型的例子就是 BabelTranspiler,它的转译流程大致如下:
代码执行
经过上面的转译流程后,现在项目所需的代码已经准备好了,接下来就是如何执行了。沙箱的代码执行的实现如下:
const g = typeof window === 'undefined' ? self : window;
export default function (
code: string,
require: Function,
module: { exports: any },
env: Object = {},
globals: Object = {},
{ asUMD = false }: { asUMD?: boolean } = {}
) {
const { exports } = module;
const global = g;
const process = buildProcess(env);
g.global = global;
const allGlobals: { [key: string]: any } = {
require,
module,
exports,
process,
global,
...globals,
};
if (asUMD) {
delete allGlobals.module;
delete allGlobals.exports;
delete allGlobals.global;
}
/* ... */
const allGlobalKeys = Object.keys(allGlobals);
const globalsCode = allGlobalKeys.length
? allGlobalKeys.join(', ') : '';
const globalsValues = allGlobalKeys.map(k => allGlobals[k]);
try {
const newCode =
`(function $csb$eval(` + globalsCode + `){` + code + `\n})`;
// @ts-ignore
(0, eval)(newCode).apply(allGlobals.global, globalsValues);
return module.exports;
} catch (e: any) {
/* ... */
}
}
- 获取传入的
index.html
文件,解析并通过设置document.body.innerHTML
来设置 html 里 body 部分的内容 - 注入 html 里定义的外部 css 与 js 资源
- 模拟 CommonJS 所需的环境,如
require
、module
、exports
、global
等方法与变量 - 从入口模块开始执行,执行时会将代码封装为立即执行函数的形式,然后调用
eval()
执行并传入上述变量 - 当模块调用了
require()
方法时,会按照上述流程递归执行响应的文件 - 执行完毕后返回
module.exports
,执行完成
经过上述流程后,项目中的代码就会被转译并执行,最终渲染在沙箱里,你就能看到代码的实际效果了。后续传入沙箱的代码更新,会通过和已有的代码对比,将变更的部分重新转译为 TranspiledModule,再经过上述的转译与执行流程,来实现视图的更新。