上一篇说了为什么要自研,以及整体链路:源码经 @babel/parser 出 AST,然后交给解释器求值。这篇拆开解释器本身。它的中枢代码只有 5 行,真正花心思的是两个抽象:Path 和 Scope。前者向 Babel 借的,后者对应 ECMA-262 里的环境记录。这套结构的雏形在 [2020 年那篇综述](/post/2020/使用TS 开发 JS 虚拟机/)里写过,本篇按现在的代码重讲一遍。
5 行的分发器
visitor.ts 全文如下(visitor.ts:6-11):
export function visitor(path: Path<any>) {
path.visitor = visitor;
if (!standardVisitors[path.node.type])
throw overrideStack(ErrImplement(path.node.type), path.stack, path.node);
return standardVisitors[path.node.type](path);
}逻辑就是查表:按 node.type 在 handler 表里找到对应函数,把 path 交给它。每个 handler 自己负责遍历子节点,所以整棵 AST 的求值是一次由 handler 们接力完成的前序遍历。第一行把 visitor 自己回填到 path 上,是为了让任意节点拿到的 path.visitor 永远指向这个入口,handler 不需要 import 它。
「自己负责遍历子节点」看一个最小的 handler 就清楚了。ExpressionStatement 全文两行(statement.ts:8-10):
export function ExpressionStatement(path) {
return path.visitor(path.createChild(path.node.expression));
}语句不直接求值,它用 createChild 给表达式节点造一个子 Path,再回调 path.visitor 进入下一轮分发。每个 handler 都重复这个模式:取出自己关心的子节点,造子 Path,回调,拿到子表达式的值后完成自己的语义。解释器因此没有一个中心化的遍历器,遍历顺序分散在各个 handler 里,新增一种节点类型只需要新写一个同模式的函数。
表从哪里来。standard/ 目录按 ES 版本分文件夹,es5 一个 index,es2015 一个 index,各自导出「节点类型名 → handler」的对象,standard/index.ts:6-9 用 spread 合成一张表:
export default {
...es5,
...es2015,
};types.ts 里我其实定义了配套的 mapped type(types.ts:125-147),思路是声明一个 IES5TypeMap 接口列出所有节点类型,再映射成「每个 key 必须是对应 handler 签名」的类型,让编译器保证「声明过的节点必有 handler」。这里要坦白:这几个类型定义了,但没有标注到任何一张表上,es5/index.ts 那个 export default {...} 是裸对象。所以编译期保证目前停留在纸面,真正的兜底是运行时那行 ErrImplement:遇到没实现的节点类型,抛出语法错误。配合 overrideStack(standard/utils.ts),错误会把当前的调用栈帧和 AST 节点上的 loc 信息拼进 err.stack,调试业务代码时能看到出错在第几行第几列,而不是一头扎进解释器内部。这个组合用下来实际够用,mapped type 一直没补,算是欠账。
「没实现就明确报错」是刻意的取舍。沙箱里跑的是编译产物,语法集合是收敛的,碰到表里没有的节点类型,大概率是上游转译配置出了问题,此时抛一个带位置信息的语法错误,比静默给出错误结果安全得多。
Path:裸 AST 为什么不够用
Babel 的 traverse 包里有个 NodePath 概念,jsvm2 的 Path 是简化版。为什么不直接用 @babel/traverse?它的 NodePath 服务的是编译期场景:增删替换节点、做作用域绑定分析,这套模型对一次性的代码转换很合适,但对求值来说太重,而且它的 scope 是静态绑定分析,不是运行时的变量容器。求值需要的是另一套东西。裸 AST 也不够,因为求值一个节点需要的信息,AST 节点上都没有:
- 当前节点在哪个作用域里(查变量要用)
- 父节点是谁(BlockStatement 判断要不要新建块级作用域要用)
- 调用栈(报错要拼栈帧)
- 一些一次性的下行上下文(比如 label 名、new 调用的标记)
所以 Path 是个五元组(path.ts:12-18):node、parent、scope、ctx、stack,外加两个回填属性 visitor 和 preset。所有 handler 拿到的参数都是 Path 而不是 node,这就是全项目统一的求值协议。
子节点的 Path 由 createChild 造(path.ts:20-35),这个方法里有三个细节值得说。
第一,scope 参数是个联合类型 ScopeType | Scope。传 Scope 实例就原样用;传一个 ScopeType 枚举值(就是个 number)就当快捷方式,自动调 this.scope.createChild(scope) 建一个对应类型的子作用域(path.ts:28)。handler 里最常见的写法因此很短:path.createChild(node.body, ScopeType.Block)。
第二,ctx 的传递是 { ...this.ctx, ...ctx }(path.ts:29)。浅拷贝合并,子节点改 ctx 不影响兄弟节点。这是个单向的下行通道:父节点可以给某个子树塞临时信息,子树之间互相看不见。LabeledStatement 就是靠它把 label 名只贴给循环体那一支(statement.ts:16-23)。
第三,造完子 Path 要把 visitor 和 preset 从父 Path 抄过去(path.ts:32-33),否则子节点没法继续向下分发。preset 这里也要坦白:这个属性目前只有拷贝、没有消费者,全项目没有任何一处读它。当初留它是想按 ES 版本开关特性(types.ts:149-156 的 presetMap 枚举列了 es5 到 es2018),后来裁剪策略改成了「不支持的语法直接 ErrImplement」,preset 就成了摆设。删倒是能删,一直没动手。
有一个靠 parent 指针实现的优化。函数体的 BlockStatement 和函数作用域在规范里是同一个环境,如果每个 BlockStatement 都无脑建块级作用域,函数体会白多套一层。statement.ts:40-43 的判断是:
const blockScope =
scope.type !== ScopeType.Block && parent?.scope !== scope
? scope
: scope.createChild(ScopeType.Block);当前 scope 不是 Block、且父 Path 的 scope 和我不一样,说明我是某段代码的最外层块(比如函数体,父节点是 FunctionDeclaration,它的 scope 是外层),直接复用当前 scope;否则说明我嵌在另一个块里面(父节点把同一个 scope 传给了我),这才新建块级作用域。注释里留的例子是函数里套裸代码块的情况(statement.ts:31-39),判断条件全靠 parent 指针比较引用,不需要额外标志。
这个优化不是省一个对象那么简单。如果函数体顶部多套一层 Block 作用域,函数体里用 let 声明的变量会落进那层块作用域而不是函数作用域,函数参数和函数体声明就分住在两层,参数遮蔽、重复声明检查的结果都会偏离规范。环境层数在这里是语义的一部分,不是实现细节。
Scope:200 行的环境记录
Scope 是这台 VM 里唯一一个需要对 ECMA-262 负责的数据结构。全文件 240 行,核心模型一句话:一条 parent 链,每个节点挂一个 Map,Map 里存 Var。
Var 是最小的绑定单元(var.ts:9-19),三个字段:kind(var/let/const)、name、value。规范里的可变绑定与不可变绑定在这里退化成 kind 字段,赋值时检查。
Scope 三档类型(types.ts:12-16):Root、Function、Block。早先版本用两个布尔字段 invasive 和 isolated 表达「这个块的作用域会不会被 var 穿透」「这个作用域是不是 var 的边界」,后来发现这两个值完全由 type 决定,就改成了 getter(scope.ts:35-41):
get invasive() {
return this.type === ScopeType.Block;
}
get isolated() {
return this.type === ScopeType.Function || this.type === ScopeType.Root;
}被注释掉的旧字段还留在类里(scope.ts:18、22),没删,留着当改动记录。
从普通对象到 Map
data 最早是普通对象,类型定义还注释在文件头(scope.ts:7-10)。改成 Map 是 2021 年 11 月跑 React SSR 那次。普通对象当字典有个经典陷阱:data['__proto__'] = x 不会新增一个自有属性,而是悄悄把对象的原型改掉了;之后再 hasOwnProperty('__proto__') 判断、再读取,得到的全是反直觉的结果。手写代码里变量名撞 __proto__ 的概率很低,但跑真实编译产物时什么名字都可能出现,压缩器、打包器生成的临时标识符不受人控制。那次 React 接入顺手就把 storage 换成了 Map,data.has / data.get / data.set 语义干净,迭代顺序也稳定。scope.ts 里几处被注释掉的 this.data[varName] = ... 就是那次迁移的痕迹。这类问题的教训是:凡是键名来自外部输入的字典,第一选择就应该是 Map,普通对象省下的那点语法糖不够付一次调试的代价。
declareVar:var 的爬升
var 声明的语义是「穿透块级作用域,挂在最近的函数或全局边界上」。实现就是 declareVar 里的三行爬升循环(scope.ts:101-103):
while (!scope && targetScope.parent !== null && !targetScope.isolated) {
targetScope = targetScope.parent;
}从当前作用域出发,只要没到顶、当前作用域不是 isolated(即是 Block),就继续向上。停下来时 targetScope 就是最近的 Function 或 Root。let 和 const 没有这段逻辑,直接落在当前作用域。
落点还要过一道冲突检查(scope.ts:105-111):目标作用域已有同名绑定时,旧绑定是 var 才允许覆盖,是 let 或 const 就抛 ErrDuplicateDeclare。这就是「var 只能覆盖 var」,对应规范里 var 与词法声明冲突报 SyntaxError 的条款。这里也有个已知偏差:函数提升和 var 提升的覆盖顺序跟规范不完全一致,下一篇讲提升时会展开。
declareVar 开头的注释(scope.ts:92-95)记录了另一个坑:var b = function a(a) { return a; } 这种具名函数表达式,函数名 a 和参数 a 都落在函数作用域上,参数绑定要能复写函数名而不触发重复声明,所以 declareVar 接受一个可选的 scope 参数跳过爬升,直接指定落点。
hasBinding:作用域链就是一条 while 循环
查变量是 hasBinding(scope.ts:133-149):从当前 scope 开始,data 里有就返回 Var,没有就沿 parent 向上,到顶返回 undefined。没有递归,没有缓存,一条 while 循环就是整条作用域链。
于是 Identifier 的求值 handler 短得不像话(identifier.ts:79-90):名字是 undefined 直接返回 undefined,否则 scope.hasBinding(node.name),拿到就返回 value,拿不到抛 ErrNotDefined(一个 ReferenceError)。读一个变量,本质就是一次链式查找。赋值的写路径要复杂得多,左值问题留到第 6 篇。
这个 ReferenceError 不是裸抛的,先过一遍 overrideStack(standard/utils.ts):往 path.stack 压入当前帧(当前栈名加上 AST 节点自带的 loc),再由 stack.ts 的 raw getter 把整条沙箱调用栈排版后重写进 err.stack,列号那里有一处加一修正,源码注释说是为了和 parser 的行列口径对齐。所以业务方看到的报错指向沙箱源码的第几行第几列,而不是 identifier.ts 的第 88 行。调试别人动态下发的代码时,这一条是刚需。
把三个零件串起来走一遍。以 var a = 1; a + 1 为例:visitor 拿到 Program 节点,Program 的 handler 先处理声明(提升的细节下一篇讲),再逐条分发语句;VariableDeclaration 的 handler 调用 scope.declareVar('a', 1),a 落进根作用域的 Map;ExpressionStatement 造子 Path 继续分发;BinaryExpression 的 handler 分别对左右两个子节点造 Path、回调 visitor;左侧 Identifier 触发一次 hasBinding('a'),在根作用域命中,返回 1;右侧 NumericLiteral 直接返回 1;最后 1 + 1 的加法直接交给宿主 JS 引擎完成。整条链路上,解释器自己做的只有分发、作用域读写和控制顺序,值与运算全是宿主的。这个「能白嫖就白嫖」的原则后面几篇会反复出现。
和规范对一下
如果把 ECMA-262 的术语贴上来:Scope 大致对应 Declarative Environment Record,Function 类型叠加参数绑定后接近 Function Environment,Root 注入全局对象后接近 Global Environment。细一点说,规范里的 Global Environment Record 是复合结构,一个 Object Environment Record 管全局对象上的属性,var 和函数声明挂在这里,一个 Declarative Environment Record 管 let 和 const。jsvm2 的 Root 没有拆这两层,三种声明进同一个 Map,靠 kind 区分。Function 类型里,参数、arguments、this 都是 data 里的普通条目,规范中 [[ThisValue]] 这类内部槽位没有对应物,因为函数对象直接用宿主的,状态不需要解释器保管。jsvm2 也没有实现 Object Environment Record(with 语句整体不支持,parser 在严格模式下也解析不出 WithStatement),也没有把「outer 环境引用」做成独立概念,parent 指针就是它。200 行换来的是 ES5 词法结构的完整骨架,代价是规范里一些边角语义被简化或裁掉,比如 TDZ 就没有实现,这也是下一篇的内容。
根作用域的初始化
一切从 Scope.createRoot 开始(scope.ts:233-239):
static createRoot(context: any = {}): Scope {
const s = new Scope(ScopeType.Root, null);
s.level = 0;
s.declareConst(THIS, undefined);
s.setContext(context);
return s;
}两件事。一是预置 this 为 undefined 的 const 绑定,顶层 this 因此是 undefined,这也顺手拿到了模块严格模式的语义。二是 setContext(scope.ts:43-56):遍历传入的 Context,把每个 key declareVar 进根作用域。Context(context.ts)是沙箱的全局白名单,内置了 Object、Array、JSON、Math 这些标准全局对象,宿主环境有 Promise、Map、Proxy 就探测着补进去,调用方还可以再传自己的注入表。VM 里能摸到哪些全局能力,完全由这张白名单决定,这是沙箱安全边界的第一道门。
入口把这些零件组装起来。runInContext(vm.ts:15-27)先 Scope.createRoot(context),再声明 module 和 exports 两个变量模拟 CommonJS 外壳,然后 new Path(ast, null, s, {}, new Stack()) 造出第一个 Path:parent 为 null,scope 是根作用域,ctx 空对象,stack 全新的。把它交给 visitor,整棵树就开始求值。返回值也在这里定了规矩:如果代码给 module.exports 赋了值就返回它,否则返回最后一条语句的求值结果。上一篇给的整体链路,落到代码里就是这几行。
到这里骨架就齐了:visitor 按类型分发,Path 携带求值所需的全部上下文向下传递,Scope 链负责所有名字的来去。下一篇讲这套骨架上第一个反直觉的语义:变量提升和闭包,以及那道 for 循环里 var 和 let 的经典面试题在 jsvm2 里分别是怎么跑出来的。

