系列目录
「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“——即第 1 节说的只有全局顶层代码进入块时才发 ENTER_SCOPE。在这一前提下,词法嵌套的函数层数与运行期 parentScope 链的长度相等,所以编译期可以把跨层寻址一次算定,运行期只需沿链走固定步数。反过来,GETL 运行实现里那个 while (scopeIndex--) 循环能正常工作,前提是函数被调用时新 Scope 的 parent 确实指向”定义处”的 Scope——这就是下一节的函数调用机制要保证的事。
函数调用: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——然后直接返回,不执行任何函数体。
真正驱动新 Frame 跑起来的是第 2 篇讲的 Fiber.run() 外层循环:调用者 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() 销毁,但它创建的 Scope 还被 increment/get 两个函数对象引用着,c.increment() 时新 Frame 的 Scope.parentScope 又指回它——jsvm3 里没有专门的”闭包对象”,闭包就是一份没被回收的 Scope 引用。__tests__/es5/scope-chain.test.ts 的两个用例(checkscope()() 和 checkscope() 都返回 'local scope')验证的正是这一点:无论 f 在哪里被调用,它看到的 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 才是 . 前面的对象——开头问的 ”this 从哪里来”,答案就是这两条指令路径:CALL 路径没有 target 概念,统一兜底成 realm.globalObj;CALLM 路径的 target 由编译期多压栈的那份对象带入指令。实测与源码一致:function f() { return typeof this; } f() 返回 'object',而 obj.m() 里 this === obj;把方法摘出来再普通调用(var g = obj.m; g()),this 又落回全局对象。
这里有一个已知的语义偏差:严格模式下普通调用的 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
...但这组指令是谁前置的,值得说细一点——jsvm3 里提升是两层机制。
第一层是 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,this 的差异则由 CALL/CALLM 两条指令路径承担;函数声明提升是 Babel 插件(块级声明搬到顶层)与 declareFunction()(前置 FUNCTION + SETL/SETG + POP、回填标签、改写存量引用)两层协作的结果。
至此表达式、栈帧、函数、作用域都齐了,但都是直线执行。下一篇看控制流:if、循环、break/continue 如何编译成 Label 与跳转,try/catch/finally 又如何靠 Guard 表和 Fiber.unwind() 工作。

