前面两篇讲了骨架(visitor、Path 与作用域链)和变量(提升与闭包)。值怎么算、变量去哪找都有了,还差一块:语句执行到一半要跳走怎么办。JS 里的跳走有四种:throw、return、break、continue。这四种在 jsvm2 里走两条完全不同的通道,一条白嫖,一条手写。
throw 走宿主通道
throw 的实现只有三行(tryCatch.ts:6-9):
export function ThrowStatement(path: Path<t.ThrowStatement>) {
// TODO: rewrite the stack log
throw path.visitor(path.createChild(path.node.argument));
}求出要抛的值,直接抛给宿主。try/catch 也是对应的写法:TryStatement 用宿主 try 包住 block 的求值,宿主 catch 拿到 err,用 declareLet 绑定到 catch 参数上,再求 handler 节点(tryCatch.ts:15-33)。错误对象本身、catch 的参数绑定、异常沿调用栈向上传,全部是宿主语义。沙箱里 throw new Error('x') 抛出去的就是一个货真价实的宿主 Error 实例,因为 new 表达式的实现本身就白嫖宿主,这一点第 5 篇细讲。那条 TODO 注释留了个尾巴:抛出来的 stack 是宿主执行栈,对不上沙箱代码的行号,重写 stack 一直没做。
throw 能白嫖,是因为异常天然就是跨层传播、逐层拦截的机制,宿主引擎替你一层层撕开栈帧。return/break/continue 做不到这一点:visitor 递归里每一层求值函数都有自己的 return,沙箱函数的 return 没法映射成宿主递归中途的 return。理论上也可以把它们包成异常抛出去,一些解释器确实这么干,但当时否掉了,理由有两个。一是 break/continue 要按 label 条件决定在哪一层停下,异常机制不支持条件拦截,只能在每一层 catch 里判类型再 rethrow,比直接返回一个对象更绕。二是控制流信号和真实错误会混在同一个通道里,沙箱代码 try { break; } catch (e) {} 这种写法会把 break 信号当真错误吞掉,还要额外加标记位区分。所以这三者选了另一条路:自己造信号,走正常的函数返回通道向上传,和异常通道井水不犯河水。
Signal:Completion Record 的极简投影
signal.ts 全文 21 行(signal.ts:1-21):
export type SignalKind = 'break' | 'continue' | 'return';
export class Signal {
constructor(public kind: SignalKind, public value?: any) {}
public static is(v: any, type: SignalKind): v is Signal {
return v instanceof Signal && v.kind === type;
}
// isBreak / isContinue / isReturn 三个快捷守卫
}ECMA-262 里每条语句执行完产生一个 Completion Record:[[Type]] 取 normal/break/continue/return/throw,[[Value]] 带值,[[Target]] 带 label。Signal 是它的极简投影:kind 对应 [[Type]],value 一个字段同时当 [[Value]] 和 [[Target]] 用,return 时它是返回值,break/continue 时它是 label 名。normal 和 throw 两种类型不存在,throw 走了上一条通道,normal 就是普通返回值。规范里 normal completion 也携带值,语句序列的值是最后一条产生值的语句的结果,eval 靠这个返回值。jsvm2 保留了这一点:BlockStatement 求完每条语句都覆盖 result,最后把它返回(statement.ts:66-79),注释里写着是为了支持 do-expression;Program 同样把最后一条语句的结果作为整个程序的返回值(program.ts:50)。所以「返回值或信号」这个双态贯穿所有求值函数,每个拿到子节点结果的地方都要先判一次类型。一个字段身兼两职,省是省,代价是每个消费点都得自己分清 value 此刻是哪种含义,本文后面两个 bug 都出在这里。
产生信号的地方在 signalStatement.ts:5-20:ReturnStatement 求出 argument 包成 Signal('return', 值),没有 argument 时值就是 undefined,对应裸 return;;Break/Continue 把 label 名(没有就是 undefined)装进 value。
之后每个复合语句都要做同一件事:拿到子节点的求值结果,判是不是 Signal,自己认就消费,不认就原样 return 给上层。BlockStatement(statement.ts:70-72)、SwitchCase(conditional.ts:56-58)、四种循环,全是这个模式。解释器里的「顺序执行」其实是信号在 visitor 递归里的接力,任何一层忘了接力,信号就丢在半路。
这套协议有个工程上的弱点:所有求值函数的返回类型都是 any,TypeScript 帮不上忙,「这里该判 Signal 而没判」这类错误编译器一个都抓不到,只能靠 Signal.isXxx 这几个类型守卫在每个消费点手工补判。第 2 篇说过 visitor 分发靠 mapped type 在编译期保证节点必有 handler,那是声明层面的完备;信号协议是值层面的约定,没有同等的编译期保护。后面两个 bug 从根上说是这个弱点的兑现。
label 协议:贴标签的和认标签的分离
label 的设计拆成两半。LabeledStatement 自己不消费循环信号,只负责贴标签(statement.ts:16-23):
export function LabeledStatement(path: Path<t.LabeledStatement>) {
const label = path.node.label as t.Identifier;
const res = path.visitor(path.createChild(path.node.body, path.scope, { labelName: label.name }));
if (res && Signal.isBreak(res) && res.value === label.name) {
return undefined;
}
return res;
}label 名通过 ctx.labelName 传给子节点。ctx 的浅拷贝是第 2 篇讲过的不可变下行通道,父节点改 ctx 不影响兄弟节点。末尾那三行处理 labeled block:foo: { break foo; },标签贴在普通块上,块里 break 这个标签,在这里直接消费掉,返回 undefined 当无事发生。
认标签的是循环。ForStatement 里的信号仲裁(loopStatement.ts:27-49)是个三段式:
if (Signal.isBreak(signal)) {
if (!signal.value) {
break;
}
if (signal.value === labelName) {
break;
}
return signal;
} else if (Signal.isContinue(signal)) {
// 结构相同,消费动作换成宿主 continue
} else if (Signal.isReturn(signal)) {
return signal;
}三种情况:break 没带 label,本层消费,用宿主 break 停掉循环;带了 label 且正好贴在本循环头上,也消费;label 对不上,说明目标是外层某层循环,原样上抛。continue 同构。return 不做任何判断,永远上抛。
还有一个细节:循环进 body 之前会把 ctx 的 labelName 置成 undefined(loopStatement.ts:23-25)。label 只对紧跟的那一层循环有效,body 里嵌套的语句看不到它,除非里面再出现一个新的 LabeledStatement。这段仲裁逻辑在 For/While/DoWhile/ForIn 四个函数里一字不差复制了四遍,当时没抽公共函数,现在看应该抽。
拿一个嵌套循环把三段仲裁走一遍:
var hit = [];
outer: for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++) {
if (j === 1) {
continue outer;
}
if (i === 2) {
break outer;
}
hit.push(i * 10 + j);
}
}
hit; // [0, 10]i=0 那轮:内层循环的 labelName 是 undefined,continue outer 的信号 value 是 ‘outer’,三段里前两段都不中,原样上抛到外层;外层 labelName 是 ‘outer’,匹配,宿主 continue 掉本轮,i 进到 1。i=2 那轮 break outer 同理,外层匹配后宿主 break,整个双层循环结束。label 匹配上的那一层负责动手,中间各层只负责传。这个协议跑起来是对的,问题出在别的消费点没有完整实现它,后面会看到。
switch:一个布尔同时干三件事
SwitchStatement 的核心是一个 matched 布尔(conditional.ts:26-49):
let matched = false;
for (const $case of node.cases) {
if (
!matched &&
(!$case.test || discriminant === path.visitor(path.createChild($case.test, switchScope)))
) {
matched = true;
}
if (matched) {
const result = path.visitor(path.createChild($case, switchScope));
if (Signal.isBreak(result)) {
break;
} else if (Signal.isContinue(result)) {
return result;
} else if (Signal.isReturn(result)) {
return result;
}
}
}这一个布尔同时实现三件事。严格匹配:discriminant === test,宿主的严格相等,类型转换规则白嫖。fall-through:matched 一旦置 true 不再翻回去,后面的 case 不再判 test,挨个执行,这就是穿透语义。default 兜底:default 分支没有 test 节点,!$case.test 直接命中。continue 不能作用于 switch 本身,但可以穿过 switch 作用于外层循环,所以 continue 和 return 一样原样上抛。
拿它对照规范,有一处小偏差:default 写在匹配 case 前面时行为不对。规范里 default 是所有 case 都不匹配才进的兜底,位置不影响语义;这里按源码顺序扫,default 排在前面就先命中:
var r = [];
switch (1) {
default: r.push('d');
case 1: r.push('c1');
}
// 规范: ['c1'] default 被跳过,case 1 命中
// jsvm2: ['d','c1'] default 先命中,再穿透进 case 1babel 产物里 default 永远排最后,所以没炸过,但语义是错的。真正的 bug 在下面。
finally 覆盖 return:借宿主的执行顺序
tryCatch.ts:34-45 的 finally 实现借了宿主一个特性:宿主 finally 块在 try 的 return 生效之前执行。
} finally {
if (node.finalizer) {
const finallyScope = scope.createChild(ScopeType.Block);
const single = path.visitor(path.createChild(node.finalizer, finallyScope));
if (Signal.isReturn(single)) {
return single;
}
}
}沙箱 try 块里执行了 return,产生 Signal(‘return’) 准备作为 TryStatement 的返回值。但宿主要 return 之前得先跑 finally 块,finally 里如果又产生一个 return 信号,直接 return single 盖掉原来那个。这正是规范语义:finally 的 abrupt completion 替换掉 pending 的那个。
function g() {
try {
return 1;
} finally {
return 2;
}
}
g(); // 2,沙箱里结果也对宿主 finally 的执行顺序替我们实现了 completion 替换,一行状态管理都不用写。这是控制流这一层白嫖得最舒服的一处。顺手说一句,try 块里如果同时有宿主异常待定(比如沙箱 throw 了一个值),finally 里的 return 信号一样能把它盖掉,因为宿主的 return 语句本身就会吞掉待定的异常,这条语义也是白捡的。
读代码读出来的两个真 bug
这两个 bug 不是测试跑出来的,是这次复盘重读代码时对出来的。
Bug 1:switch 吞掉带 label 的 break。
回看 SwitchStatement 第 39 行:if (Signal.isBreak(result)) { break; }。循环里的三段仲裁到这里只剩一段,只要是 break 就消费,根本不看 value 里的 label。可是 break outer 的目标是外层循环,switch 凭什么吃掉它:
var result = [];
outer: for (var i = 0; i < 5; i++) {
switch (i) {
case 2:
break outer;
default:
result.push(i);
}
}
// 规范: [0, 1] break outer 直接结束 for 循环
// jsvm2: [0, 1, 3, 4] switch 把 break 吞了,for 继续跑把 i=2 那轮在 jsvm2 里走一遍:break outer 产生 Signal(‘break’, ‘outer’);SwitchCase 见信号就上抛,这一步没错;SwitchStatement 收到后命中第 39 行,宿主 break 跳出 case 循环,函数正常返回 undefined;ForStatement 拿到 undefined,不是信号,继续下一轮。一个本该终止整个外层循环的信号,在 switch 这一层被消化成了「case 执行完毕」。规范里 switch 只能消费不带 label 的 break 和指向自己的 labeled break,其余都必须上抛,这条协议在循环里实现全了,在 switch 里丢了。
修法是把循环那套判断搬过来:无 label 的 break 才消费,value 等于贴在自己头上的 labelName 也消费(foo: switch (...) { case 1: break foo; } 是合法写法,但 SwitchStatement 目前根本没读 ctx.labelName,这个场景是靠 bug 1 的「全吞」误打误撞才对的),其余上抛。
Bug 2:finally 只认 return,吞掉 break/continue。
tryCatch.ts:41 行:if (Signal.isReturn(single)) { return single; }。finally 块产生的信号只有 return 会被返回去替换 pending completion,break 和 continue 直接掉进 void。规范里 finally 的任何 abrupt completion 都要替换:
function f() {
for (var i = 0; i < 3; i++) {
try {
return i;
} finally {
break;
}
}
return 'loop-done';
}
f();
// 规范: 'loop-done' finally 的 break 替换掉 return,循环终止,走到最后的 return
// jsvm2: 0 break 被吞,try 里的 return 0 生效jsvm2 里的执行轨迹:try 块产生 Signal(‘return’, 0),TryStatement 准备返回它;宿主 finally 先跑,finalizer 求值得到 Signal(‘break’);第 41 行 isReturn 判不中,single 被丢弃,宿主 finally 正常结束,try 的 return 生效,Signal(‘return’, 0) 一路到函数边界拆出 0。规范语义里 finally 的 break 应该替换掉 pending 的 return,让循环终止,f 走到最后的 return 'loop-done'。
修法和上一个同样机械,把 isReturn 放宽成 single instanceof Signal。但两个 bug 摆在一起看,方向正好相反:switch 消费得太宽,不看 label 全吞;finally 消费得太窄,只认 return 全漏。根因是同一个,value 字段身兼返回值和 label 两种用途,每个消费点都要自己记住完整协议,漏一项就是一个语义 bug。
测试为什么没抓住它们:break 带 label、finally 里写 break,这两类代码 babel 产物都不会生成,回归用例里没有。jsvm2 语义裁剪的实际边界,说到底是「babel 产物会长成什么样」决定的,这个话题第 6 篇还会回来。
消费边界的不对称
信号最终要在边界上拆掉,jsvm2 的边界有两处,行为不对称。
函数边界拆得最彻底(function.ts:73-75):
if (result instanceof Signal) {
return result.value;
}不区分 kind,一律拆 value 作为函数返回值。正常情况下传到这里的只有 return,break/continue 在循环里已经消费掉了,一切正确。但如果有漏网的信号传到这里,函数照样拆,返回一个 label 名或者 undefined,错误被静默吞掉而不是报出来。
Program 边界只认 return(program.ts:44-47):
result = path.visitor(path.createChild(node));
if (Signal.isReturn(result)) {
return result;
}顶层如果出现 break/continue 信号,不会中断后续语句的执行,会被下一条语句的结果覆盖;如果它是最后一条语句的结果,Signal 对象就作为整个程序的运行结果泄漏给调用方,调用方拿到一个既不是值也不是错误的奇怪对象。
这个泄漏我实际构造出来看过。babel 不放行顶层 break,就手工拼 AST:break; log(42); break;,走不带 module 外壳的 run() 入口(vm.ts:29-34)。两个观察:第一,中间的 log(42) 照常执行,顶层 break 完全没有阻断作用;第二,返回值 res 是一个 Signal 实例,kind 为 ‘break’,value 为 undefined,调用方拿到它无从判断程序到底是正常跑完还是出了状况。同一个 AST 换 runInContext 走,Signal 又被 module/exports 外壳挡掉,拿到的是 {}(vm.ts:25-26),因为 MODULE 在根作用域永远存在,res 那个分支实际上摸不到。
好在这层有 parser 兜底:顶层 break 在规范里是 SyntaxError,babel 解析时直接报「Unsyntactic break」,正常源码根本到不了引擎,@babel/parser 对裸的 break; 直接拒掉。等于这道防线的检查整个外包给了编译期,又是编译期收窄解释期的一个样本,只不过这次是被动收窄,引擎自己没有任何防御。
两处的取舍对比值得记下来:函数边界「拆得宽」,因为它假设上游协议完备,漏过来的信号本来就反常;Program 边界「认得窄」,因为它假设 parser 已经把非法输入拦掉了。两个假设在日常输入下都成立,但假设就是假设,它们都不构成校验。
收尾
控制流这一层的账可以这么算:throw 全白嫖,try/catch/finally 白嫖了一半(异常传播和 finally 的执行顺序),return/break/continue 全手写,成本是一个 21 行的 Signal 类加每个复合语句里的仲裁代码。仲裁协议本身不难,难在每个消费点都要把协议记全,这两个 bug 就是记漏的代价。
如实交代一下状态:写这篇的时候这两处还没改,修法在上面写清了,会随下一轮维护一起进。复盘的价值有一半就在这种地方,测试集覆盖不到的角落,只能靠逐行重读去对规范。
下一篇预告:函数、this 与 new,看 jsvm2 怎么把沙箱函数整个变成宿主函数,call/apply/bind、instanceof、原型链全部零成本,真正手写的只有三件事。

