上一篇说明了 ES2015 的裁剪范围。解释器能否用于生产,取决于其在目标输入分布上的验证结果。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 的保真度,不是引擎的语义覆盖度。
同一次提交还修改了 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。
一次 React SSR 用例引发 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 降级。
后续的重写没有继续在 AST 直驱模型上逐项修补,而是转向 JSVM3:将 AST 的识别、作用域分析和优化前移到编译期,运行时执行扁平的字节码 Script。这同时处理了 AST 直驱的执行开销、暂停恢复和可分发产物问题;但语言语义仍要由编译器、指令集和测试共同保证。
后续演进
JSVM2 的实现保留两条可复用的原则:函数、异常、原型链和运算符等宿主已正确实现的语义直接复用;Babel 转译和提升插件把可静态确定的工作放在执行前完成。它也暴露了 AST 直驱模型的边界:每次执行仍需递归遍历 AST,执行位置依赖宿主调用栈,压缩后的 AST 也不是理想的端侧分发协议。随后开始的 JSVM3 系列将这条编译期与运行期的分工进一步拆开:编译器生成 Script 字节码,运行时用 Fiber 和 Frame 按指令执行。
