上一篇讲到 ES2015 的裁剪,结尾留了一个问题:一个语义覆盖只有「够用」水平的解释器,凭什么敢上生产。这一篇是系列收官,回答这个问题。我的答案是:自研引擎的核心竞争力不在实现,在验证。实现一台能跑 demo 的解释器,一个周末就够;难的是证明它在你关心的输入分布上是对的。jsvm2 敢上生产,靠的是一套五层测试组合拳,外加一条「编译期收窄解释期」的工具链。这篇把这套东西完整讲一遍。
五层测试
测试用例不是一次规划出来的,是开发过程中一层层加上去的,每一层对应一类当时真实担心的问题。
第一层是规范用例。__tests__/es5-testsuite/ 目录下放了 7 个文件,文件名就是 ES5 规范的章节号:10.6(arguments 对象)、11.1(主表达式)、11.4(一元运算符)、11.13(赋值)、12.14(try 语句)、15.2(Object)、15.7(Number)。用例取材自 test262 的精选章节,共 63 例。test262 全量有几万例,里面大量篇幅在测 Date 的边界、正则的地区行为这类和沙箱业务无关的东西,全跑没有意义,所以只挑了语义核心章节。这一层管的是:规范里写明的行为,引擎做对了吗。
第二层是教材模式。__tests__/es5/ 和 __tests__/es2015/ 两个目录按语言机制组织,function、loop、prototype、scope-chain、tryStatement 这样一类一个文件。这批用例的写法接近《JavaScript 高级程序设计》里的经典示例,是开发引擎时的主力回归集,每修一个 bug 先在这里补一条。这一层管的是引擎自己的子系统行为是否稳定。
第三层是面试陷阱题。__tests__/case/ 目录,callback-function、chain、for-in、ins-call、wrap-function、class-call,每个文件对应一类当年把我坑过的语义。教科书不强调、但真实代码里会出现的刁钻组合,就靠这一层兜底。
第四层是真实编译产物回归。__tests__/case/esbuild.test.ts 直接把 esbuild 打包输出的一段真实产物贴进来跑,__defProp、__markAsModule、__export 这些 helper 原样保留。引擎在线上执行的从来不是手写代码,而是工具链生成的代码,这层测试对着最真实的输入分布做回归。
第五层是生态集成。__tests__/framework/lodash/ 下有三个 spec 文件,共 36 个用例,做法是把整个 lodash 构建产物读进来,在 VM 里执行,拿到 _ 之后再跑 lodash 官方的 API 测试(__tests__/framework/lodash/lodash.ts:5-9)。这个结构有个值得说的细节:lodash 本体在沙箱里被解释执行,而 spec 断言跑在 jest 里,跨了两个世界。沙箱里产生的数组、对象被拿到宿主侧做 toEqual 比较,等于顺带验证了沙箱值和宿主值在边界上的互操作。注入的上下文也很克制,只有 { self: global, global, console, require } 四样,刚好够 lodash 的环境嗅探代码找到全局对象。lodash 的代码量大、用到的语言特性杂,还自带权威答案,是性价比很高的一致性测试。
五层加起来三百来个用例,单测覆盖率 91%。这个数字本身不说明什么,说明的是组合方式:规范、教材、陷阱、真实产物、生态库,每一层堵一类漏。
三个刁钻用例
光看层次不够,挑三个具体用例看看这些测试在防什么。
第一个是 catch 参数的 var 穿透,来自 __tests__/es5-testsuite/12.14.test.ts 的 12.14-1:
function testcase() {
foo = "prior to throw";
try {
throw new Error();
} catch (foo) {
var foo = "initializer in catch";
}
return foo === "prior to throw";
}catch (foo) 的参数绑定在一个独立的 catch 作用域里,但 var foo 的声明要穿透这个作用域提升到函数级。所以 catch 块里的赋值写的是 catch 参数,外层的 foo 必须保持 "prior to throw" 不变。这条用例同时考三件事:catch 作用域的独立性、var 提升的爬升方向、同名绑定的归属判定。第 3 篇讲的 declareVar 爬升逻辑(scope.ts:98-103)就是靠这类用例喂出来的。
第二个是命名函数表达式的遮蔽优先级,来自 __tests__/case/callback-function.test.ts:
function b(cb) {
return cb();
}
function a(e) {
b(function e() {
return e;
});
return e;
}
module.exports = a(1)回调里的 e 要解析到函数表达式自己的名字(自我绑定,递归靠它),而外层的 return e 要解析到参数 e,结果是 1。同一个标识符在两行代码里指向完全不同的绑定。这个用例是真实 bug 的回归,对应第 3 篇讲过的具名函数表达式自我绑定(scope.ts:92-95)。
第三个是严格模式下 arguments 与形参解绑,来自 __tests__/es5-testsuite/10.6.test.ts 的 10.6-10-c-ii-1-s:
function foo(a, b, c) {
'use strict';
a = 1; b = 'str'; c = 2.1;
if (arguments[0] === 10 && arguments[1] === 'sss' && arguments[2] === 1)
return true;
}非严格模式下改形参会同步改 arguments,严格模式下两者解绑,改了 a,arguments[0] 还得是调用时传入的 10。jsvm2 的 arguments 是白嫖宿主的(第 5 篇),引擎代码跑在宿主模块的严格模式里,这个语义白捡。但白捡的语义也得有用例钉住,否则哪天重构时手写一个 arguments 绑定,就把对的改成错的了。
测试管线即架构
测试用例本身只是一半,另一半是 __tests__/helper.ts 里那条执行管线。所有用例都经过同一个 run 函数,这个函数就是第 1 篇说的「编译期收窄解释期」主线的具体形态:
export const run = function (code, ctx = {}, hoisting = true, convertES5 = false) {
let transformCode = code;
if (convertES5) { /* babel preset-env + assumptions */ }
if (hoisting) { /* babel plugin: hoisting */ }
if (process.env.TERSER === 'true') {
transformCode = minifySync(transformCode);
}
const ast = parse(transformCode, { sourceType: 'module', plugins: [] });
const sandbox: any = createContext(ctx);
return runInContext(ast, sandbox);
};管线分三段,每段都有明确意图。
第一段 convertES5 是可选的 babel 转译,preset-env 的目标定在 Safari 9 / Android 4.4,关键是配了 14 项 assumptions(helper.ts:40-55):iterableIsArray、noNewArrows、setComputedProperties、skipForOfIteratorClosing 等等。assumptions 是 babel 的一个少有人用的配置面:它告诉 babel「目标环境有这些保证,你可以生成更简单的代码」。比如 iterableIsArray 让 for-of 降级时假定可迭代对象就是数组,省掉 iterator 协议的运行时判断。这 14 项合起来的含义是:把输入定制成解释器最擅长执行的形态。线上代码走同样的转译,所以解释器不需要实现完整语义,只需要实现「babel 在这些 assumptions 下的输出」所覆盖的语义。沙箱引擎的能力边界不是解释器定的,是转译层定的。
第二段 hoisting 是自研的 babel 插件,下面单独讲。
第三段是对抗性输入:设 TERSER=true 时,转译后的代码再经过一次 terser 压缩,才交给解释器。压缩会把变量名压成单字母、合并声明、改写表达式结构,等于用机器生成大量「人写不出来但合法」的形态轰炸引擎。terser 的 minify 是异步 API,jest 的用例要同步执行,所以 helper.ts 里用 execSync 起了一个子进程 __tests__/minifysync.js,从 stdin 读代码、压缩后写 stdout,把异步压缩硬生生同步化。这个子进程脚本还做了件贴心事:压缩失败时把出错位置前后各 100 个字符打出来,方便定位是引擎不支持还是代码本身有问题。
管线最后一环也有个调试友好设计:run 的 catch 里先把转译后的代码完整打印再抛错(helper.ts:79-82)。用例失败时,终端里既有 jest 的断言 diff,又有引擎实际吃到的那份代码,复现成本很低。写解释器测试,一半的痛感来自「断言挂在转译后的代码上,而你只能看到转译前的」,这个设计就是冲着这个痛点去的。
hoisting 插件:把提升挪到编译期
第 3 篇讲过,jsvm2 的变量提升靠执行前的两阶段扫描实现。但引擎还有另一条路:plugin/hoisting.ts 这个 babel 插件,在编译期就把 var 声明和函数声明物理提升到作用域顶部。提升之后的代码里,声明语句已经出现在它该在的位置,解释器执行时看到的是一份接近简单 IR 的东西,两阶段扫描的负担就轻了。
插件 86 行,两个 visitor。VariableDeclaration 的 exit 钩子处理 var:找到目标函数作用域,把声明推上去,初始化留在原地变成纯赋值。FunctionDeclaration 的 exit 钩子处理函数声明:提升到最近函数体的顶部,或者收集到 __FunctionDeclaration__ 里、在 Program exit 时统一 unshift 到文件头。严格模式下不做函数提升,因为块级函数声明在严格模式下的语义本来就不同。
真正体现实战痕迹的是 var 提升回调里那段中文注释(plugin/hoisting.ts:38-43):
/*
* var a = i;
* for (var i = 0; i <0;i++){}
* 这种case下 直接通过 targetScope.hasOwnBinding 判断 i 是属于Program的作用域的,所以要判断一下i本身的位置
* 如果不在目标作用域的情况下,还是要提升一下变量的声明
* */for (var i ...) 里的 i 按规范属于 Program 作用域,babel 的 scope 分析也会这么报,但 i 的声明位置在 for 语句内部,直接按 hasOwnBinding 判断会漏提升。插件补了一个位置判断:绑定已存在但声明本体不在目标作用域里时,照样提。这种边角只有拿真实编译产物喂进来才会暴露,纯写规范用例很难遇到。
这个插件的存在本身就是「编译期收窄解释期」最直白的注脚:能静态确定的语义,就不要留到运行期去算。解释器越简单,需要证明的东西越少。
React SSR 压测
五层测试之外,还有一次压测值得单独讲:2021 年 11 月 29 日,一次 commit 把 React 服务端渲染跑通了。入口(framework/react-case/case.ts)一共 21 行:读进 webpack 打包出的 dist/out.js,createContext({}) 建一个空沙箱,runInContext 执行,把模块导出打印出来。
case 本身小得不起眼,源码(framework/react-case/src/app.jsx)就是渲染一个 <h1>Hello, world!</h1>:
import * as React from 'react';
import * as Server from 'react-dom/server';
let Greet = () => <h1>Hello, world!</h1>;
console.log(Server.renderToString(<Greet />));跑的是 webpack 打包出的整个 react-dom/server bundle。为什么选 SSR 不选 DOM 渲染?因为 SSR 是纯计算:createElement、调和、拼字符串,全程不碰宿主 DOM API,沙箱不需要伪造任何浏览器环境。用 DOM 渲染做压测,测出来的大半是 fake DOM 的保真度,不是引擎的语义覆盖度。
真正的工作量在那个 commit 的另一半:同一次提交里改了 7 个引擎源文件。Scope 的存储从普通对象换成 Map(src/scope.ts),这是为了堵 __proto__ 属性名污染,React 的代码里有大量对象 key 操作,用普通对象存绑定会被原型链上的名字干扰。ForInStatement 补了左侧是裸标识符的情况(src/standard/es5/loopStatement.ts),for (w in g) 不带声明关键字,之前只处理了 for (var w in g),注释里写着「react构建出的dist大量应用」。SequenceExpression 原来只求最后一个表达式的值(src/standard/es5/sequenceExpression.ts),前面的表达式全被跳过,副作用丢了,改成逐个求值返回末项。callExpression 补了 Object.prototype.hasOwnProperty.call(g, w) 这种调用的处理。还有一处诚实的痕迹:memberExpression.ts 里当时留了一行调试用的 console.log 没删,也跟着进了 commit。
一次「Hello, world」逼出 7 个文件的修复,说明之前的几百个用例仍有盲区,而真实框架代码是最好的盲区探测器。这也是把 lodash 和 React 放进回归体系的原因:自己写的用例永远带着自己理解的偏见,框架代码不带。
性能定位
性能这件事必须说清楚,不然这篇就是软文。jsvm2 是树上解释器,没有字节码、没有 JIT,每次求值都在 AST 节点间走函数调用。benchmark/function.ts 留了一个最朴素的对比脚手架:同一段循环计算代码,一份在 VM 里跑,一份原生跑,用 Benchmark.js 对比 ops。
量级结论:比无 JIT 的 V8 慢大约两个数量级,主要开销在上下文创建和 Path/Scope 的对象分配上,每个 AST 节点求值都要建 Path、必要时建子 Scope,GC 压力很大。这个 benchmark 文件也是和 React case 同一次 commit 进来的,那次提交的主题表面是「跑通 React」,实际是「跑通之后立刻确认性能没有离谱」,验证和度量是同一个动作的两面。
但有两点要同时说。第一,业务场景无感:沙箱里跑的是配置逻辑和页面模板,单次执行是毫秒级任务的零头,瓶颈从来在 IO 和渲染。第二,这个 benchmark 的定位就是脚手架,用来在重构时确认没有退化一个量级,不用于对外宣传数字。选引擎时性能是准入门槛,不是竞争指标;竞争指标是前面讲的语义覆盖和可验证性。
工程上还有一层配套值得提。2020 年那篇《使用TS 开发 JS 虚拟机》里写过,上生产前我们做过 AST 等价压缩,把 babel AST 的体积压下来,并基于压缩 AST 自定义了一套 sourcemap 协议和后台调试工具,线上报错输入 ID 就能反解到行列。验证体系不只在开发期,线上可观测性也是它的一部分。
没填的坑和后续
系列走到这里,把七篇里散落着承认过的语义缺口汇总一次,算是给这个项目一张诚实的体检表:
- 函数提升与 var 提升的顺序:
function a(){} var a;之后typeof a应为'function',本实现得'undefined'(第 3 篇)。 - TDZ 未实现,let/const 在声明前访问会静默拿到外层同名绑定的值(第 3 篇)。
- finally 只认 return 信号,finally 块里的 break/continue 会被吞(第 4 篇)。
- newFunction 的意图标记在重入和异常场景会误判(第 5 篇)。
- readme 打钩的 for-of 实际没实现,
**运算符打钩但不在运算符表里(第 6 篇)。 - class 整个没做,依赖 babel 降级。
后续如果继续投入,优先级大概是:先补 TDZ 和 finally 的信号处理,这两个是静默错误,危害大于明确报错;class 和 for-of 继续做「编译期降级」的路线,不在解释器里实现。这个优先级排序本身就是整个项目方法论的延续:解释器只承担必须运行期做的事,其余交给编译期。
结语
七篇写完,回头看这个项目其实就两句话。一句是「能白嫖宿主的绝不自己写」:函数是宿主闭包、异常是宿主异常、原型链和运算符语义全部委托,解释器亲手实现的只有作用域、控制流信号和左值这三样白嫖不到的东西。另一句是「编译期收窄解释期」:输入是 babel 在 14 项 assumptions 下定制过的产物,提升有插件提前做掉,验证按真实编译产物的分布组织。白嫖决定了这台引擎能有多小,收窄决定了它敢上生产;小所以能验证,收窄所以验证得动。一台解释器最终让人放心的理由,从来不是它实现了多少,而是它知道自己没实现什么,并且把每一条都用测试钉在了原地。

