2020 年我写过一篇《使用TS 开发 JS 虚拟机》,讲了用 TypeScript 实现一个基于 AST 的 JS 解释器的基本思路。那篇文章之后项目没有停,2021 年 7 月正式立项重写,2022 年 2 月发了 1.0.0,之后进入维护期。现在回头整理,觉得值得把整套设计系统地复盘一遍,于是有了这个系列。这一篇先回答最源头的问题:为什么非得自己写一台虚拟机,以及它整体长什么样。
业务背景:代码必须能动态下发
我们的小程序&低代码平台有一个硬诉求:页面逻辑不能全部打进安装包,必须支持从远端拉取 JS bundle,在端上的沙箱里执行。小程序发版要走平台审核,周期以天计,而运营活动、页面模板、搭建物料的更新频率以小时计,两边差着数量级。低代码平台那边更直接,搭建产出的页面逻辑本身就是数据,天然要在运行时才确定。动态下发代码对这类业务不是优化项,是前提条件。
同时这段代码不能直接在宿主环境里跑。它来自远程,必须和宿主隔离:能访问什么全局对象、能调什么宿主 API,要由我们控制,而不是由代码自己决定。所以需要的不是一个 eval 的替代品,而是一个带沙箱边界的执行环境。
这个背景不是纸面推演。仓库的 dev.ts 里留着一个当时的真实调试样本,是从我们动态化平台拉下来的 loader bundle,编译后的样子大致如下:
function getTplFromTT(_x, _x2) {
return _getTplFromTT.apply(this, arguments);
}
function _getTplFromTT() {
_getTplFromTT = _asyncToGenerator(regeneratorRuntime.mark(function _callee(key, version) {
var res, bundle, _yield$get, data;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return post({
url: ".../config/theseus/checkList?bundleNames=".concat(key),
data: { appVersionName: version, platform: 'Android', bundles: [] }
});
case 2:
res = _context.sent;
bundle = res.data.bundles.find(function (item) {
return item.bundleName === key;
});
// ...拿到 bundle.url 再下载,返回的 data 里带 ast 字段这段代码有三个地方值得注意。第一,它是 babel 的编译产物,_asyncToGenerator、regeneratorRuntime 这些 helper 说明源码里的 async/await 在构建期就被降级成了 ES5 状态机,解释器实际要处理的是这种工具链生成、风格高度统一的代码,而不是手写玩具。第二,它干的就是动态化的标准流程:拿 key 和版本号去配置服务查 bundle 地址,下载,然后执行。第三,返回数据里直接带 ast 字段,也就是说下发的可以是解析好的语法树而不一定是源码文本,这一点后面讲架构时会用到。
选型:三条路都走不通
动态执行 JS,常规选项有三个。
一是用宿主能力,eval 或 new Function。微信小程序环境明确禁止这两个 API,这是平台限制,没有绕的空间,直接排除。
二是用市面上现成的 JS 解释器库。当时的评估方法很朴素:拿公开的测试集当验收线,跑 kangax 的 es5-testsuite 和 Test262 的相关章节。结论在 2020 年那篇文章里写过,已知的一些 VM 没有一家能完整通过 ES5 或 ES2015 的测试用例,无法在生产环境使用。解释器这种东西,大部分语义支持和能上线之间隔着很长一段路:变量提升、闭包、this 绑定、原型链、控制流和中止完成的交互,任何一处语义偏差落在业务代码里就是线上事故,而下发的代码我们又不可能逐行人工审查。
前两条都堵死之后,自研其实是唯一选项,剩下的是说服自己这条路的工作量可控。可控的依据有两个。其一,要执行的代码是编译产物,语法形态可以由构建工具链提前收窄,解释器不需要覆盖语言的全部表面积,这和「写一个通用 JS 引擎」是两个难度级别的任务。其二,解释器的核心机制并不复杂,复杂的是语义覆盖度,而覆盖度可以用测试一层层堆出来,每堆一层都有明确的验收标准。后来项目的演进基本验证了这两点:机制部分几周就立住了,之后一年多的工作量几乎全在语义和测试上。
思路一句话:babel 出 AST,visitor 前序遍历求值
jsvm2 的思路用一句话就能说完:用 @babel/parser 把源码解析成 AST,然后对 AST 做前序遍历,按节点类型分发到对应的求值函数。
这里有两个决策值得展开。一是不自己写 parser。JS 解析已经是被充分解决的问题,@babel/parser 覆盖全部语法且持续跟随标准,自研 parser 只会把人力耗在一个不产生差异化的环节上。真正的难点在语义求值,把解析外包出去,精力才能集中在该集中的地方。二是输入形态可以收窄。因为执行的是编译产物,解释器实际面对的语法分布远比语言全集窄:前面那段 bundle 里,async/await 已经被 regenerator 降级成 function、switch 和 while 组成的状态机,AST 里没有 generator 相关的节点。构建工具链每多承担一步降级,解释器就少实现一类语义,这笔账非常划算。
全景架构按数据流向是这样几环:
- 源码在端外由
@babel/parser解析成 AST。引擎本身不吃源码,只吃 AST,所以 parse 这一步完全可以在构建期完成,下发的直接是语法树,前面 bundle 里的data.ast就是这个用意,端上连解析成本都省了。 - AST 节点被包装成 Path。Path 除了节点本身,还携带父节点引用、所在作用域、上下文和调用栈,求值过程中需要的环境信息都从 Path 上取。
- visitor 按
node.type查表,把 Path 分发到对应节点类型的求值函数。 - 变量的声明和读写沿着 Scope 链进行,Scope 对应 ECMA-262 里的环境记录。
- 最外层是 Context,即沙箱的全局对象。宿主想暴露什么能力给沙箱,就注入到 Context 里;没注入的,沙箱代码就摸不到。
这个结构里没有自研解析器,没有字节码,没有 JIT。能前置到编译期的事情全部交给 babel 那一侧,解释器只管一种已经收窄过的输入形态。「编译期收窄解释期」是整个系列反复出现的一条主线,另一条是「能白嫖宿主的绝不自己写」:Object、Array、Promise 这些内建对象直接用宿主的,原型链和类型转换语义因此免费正确。后面每一篇都会回到这两句话上。
入口只有十几行
说得再多不如看入口。src/vm.ts:15-27 是 runInContext 的全部:
export function runInContext(ast: any, context = createContext()) {
const s = Scope.createRoot(context);
// define module
const $exports = {};
const $module = { exports: $exports };
s.declareConst(MODULE, $module);
s.declareVar(EXPORTS, $exports);
const path = new Path(ast, null, s, {}, new Stack());
const res = visitor(path);
// exports
const moduleVar = s.hasOwnBinding(MODULE);
return moduleVar ? moduleVar.value.exports : res;
}这十几行把架构图的每一环都落实了:Scope.createRoot(context) 建根作用域并把 Context 挂上去;new Path(ast, null, s, {}, new Stack()) 把 AST 包装成根 Path,带上一个空的调用栈;visitor(path) 开始遍历求值。执行结束后从根作用域里把 module 取出来,返回它的 exports。
中间那段 module/exports 值得单独说一句。这里没有实现任何模块系统,只是在根作用域里预声明了两个变量:module 指向 { exports: {} },exports 指向同一个对象。沙箱代码里写 module.exports = xxx 时,走的只是普通的成员赋值语义;执行完引擎把 module.exports 取出来作为这次执行的返回值。前面那段 loader bundle 末尾的 exports.checkList = checkList 能被正确导出,靠的就是这两行声明。CommonJS 外壳语义的全部成本就是两个变量,这是「白嫖」思路的第一个样本。同一个文件里还有一个 run 函数(vm.ts:29-34),差别只是去掉了 module 外壳,直接返回求值结果,适合不需要模块语义的场景。
Context:沙箱的边界在哪
沙箱全局对象的实现在 src/context.ts,思路同样简单。引擎先准备一份内置全局清单 CTX:Infinity、NaN、parseInt、Object、Function、Error 家族、Number、Math、Date、String、RegExp、Array、JSON、定时器、console,再按宿主环境探测补上 Symbol、各类 TypedArray、Map、Set、Promise、Reflect、Proxy 等。注意这些值全是宿主环境里的真身,沙箱里的 Object 就是宿主的 Object,所以原型链、instanceof、各种内建方法的行为不用解释器操心。
Context 的构造器只有一件事:把 CTX 和调用方传入的外部对象合并,逐个 key 挂到自己身上。业务侧想给沙箱开放能力,比如小程序里的 wx.request,就 createContext({ wx }) 注进去;没注的 API,沙箱代码访问时就是未声明变量。动态下发的代码能摸到多大的世界,完全由这一次注入决定。
这里也有一个如实交代的毛边。context.ts:55-58 有一段探测逻辑:如果宿主环境存在 eval,就把它放进 CTX。本意是兼容浏览器场景,副作用是在浏览器里沙箱代码能拿到宿主 eval,沙箱边界因此多出一个缺口。小程序环境里 eval 本身是 undefined,这个分支自然不会触发,所以线上业务没受影响,但它说明内置清单这种「先全给再控制」的思路是有代价的,更稳妥的方向是默认最小集合、按需开放。
证据链:怎么知道它能用
选型阶段最大的质疑一定是:自研引擎的语义覆盖度凭什么够?当时的回答是一组可以核查的事实,而不是一句「我们测过了」。
语法覆盖上,readme 里的 ES5 清单除 WithStatement 外全部打钩。with 不实现不是偷懒,是实现不了:@babel/parser 在严格模式下直接禁用 with 语句,而引擎整体表现为严格模式,AST 里根本不会出现这个节点,没有可实现的输入。ES2015 是部分支持,let/const、箭头函数、解构、展开运算符这些编译产物里高频出现的特性做了;class、模板字符串、import/export 这些能由 babel 在构建期降级掉的就没做。这张清单是诚实的,打钩和没打钩都写在 readme 里。第 6 篇会拿实测结果逐项核对它,其中有打钩打早了的地方,到时如实说。
测试上,单测覆盖率 91%。用例来源有四类:kangax 的 es5-testsuite、Test262 的精选章节、《JavaScript 高级程序设计》里的经典 case、JS 面试陷阱题。es5-testsuite 和 Test262 是社区维护的规范符合性测试,跑它们是把「我以为实现了」换成「规范认为实现了」;高程 case 和面试题的价值在于它们专门瞄准提升、闭包、this、原型链这些实现者最容易想当然的地方,很多用例人看着都会答错,拿来压解释器正好。测试怎么搭、为什么这样分层、跑通过程中修了什么,是第 7 篇的全部内容。
生态验证上做了两件更硬的事。一是把 lodash 的全量 API 放进 VM 里跑,用 lodash 自己的官方 spec 回归,覆盖真实库里各种边角用法。二是把 React 服务端渲染完整跑了起来,这个在 2021 年 11 月达成,一次跑通的过程中顺手修掉了作用域实现、this 绑定等一批只有真实框架才压得出来的 bug。选 SSR 而不是浏览器渲染,是因为 SSR 输出是字符串,断言稳定,且不依赖 DOM。这两件事的分量和单测不同:单测是解释器作者自己选的考题,真实库和框架不会配合你,它们用到什么语义你就必须答对什么语义,答错就是直接抛错或结果不对。一个能把 React 渲染到字符串的解释器,语义覆盖度基本不需要再口头辩护。
时间线顺带交代完整:2021 年 7 月立项开发,11 月跑通 React SSR,12 月接入 lodash 回归,2022 年 2 月发布 1.0.0,之后是维护期。这个系列是 2023 年对整个过程的复盘,各篇里既有设计决策,也有当时踩的坑和至今没补的语义缺口,都会如实写。
系列目录
接下来六篇按实现层次展开,每天一篇:
| 日期 | 标题 |
|---|---|
| 06-12 | JSVM2:为什么小程序需要一台 JS 虚拟机(本篇) |
| 06-13 | JSVM2:解释器的骨架,visitor、Path 与作用域链 |
| 06-14 | JSVM2:变量提升与闭包,两阶段扫描与一道经典面试题 |
| 06-15 | JSVM2:return/break/continue 怎么传,Signal 信号与 finally 的坑 |
| 06-16 | JSVM2:函数、this 与 new,能白嫖的绝不自己写 |
| 06-17 | JSVM2:表达式求值的现实主义,左值、运算符与 ES2015 裁剪 |
| 06-18 | JSVM2:怎么证明一个 JS 引擎是对的 |
下一篇预告:从 visitor 分发器、Path 五元组和 Scope 环境记录讲起,看这台解释器的骨架为什么只有几百行。

