上一篇把骨架搭完了:visitor 按节点类型分发,Path 带着 scope 和 ctx 往下传,Scope 链管所有名字的来去。这篇讲这套骨架上长出来的第一个反直觉语义:变量提升。顺着它会碰到闭包,碰到那道 for 循环里 var 和 let 的面试题,最后还得坦白一件没做的事:TDZ。
提升不是挪代码,是先跑一遍声明
讲变量提升最常见的说法是”引擎把声明挪到了作用域顶部”。这个说法好记,但它描述的是效果不是机制,而且按它去想,很多边界情况推不出来。ECMA-262 的真实过程是两阶段:先 instantiation(实例化),把当前执行上下文里所有声明绑定建出来;再 evaluation(求值),按顺序执行语句。提升只是 instantiation 先于 evaluation 的副作用。
jsvm2 的实现直接照搬了这个结构。Program 的 handler 里有两个长得几乎一样的循环(program.ts:30-49):
export function Program(path: Path<Program>) {
const { node: program, scope } = path;
// hoisting
for (const node of program.body) {
if (isFunctionDeclaration(node)) {
path.visitor(path.createChild(node));
} else if (isVariableDeclaration(node)) {
for (const declaration of node.declarations) {
if (node.kind === Kind.var) {
scope.declareVar((declaration.id as Identifier).name, undefined);
}
}
}
}
let result;
for (const node of program.body) {
if (!isFunctionDeclaration(node)) {
result = path.visitor(path.createChild(node));
if (Signal.isReturn(result)) {
return result;
}
}
}
return result;
}第一个循环是 pre-pass:遇到函数声明,直接走 visitor 执行它(FunctionDeclaration 的 handler 会求出函数对象并 declareVar 进作用域);遇到 var 声明,只建绑定,值给 undefined。第二个循环才是正式执行,并且跳过函数声明,因为 pre-pass 已经处理过了。BlockStatement 里是同一份模式(statement.ts:54-64),块级代码也先扫一遍声明再逐条执行。
所以 typeof x; var x = 1; 在 jsvm2 里得到 'undefined',不是因为有谁把 var x 剪贴到了前面,而是 x 的绑定在第一遍扫描时就建好了,第二遍执行到 typeof x 时它当然存在。我把这段代码跑了一遍确认行为,和浏览器一致。
pre-pass 只扫当前块的一层,不递归进子块,嵌套块的声明由各个 BlockStatement 自己进去以后再扫。那 if (true) { var x = 1; } 之后 x 为什么在块外能访问?靠的是上一篇讲的 declareVar 爬升:块内 pre-pass 调 declareVar,var 穿过 Block 作用域落到外层函数或根作用域。实测 x 在外层取值是 1,符合规范。还有两种边界情况我也顺手测了:同一个函数声明写两遍,后写的赢(a() 返回第二版的 2),因为 pre-pass 按序执行两次 declareVar,后一次冲掉前一次;函数表达式不享受提升,f(); var f = function () {...} 在调用点抛错,而把调用挪到声明之后一切正常。这几条行为和浏览器逐条对齐,说明”先扫声明再执行”这个模型本身是对的,出问题的是细节顺序,下一节就是一个细节。
这个实现和规范有一个对应关系值得记住:规范里 instantiation 和 evaluation 的分离写在 GlobalDeclarationInstantiation、FunctionDeclarationInstantiation 这些抽象操作里,jsvm2 把它拍平成了”每个 Program 和 Block 自己扫自己”。拍平的代价下面马上会看到。
一个真实的提升 bug
pre-pass 的扫描顺序是按 body 里语句的书写顺序来的,遇到谁处理谁。这就埋了一个问题:函数声明和 var 声明同名时,谁覆盖谁取决于书写顺序。我写了个用例实测:
var t;
(function () {
function a() {}
var a;
t = typeof a;
})();
module.exports = t;浏览器和规范答案都是 'function'。jsvm2 跑出 'undefined'。(这个例子要包在函数体里测:模块顶层 function 和 var 同名,按规范本身就是早期错误,babel 解析直接拒绝。)
原因拆开看有两层。第一层在 pre-pass:扫到 function a 时 a 被绑定为函数对象,再扫到 var a 时执行 declareVar('a', undefined)。第二层在 declareVar 的覆盖逻辑(scope.ts:105-111):目标作用域已有同名绑定时,只检查”var 只能覆盖 var”,只要是 var 盖 var 就无条件 data.set 新值。于是 undefined 把函数对象冲掉了。
规范的实例化阶段顺序相反:var 绑定先建,且只在绑定不存在时才初始化为 undefined;函数绑定后建,直接覆盖成函数对象。这套顺序在脚本层写在 GlobalDeclarationInstantiation 里,在函数体写在 FunctionDeclarationInstantiation 里,两处都保证”函数赢”,和书写顺序无关。jsvm2 把这些抽象操作拍平成了每个块各自的两遍循环,拍平的过程中丢了”var 初始化不得覆盖已有绑定”这条规则,pre-pass 又把两类声明混在一个循环里按序处理,书写顺序就泄漏成了语义。
这个 bug 我后来是怎么发现的也值得一提:单测里其实一直跑不出它。我们的测试管线在跑用例前会过一个自研的 babel 提升插件,把 var 和函数声明在编译期就重排好了,解释器收到的代码里这个时序问题已经被抹平。引擎自身的 pre-pass 反而很少被原始语序的输入打到。这是”编译期收窄解释期”思路的一个暗面:转译层帮你规避的问题,你不会知道自己有。修复方向也清楚,pre-pass 里函数声明要统一在 var 之后处理,或者 declareVar 初始化时跳过已有绑定,两条改一条即可。写这篇文章时它还躺在 issue 列表里。
闭包:白嫖宿主的词法捕获
jsvm2 里没有”闭包”这个模块,一个相关的类都没有。闭包是 FunctionExpression 的实现方式白送的。看 function.ts:33-43:
const func = function (this: any, ...args) {
stack.enter(functionName);
// ...
const funcScope = scope.createChild(ScopeType.Function);
if (node.id && isFunctionExpression(node)) {
funcScope.declareVar((node as any).id.name, func, funcScope);
}
// 参数绑定、this、arguments ...
const result = path.visitor(path.createChild(node.body, funcScope));
// ...
};VM 里的函数对象就是一个宿主 JS 函数。关键在 scope 这个变量:它是 FunctionExpression 求值时 Path 上携带的、定义位置的词法作用域,被宿主 function 表达式按 JS 自己的闭包规则捕获了。之后无论 func 被传到哪里、什么时候调用,函数体求值时都以这个捕获的 scope 为起点 createChild(ScopeType.Function) 建自己的调用作用域。词法作用域”定义时确定”这条规则,一行代码都没写,靠的是宿主闭包老老实实干活。
闭包的两个经典性质也因此不用测引擎、只测用法:同一个内层函数多次调用看到的是同一份外层绑定,makeCounter 连调三次返回 3;两次 makeCounter 产生的闭包互不干扰,各数各的。这两条我都跑了用例确认,它们成立的原因是 createChild 以捕获的 scope 为 parent,而捕获发生在定义那一时刻,一次定义一份捕获。
具名函数表达式的自我绑定也在上面这段里:node.id 存在时,把函数自己以名字 declareVar 进 funcScope(注意第三个参数传了 funcScope,强制绑定在函数作用域本身,不触发 var 爬升)。所以 var f = function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); } 这种递归写法可用,我实测 f(5) 返回 120。scope.ts:92-95 有一段注释专门记了这个场景的坑:var b = function a(a) { return a; },参数名和函数名撞车时,必须让参数和函数名都落在 funcScope 上,参数复写 a 才不会触发重复声明报错。注释比代码长,这种地方都是踩过才写的。
function.ts 里当然不止作用域捕获这一件事,参数绑定、this、arguments、new 的判断都在同一个宿主函数里完成。这几件各有各的坑,和闭包关系不大,留到讲函数、this 与 new 的那篇再拆。这里只需要记住一点:闭包这个听起来最需要引擎支持的特性,在 jsvm2 里是代码量为零的特性,因为它本来就是宿主语言最擅长的部分。
面试题:for 循环里的 var 和 let
先抛题。下面两段代码,各输出什么:
var fns = [];
for (var i = 0; i < 3; i++) {
fns.push(function () { return i; });
}
fns.map(function (f) { return f(); }); // ?
var fns = [];
for (let i = 0; i < 3; i++) {
fns.push(function () { return i; });
}
fns.map(function (f) { return f(); }); // ?var 版输出 [3, 3, 3],let 版输出 [0, 1, 2]。解释是面试标准答案:var 声明的 i 在整个函数作用域里只有一份,三个闭包看到的是同一个 binding 的终值;let 是每轮迭代一份新绑定,闭包各自捕获自己那一轮的 i。
我在 jsvm2 里把两段都跑了,结果和规范完全一致。有意思的是,翻遍 loopStatement.ts 和 scope.ts,找不到任何一行代码是专门为这道题的 var/let 分野写的。这个行为是从两个已有机制的交互里涌现出来的。
第一个机制在 ForStatement(loopStatement.ts:12-26):for 先建一个 forScope 放 init 的声明,然后每轮迭代执行体之前,forScope.fork(ScopeType.Block) 复制出一个本轮的 loopScope,body 在 loopScope 里跑。
第二个机制是 fork 的拷贝方式(scope.ts:176-196)。fork 建的是原作用域的兄弟(parent 相同),然后把 data 里的绑定逐个复制过去:
public fork(type?: ScopeType): Scope {
const siblingScope = new Scope(type || this.type, this.parent);
siblingScope.level = this.level;
siblingScope.context = this.context;
this.data.forEach((value) => {
siblingScope.declare(value.kind, value.name, value.value);
});
return siblingScope;
}注意两点。一是拷贝走 declare(kind, ...),按 kind 分发。二是 var 的 declare 是 declareVar,而上一篇讲过 declareVar 会沿着 parent 链爬升,跳过所有 Block 作用域,直到函数或根作用域才落下(scope.ts:101-103)。
现在把两个机制合起来推演。var 版:init 里 var i = 0 在 forScope 上执行,declareVar 直接爬到外层的函数或根作用域落下,forScope 自己的 data 里根本没有 i。每轮 fork 拷的是 forScope 的 data,等于啥也没拷,loopScope 里也没有 i。闭包捕获 loopScope,求值 i 时沿链爬到外层作用域,三个闭包摸到同一份 binding,循环结束后它是 3。let 版:declareLet 不爬升,i 老老实实存在 forScope.data 里,每轮 fork 按当时的值拷一份进本轮 loopScope,闭包各拿各的快照。[3,3,3] 和 [0,1,2] 就这么分别出来了,分叉点不在循环里,在 declareVar 那三行爬升循环里。
顺带验证了 ES5 时代这道题的官方解法:每轮换一个函数作用域,把 i 当参数传进去。
var fns = [];
for (var i = 0; i < 3; i++) {
(function (j) {
fns.push(function () { return j; });
})(i);
}实测输出 [0, 1, 2]。原理在 jsvm2 里同样成立:IIFE 每次调用都 createChild 一个新的 Function 作用域,参数 j 按调用时的值绑定进去,闭包捕获的是各自的那一层。var 没有块级作用域,但函数作用域永远管用,这也是当年没有 let 时大家的肌肉记忆。
这套 fork 快照对应规范 ES2015 加的 CreatePerIterationEnvironment:每轮迭代为 let/const 绑定复制一份环境记录。jsvm2 的实现是近似的,而且近似得有个明确的偏差。规范复制的是绑定本身,循环体里对 i 的赋值写在本轮环境里,update 表达式接着这份值继续走;jsvm2 复制的是值,写操作落在快照上,流不回 forScope。用例一测就露馅:
var fns = [];
for (let i = 0; i < 3; i++) {
fns.push(function () { return i; });
i = i + 10;
}
fns.map(function (f) { return f(); });规范语义下第一轮 i 被改成 10,update 后变 11,循环直接结束,闭包捕获的是本轮环境上改后的值,结果是 [10]。jsvm2 实测输出 [10, 11, 12]:body 的赋值只改了快照,forScope 里的 i 还是按 0、1、2 走满三轮,闭包拿到的是各自快照被加 10 之后的值。规范给一个值,jsvm2 给三个,偏差比看起来更明显。好在 jsvm2 主要跑 babel 编译产物,循环体内改计数器又被闭包捕获的写法几乎不出现,这个偏差至今没咬过我们。但它是个真偏差,记在这里。
TDZ:坦白没做的部分
let/const 还有一条规则 jsvm2 完全没实现:暂时性死区。规范里 let 绑定从进入作用域就存在,但到声明语句执行前处于 uninitialized 状态,访问要抛 ReferenceError。jsvm2 的 Var 只有 kind、name、value 三个字段(var.ts:9-19),declareLet 和 declareConst 建绑定时值一步到位,没有”已声明未初始化”这个第三态。pre-pass 也只扫 var 和函数声明,不碰 let/const。所以 TDZ 的输入在 jsvm2 里不会报错,而是按普通作用域链规则解析,let 和 const 一视同仁地受影响。实测:
var a = 'outer';
var t;
(function () {
t = a; // 规范:ReferenceError(TDZ)
let a = 'inner';
})();
module.exports = t;jsvm2 返回 'outer'。函数作用域里没有 a 的绑定(let 声明还没执行到),查找穿透到外层拿到了值。报错变成了静默的错值,这是最差的失败模式:不崩,但语义错了,而且错得没有痕迹。
补这个功能技术上不难:Var 加一个未初始化哨兵值,pre-pass 扫块内的 let/const 并以哨兵建绑定,Identifier 求值读到哨兵就抛 ReferenceError,声明语句执行时把哨兵换成真值。当时没做的原因很现实:输入以编译产物为主,正常写法极少踩 TDZ,踩到了编译输出也多半已经重排过。为一个低频路径给每次标识符求值加一次分支判断,性价比不划算。但”性价比不划算”和”不存在”是两回事,shadowing 场景下静默拿外层值这个行为,排查起来会很折磨人,这里如实记账。
收个尾
这篇的三个结论可以串成一句:提升是两阶段扫描的产物,不是代码移动;闭包是宿主词法捕获的白嫖,不是引擎里的特殊机制;for 循环的 var/let 分野是 declareVar 爬升和 fork 按值快照撞出来的涌现行为,规范里的 CreatePerIterationEnvironment 只是给这个现象起的名字。同时也记了两笔债:函数和 var 同名时提升顺序的 bug,以及没实现的 TDZ。这两笔债的共同点是没有引发过线上事故,所以一直排不进优先级,但它们都是和规范白纸黑字的偏差,排查到的时候会很疼,先记账再说。
控制流比数据流更麻烦。return、break、continue 没法像闭包这样白嫖宿主,jsvm2 得自己造信号对象一层层传递,而 finally 会在传递路上制造麻烦。下一篇《JSVM2:return/break/continue 怎么传,Signal 信号与 finally 的坑》,讲双通道控制流设计,以及 switch 吞 break、finally 只认 return 这两个真实 bug 的现场。

