系列目录
「JSVM3 原理与思考」系列,基于 jsvm3 源码整理:
- 从 AST 直驱到字节码:JSVM3 的编译与执行分层
- JSVM3 如何执行字节码:指令、栈帧与求值栈
- JSVM3 如何实现函数调用、作用域与声明提升(本篇)
第 2 篇提到,GETL [0, 3] 的第一个 0 表示沿 parentScope 查找的层数。本文说明变量名如何在编译期变为索引、闭包变量如何按作用域深度寻址、函数调用如何创建 Frame、this 的来源,以及函数声明提升由哪些代码实现。字节码均来自 demo/src/disassemble.ts 对 transform() 产物的反汇编(convertES5: false,与 __tests__/helper.ts 的 run() 一致)。
变量索引化:名字只存在于编译期
Emitter(src/compiler/emitter.ts)构造时有一个字段 this.varIndex = 3——局部变量的索引从 3 开始分配,因为 0、1、2 三个槽位被预占了:VmFunction() 为每个函数脚本准备的初始作用域是 { this: 0, arguments: 1 },如果函数有名字(包括命名函数表达式),再加上 { [name]: 2 } 供函数体引用自身。
之后每次遇到变量声明,declareVar() 做两件事:把名字写进当前编译期作用域对象(scope[name] = this.varIndex++),同时 localNames[varIndex] = name 记录名字供调试和反汇编使用。var 声明固定写进 scriptScope(函数级),let/const 写进 this.scopes[0](当前块)。注意一个容易看漏的细节:函数体内的块级作用域只是编译期符号表——enterScope() 里 if (!this.scopes.length) 才发 ENTER_SCOPE 指令,即只有全局顶层代码进入块时才在运行期创建新 Scope;函数内部的块不会,let 变量的槽位和 var 一样分配在所属函数的索引空间里。
运行期中,Scope(src/vm/scope.ts)用于读写的字段是 parentScope 指针和定长 data 数组;get(i)/set(i) 按整数索引操作。names 字段仅用于调试和反查,取值路径不依赖它。emitter.end() 之后不再需要名称到索引的映射,运行期不会按变量名进行哈希查找。
GETL/SETL 与 GETG/SETG:编译期的一次分流
每个标识符在编译期都要经过一次分流,逻辑在 Emitter.scopeGet() / scopeSet():先调 this.scope(name) 沿编译期作用域链查,命中就发 GETL/SETL [scopeDepth, varIndex];查不到就把名字收进 globalNames 表,发 GETG/SETG [nameIdx]。
两条指令族的运行时代价完全不同(src/opcodes/ins.ts)。GETL/SETL 是纯数组操作:
export const GETL = createOP(OPCodeIdx.GETL, function (frame, evalStack, scope, realm, args) {
let scopeIndex = args[0];
const varIndex = args[1];
while (scopeIndex--) {
scope = scope.parentScope!;
}
evalStack.push(scope.get(varIndex));
});GETG/SETG 则要走 frame.script.globalNames[args[0]] 查出名字字符串,再到 realm.globalObj 上做属性读写。GETG 还多一层语义检查:属性不存在且 ignoreNotDefined 标记为 0 时抛 JSVMReferenceError('GETG ${k} not def')。第 2 篇提过这个标记,这里补它的来源:它由 Emitter.UnaryExpression() 在处理 typeof x 时置 1,所以 typeof 未声明变量 不会抛错,直接读未声明变量会。与 SETG 配套的还有 DECLG:纯粹的 var 声明(无初始化)只在全局对象上没有该属性时才写入 undefined,不覆盖已有值。__tests__/es5/hoisting.test.ts 的 var 用例(num = 6; var num; 结果仍为 6)验证的正是 DECLG 这个”已存在则不动”的行为。
嵌套函数:闭包变量按深度寻址
现在回答开头的问题。用一个会被内层函数反复读写的计数器作例子:
function createCounter(init) {
var count = init || 0;
return {
increment: function() { count++; },
get: function() { return count; }
};
}反汇编 increment 和 get 两个子脚本,核心各一条:
=== increment ===
0003 GETL [1, 4] ; count
0004 SR3
...
0007 SETL [1, 4] ; count
=== get ===
0003 GETL [1, 4] ; count
0004 RETV[1, 4] 的第一个参数 1 是作用域深度:沿 parentScope 走 1 层,到达 createCounter 那次调用创建的 Scope;4 是 count 的槽位(0/1/2 预占,init 分到 3,count 分到 4)。深度再套一层也同样平铺,三层嵌套 a(x) → b(y) → c() 里 c 的函数体是:
=== c ===
0003 GETL [2, 3] ; x
0005 GETL [1, 3] ; y
0006 ADD
0007 RETV该深度由编译期 Emitter.scope() 计算:遍历 this.scopes 时,只有跨越函数边界(scope === this.scriptScope)才累加,块级作用域不参与。前提是函数体内的块不会在运行期创建 Scope,即只有全局顶层代码进入块时才发射 ENTER_SCOPE。在该前提下,词法嵌套的函数层数等于运行期 parentScope 链长度,编译期可确定跨层寻址深度,运行期沿链走固定步数。函数调用时,新 Scope 的 parent 必须指向定义处 Scope,才能保证 GETL 中的 while (scopeIndex--) 正确取值。
函数调用:Frame 怎么建,闭包怎么捕获
函数对象的诞生在 FUNCTION 指令(src/opcodes/ins.ts):取 frame.script.children[scriptIndex] 这个子脚本,调 createFunction()(src/opcodes/utils.ts)。createFunction() 返回的是一个原生 JS 包装函数,关键在它闭包捕获了两样东西:子 Script,以及 FUNCTION 指令执行时当前 Frame 的活动 Scope。包装函数上还用 defProp 打了几个内部字段:__JSVMFun__ 标记”这是 jsvm3 自己造的函数”,__fiber__/__cname__/__con__ 是可写的调用上下文暂存位。
调用侧,CALL 指令(普通调用)经 call() 进入 callFun()(同文件),callFun 里按 __JSVMFun__ 分成两条路:
- 宿主原生函数(如
Map、Array.prototype.push):直接func.apply(target, args),返回值push回调用者的求值栈,调用在一条指令内完成,不产生新Frame。 - jsvm3 函数:先把当前
fiber、调用名、construct标记写到包装函数的__fiber__/__cname__/__con__上,然后同样func.apply(target, args)触发包装函数。包装函数检测到__fiber__存在,就把调用者 Frame 挂起(fiber.callStack[fiber.depth].suspended = true),接着fiber.pushFrame(script, this, scope, arguments, fun, name, construct)——这里的scope就是createFunction时捕获的定义处Scope——然后直接返回,不执行任何函数体。
第 2 篇介绍的 Fiber.run() 外层循环会执行新的 Frame:调用者 Frame 的 run() 因挂起而未完成后,frame 改为新的栈顶 Frame 并进入下一轮。jsvm3 函数调用不增加宿主调用栈,递归深度由 Fiber.maxDepth 限制。
pushFrame()(src/vm/fiber.ts)建新 Frame 时的关键一行是 new Scope(parent, script.localNames, script.localLength)——parent 就是捕获的 Scope,词法链在此接通。随后 scope.set(0, this) 写入 this,把函数对象 self 和 arguments 压进新 Frame 的求值栈;函数体的第一条指令 FUNCTION_SETUP 再把它们分别收进槽位 1 和槽位 2。形参初始化(从 arguments[i] 逐个 GET 再 SETL)第 1 篇已经展开过,不再重复。
createCounter(41) 返回后,其 Frame 已由 popFrame() 销毁,但 increment 与 get 两个函数对象仍引用它创建的 Scope。调用 c.increment() 时,新 Frame 的 Scope.parentScope 指向该 Scope。jsvm3 未定义独立的闭包对象,闭包由仍被函数引用的 Scope 表示。__tests__/es5/scope-chain.test.ts 的 checkscope()() 与 checkscope() 都返回 'local scope',表明函数可见的 scope 由定义位置决定。
this:方法调用与普通调用为何是两条指令
Emitter.CallExpression() 按 callee 的形态分开发射。callee 是 MemberExpression(obj.inc(2))时发 CALLM,栈布局是 [target, key, ...args]——开头的 SR1; LR1 先把 obj 弹进寄存器 r1 再立刻压回,栈内容不变,但 r1 里留了一份 target 副本(第 2 篇介绍过 r1/r2/r3 是 Fiber 上跨帧共享的临时槽位):
0018 GETG [1, 0] ; obj
0019 SR1
0020 LR1
0021 STRING_LITERAL [1] ; "inc"
0023 LITERAL [2]
0024 CALLM [1, 1]与之相对,普通调用(f(2))发的是 CALL,栈布局里没有 target 的位置,只有 [func, ...args]。两条指令在 src/opcodes/utils.ts 里汇入同一个 callFun(),this 的差异就落在 target 这个参数上:call() 把 target 直接传 null;callm() 则从栈上依次弹出 key 和 target,用 get(target, key) 取出函数后把 target 作为 this 传入。callFun() 开头对 target 做了一次兜底:
// "" 字符串情况 @TODO 严格模式?
if (target == null) {
target = realm.globalObj;
}普通调用的 this 按非严格模式绑定为全局对象,方法调用的 this 是 . 前的对象。CALL 路径没有 target,因而使用 realm.globalObj;CALLM 的 target 由编译期压入栈的对象提供。实测中,function f() { return typeof this; } f() 返回 'object',obj.m() 中 this === obj;var g = obj.m; g() 再次按普通调用绑定到全局对象。
这里有一个已知的语义偏差:严格模式下普通调用的 this 应该是 undefined,但 jsvm3 目前不区分严格模式,一律走上面的全局对象兜底。__tests__/es5/function.test.ts 里 “function call non context” 用例(call_1() 期望 typeof this === 'undefined',标注 // @TODO 严格模式)因此被整体注释掉,兜底代码上方那行 // "" 字符串情况 @TODO 严格模式? 注释指向的也是同一件事——这是当前 jsvm3 对严格模式 this 语义处理不完整的一个例子,普通调用按非严格模式绑定全局对象。
函数体内读 this 就是读槽位 0:inc 里 this.count 编译为 STRING_LITERAL [0] ; "count"、GETL [0, 0]、GET。箭头函数则相反,VmFunction() 里 if (node.lexicalThis) delete initialScope.this——初始作用域里没有 this,this 的查找会沿词法链落到外层,和第 3 节的普通闭包变量走同一条路。
函数声明提升:两层机制,不是一层
var a = catName("Chloe"); function catName(name) { ... }(hoisting.test.ts 的第一个用例)可执行,是因为 FUNCTION + SETL/SETG + POP 被放在前置指令中。模块顶层反汇编如下:
0000 FUNCTION [0, 0] ; catName
0001 SETG [0] ; catName
0002 POP
0003 LINE [4]
0005 GETG [0, 0] ; catName
...这组指令由两层机制前置。
第一层是 Babel 插件 src/compiler/plugin/hoisting.ts,在 transform() 的插件链最前面运行。它做两件事:var 声明用 @babel/helper-hoist-variables 提升到函数/Program 顶部;FunctionDeclaration 的 exit 钩子里,把不在函数体/Program 顶层的函数声明(典型的是 if 块里的函数声明)剪切到目标函数体顶部(unshiftContainer)或暂存到 __FunctionDeclaration__ 在 Program exit 时统一 unshift,严格模式下跳过。实际验证开关效果:if (x > 0) { function f() {...} r = f(); } 开启插件后,插件输出的代码里 function f 已被移到 Program 顶部(排在 var r; 之前);关闭插件后的结果则分两种情形:块内只引用一次时,minify-dead-code-elimination 会把声明内联进唯一的引用点,FUNCTION 指令落在 JMPT 分支内部(ENTER_SCOPE 之后),提升语义消失;引用多次(如 r = f() + f())时,declareFunction() 仍会把 FUNCTION + SETL + POP 前置到指令数组最前,但前置的 SETL 在 ENTER_SCOPE 之前执行、写进的是全局帧的 Scope,块内 GETL 读的却是 ENTER_SCOPE 新建 Scope 里的空槽位,运行时抛 f is not a function——但失败的原因不是”留在块内”,而是绑定写错了运行期 Scope。块内 var 同理:关掉插件后 var a = i; for (var i = 0; ...) 里的 i 在编译期查不到声明,被分流成 GETG,运行时直接抛 GETG i not def。
第二层是 Emitter.declareFunction(),即使关掉 Babel 插件它也生效。VmFunction() 处理带 declare 标记的节点时调用它,它把 [FUNCTION, SETL/SETG, POP] 三条指令 concat 到 this.instructions 最前面——是”整个指令数组的最前”,不是”当前语句之前”。一个可以看见的副作用是:函数体内声明的函数,其前置三元组甚至排在 FUNCTION_SETUP 之前。这之所以无害,是因为三条指令对求值栈的净效果为零(FUNCTION 压入一个值,SETL 弹栈写槽位后压回,POP 再丢掉),而 FUNCTION_SETUP 依赖的 [fn, arguments] 早已由 pushFrame 铺在栈底。前置之后还要做两件善后:遍历所有指令把已标记的 Label.ip += 3(跳转目标整体后移三条);以及替换已发射的引用指令——
替换逻辑对应”声明前引用”的场景。var a = f; function f() { return 1; } return a(); 中,var a = f 先编译,此刻 f 还没进任何编译期作用域,被分流成 GETG f;等到 declareFunction('f') 执行,f 已分到局部槽位,之前那条 GETG 就必须改写,否则运行时会去全局对象上找一个永远不会存在的 f。declareFunction() 里的两段循环分别处理两种存量引用:GETG 且名字索引匹配的,替换为新槽位的 GETL;GETL 且深度非 0、沿外层作用域查到的正是同名变量的(函数表达式内部在声明前引用了外层的 C,而本层随后声明了 function C),同样替换为本层槽位。其中 GETG→GETL 的替换有一个前置条件:仅当当前编译期作用域链非空(this.scopes.length 非零)时才执行;顶层 scopes 为空,函数声明走的是 SETG 路径,存量 GETG 引用无需改写——前面顶层例子里 GETG [0, 0] ; catName 原样保留正是这个原因。我用绕开 DCE 插件的编译管线验证了两个场景的最终指令——前者 var a = f 处确实是 GETL [0, 4] ; f 而非 GETG,后者声明前的 t = C 取到的是本层提升后的函数(运行返回 2)而非外层的 C = 1,与源码注释里留的 IIFE case 一致。
顶层和函数体顶层的函数声明由 declareFunction() 独立提升,是否开启 Babel 插件不会改变这部分产物。Babel 插件处理块级函数声明和块内 var,将声明移动到顶层以满足 Emitter 的作用域假设。
用递归与回调测试反向验证
现有测试集还覆盖以下行为:
- 递归:
__tests__/es5/recursion.test.ts用命名函数表达式自递归(fn(function fn1(){ ... fn1(); }),结果为 4)。反汇编显示fn1体内的自调用是GETL [0, 2]——走槽位 2 的自引用,不经过任何名字查找。全局函数fib的递归调用同样编译为GETL [0, 2],因为VmFunction()给所有命名函数都在初始作用域里放了自己。 - 命名函数表达式的名字可见性:
__tests__/case/callback-function.test.ts的var d = function c() { return c === d; }返回true——c命中槽位 2 的自引用,d是GETG全局查找,两者指向同一函数对象;而函数体内var c = 1遮蔽后返回false。__tests__/es5/function.test.ts的 “object function scope case1” 补了另一半:对象方法fy: function fy1() {...},函数外部typeof fy1是'undefined'——函数表达式的名字只存在于自己的作用域里,不污染外层。 - 全局函数名可被覆盖:callback 用例 case4(
function a() { b((b = 1)); return b; }返回 1)验证了函数声明绑定到全局后就是一个普通属性,赋值即覆盖。 - 声明前引用:上一节两个绕过 DCE 的验证用例,行为与浏览器一致。
小结
本文说明了函数与作用域的实现。declareVar() 在编译期为变量分配索引,运行期 Scope.data 按 [深度, 槽位] 寻址;函数体内的块不创建运行期 Scope 时,词法函数层数等于 parentScope 链长度。createFunction() 包装函数对象并捕获定义处 Scope;调用经 callFun() 分流,jsvm3 函数挂起调用者后由 pushFrame 创建新 Frame,原生函数直接 apply,CALL/CALLM 决定 this 的来源。Babel 插件处理块级声明,declareFunction() 前置 FUNCTION + SETL/SETG + POP、回填标签并改写已有引用。
至此表达式、栈帧、函数、作用域都齐了,但都是直线执行。下一篇看控制流:if、循环、break/continue 如何编译成 Label 与跳转,try/catch/finally 又如何靠 Guard 表和 Fiber.unwind() 工作。
