JSVM2:变量提升与闭包,两阶段扫描与一道经典面试题

📅
2 分钟阅读
·

上一篇说明了 visitor 按节点类型分发、Path 传递 scope 和 ctx,以及 Scope 链管理变量绑定。本文说明变量提升、闭包、for 循环中 varlet 的差异,以及未实现的 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':第一遍扫描已建立 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) 建自己的调用作用域。词法作用域在定义时确定,由宿主闭包捕获 scope 实现,无需额外的解释器逻辑。

因此可通过以下用例验证闭包行为:同一个内层函数多次调用看到的是同一份外层绑定,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.tsscope.ts 没有为该示例单独编写分支。结果来自两个已有机制的组合。

第一个机制在 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 的绑定,查找会穿透到外层并取得其值。该场景不会抛错,但结果与规范不一致。

补这个功能技术上不难:Var 加一个未初始化哨兵值,pre-pass 扫块内的 let/const 并以哨兵建绑定,Identifier 求值读到哨兵就抛 ReferenceError,声明语句执行时把哨兵换成真值。当时没做的原因很现实:输入以编译产物为主,正常写法极少踩 TDZ,踩到了编译输出也多半已经重排过。为一个低频路径给每次标识符求值加一次分支判断,性价比不划算。但”性价比不划算”和”不存在”是两回事,shadowing 场景下静默拿外层值这个行为,排查起来会很折磨人,这里如实记账。

已知边界

变量提升由两阶段扫描产生;闭包由宿主的词法捕获实现;for 循环中 varlet 的差异来自 declareVar 的爬升规则和 fork 的按值复制。已知限制包括函数与 var 同名时的提升顺序,以及未实现的 TDZ。两者均与规范存在偏差。

returnbreakcontinue 不能直接复用宿主的闭包机制,jsvm2 需要用信号对象逐层传递;finally 会影响信号传播。下一篇说明双通道控制流设计,以及 switch 对 break 和 finally 对 return 的处理问题。


692 字 · 39 段落
ximing

Follow onGitHub

相关文章