前面几篇讲了解释器的骨架、作用域与闭包、控制流信号,这一篇讲函数。函数是 jsvm2 里「白嫖宿主」这条主线体现得最彻底的部分:call/apply/bind、instanceof、原型链、构造器返回对象的特例,全部零成本来自宿主。真正手写的只有三件事,作用域捕获、this 传递、构造调用的意图标记。写解释器这几年我有个体会:实现者的第一美德不是勤奋,是识别哪些语义根本不用自己写。
函数对象就是一个宿主闭包
第三篇提过闭包白嫖宿主,这里看全貌。FunctionExpression 的求值结果就是直接造一个宿主函数(function.ts:33):
// function.ts:33-43
const func = function (this: any, ...args) {
stack.enter(functionName);
// 判断是不是构造函数,如果是构造函数要返回一个实例
shouldReturnInstance = newFunction.has(func);
const funcScope = scope.createChild(ScopeType.Function);
if (node.id && isFunctionExpression(node)) {
funcScope.declareVar((node as any).id.name, func, funcScope);
}
// ...参数绑定这个 func 通过宿主闭包捕获了定义时的 scope,词法作用域就到手了。函数体执行时基于它 createChild 一个 Function 作用域,参数逐个绑定:Identifier 直接 declareVar,AssignmentPattern(默认参数)和 RestElement 交给对应的 visitor 处理(function.ts:44-57)。arguments 也不用自己造,宿主的 arguments 对象直接 declareConst 进作用域(function.ts:67),类数组结构、length、索引访问全是真的。
宿主函数白送的东西还有 call、apply、bind。VM 里的函数就是宿主函数,VM 里的代码写 fn.call(obj),走的是宿主 Function.prototype 上的真方法,不用模拟。也有白送得不准的:toString 会打印出宿主包装函数的源码而不是用户源码,代码里挂着 TODO(function.ts:14),业务里没人依赖,先放着。
有两样东西宿主给不了:name 和 length。解释器拼出来的宿主函数名固定是 func,length 是 0(rest 参数不计数),得用 defineProperty 补(utils.ts:19-29):
// utils.ts:19-29
export function defineFunction(func, node, name?) {
Object.defineProperties(func, {
name: { value: name || (node.id ? node.id.name : ''), ... },
length: { value: node.params.length, ... },
});
}这是典型的补丁式白嫖:主体白拿,宿主不知道的元信息手工补上。
FunctionDeclaration 比 FunctionExpression 更薄:复用上面的逻辑求值出函数对象,补 name/length,然后 declareVar 到当前作用域(function.ts:16-24)。源码里留着一句注释 “Function can repeat declaration”,重复声明直接覆盖。提升不在这个节点里做,靠第三篇讲的 pre-pass 先扫一遍声明完成,函数节点自己只管求值。
this:作用域里一个名叫 ‘this’ 的常量
jsvm2 不给 this 建专门通道,它就是作用域里一个名叫 ‘this’ 的 const 绑定。函数被调用时,把宿主传进来的 this 绑进函数作用域(function.ts:59):
funcScope.declareConst(THIS, this);ThisExpression 的实现因此只有 4 行,就是一次作用域查找(thisExpression.ts:5-9):
export function ThisExpression(path: Path<t.ThisExpression>) {
const { scope } = path;
const tv = scope.hasBinding(THIS);
return tv ? tv.value : null;
}hasBinding 沿作用域链向上找。内层函数没有自己的 this 绑定,就拿到外层的,这和规范里 ResolveThisBinding 沿环境记录查找是同一回事,只是规范用一个专门的槽位,jsvm2 复用了普通变量机制。根作用域创建时就预置了 this 为 undefined(scope.ts:233-239),所以 VM 顶层代码里取 this 拿到的是 undefined 而不是全局对象,和 ES module 的顶层语义一致。
严格模式是白捡的。解释器源码是 ES module,天然严格模式,所以 func.apply(undefined, args) 时宿主不会把 undefined box 成全局对象,VM 里的函数全部表现为严格模式语义。反过来说,非严格模式下「this 自动指向全局对象」这个行为没法白嫖,要实现就得在调用处判断当前代码是否严格,再决定传 undefined 还是全局对象。jsvm2 没做。babel 产物基本都是严格模式,这是一次有意识的裁剪。
runtimeThis:一次性的注册-消费通道
难的是 obj.fn() 这种成员调用:函数从对象上取下来之后,调用时 this 从哪来?
最早的直觉方案是在 MemberExpression 里直接 target.bind(obj),返回一个绑好 this 的函数。代码里还留着否决这个方案的注释(memberExpression.ts:60-64):Date 这类宿主对象不行,Date.now 挂在构造函数上,一旦 bind 就找不到静态方法;而且 bind 每次生成新函数对象,同一个方法取两次值身份就不相等,缓存和 === 判断全乱。所以定下规矩:member 的时候不处理 bind,统一在调用的地方处理。MemberExpression 原样返回宿主函数(memberExpression.ts:65-68),this 的传递变成另一个问题。
jsvm2 的方案是拿一个 WeakMap 当一次性快递柜(utils.ts:44 的 runtimeThis)。MemberExpression 求值时,如果取出来的属性是函数、父节点是调用表达式,就把对象寄存进去(memberExpression.ts:7-18):
// memberExpression.ts:7-18
function defineCtx(target: any, parent: any, ctx: any) {
if (
isFunction(target) &&
parent &&
isCallExpression(parent.node) &&
!runtimeThis.has(parent)
) {
runtimeThis.set(parent, ctx);
}
}WeakMap 的 key 是 CallExpression 的 Path 对象。Path 是每次求值时现场创建的包装,天然唯一对应一次调用,Path 被回收时条目自动消失,不用手动维护生命周期。
CallExpression 执行前取件,取完立刻销毁(callExpression.ts:99-102):
let context: any = undefined;
if (runtimeThis.has(path)) {
context = runtimeThis.get(path);
runtimeThis.delete(path);
}
const result = func.apply(context, args);这个通道能工作,前提是一个容易踩坑的求值顺序:必须先求 callee,再求参数。callExpression.ts:64-67 留了一段注释,是实战踩坑的记录:
顺序不能乱,需要对执行的函数的ctx做提前确定,参数里面也可能有函数表达式 比如 obj2.doFoo(obj.foo)……所以先取左值,然后memberExpression里面作判定,已经有值的时候就不重复设置了,在获取context的时候取完销毁,从而做到一次运行只取一次
拆开看三种情况。obj.fn():callee 的 MemberExpression 先求值,把 obj 寄存,调用时取走,this 正确。obj2.doFoo(obj.foo):参数里的 obj.foo 同样满足「父节点是调用的函数成员」,也会触发 defineCtx,但 callee 先求值已经占了坑,!runtimeThis.has(parent) 把后来的挡掉,obj2 胜出,结果正确。d().c() 链式调用:.c 的宿主对象是 d() 的返回值,MemberExpression 求 object 时已经把 d() 跑过一遍,拿到的返回值寄存为 ctx,调用 c 时不用回头再跑一次 d()。
顺带一个工程细节:报错用的函数名是从 AST 现拼的。成员调用拼成 obj.fn 或 obj["fn"],标识符调用直接用名字(callExpression.ts:15-34),抛 ErrIsNotFunction 时带上,业务方看到的报错形态和浏览器里差不多。这种地方不影响语义,但影响每天排查问题的速度。
这个通道本质是个启发式:它假设「一个调用节点下第一个被求值的函数成员表达式就是 callee」。按代码推演,能找到一个漏网的边界:doFoo(obj.fn)。callee 是普通标识符,不占坑;参数里的 obj.fn 反而把自己寄存成这次调用的 ctx,于是 doFoo 会以 this 为 obj 被调用,和标准语义不符。babel 产物里这种写法不多,我当时没踩到,是这次复盘读代码才看到的。记下来,它和第四篇的控制流 bug 是同一类代价:通道式设计省掉了结构化传参,就要接受边界上的误判。
箭头函数:两行词法 this
箭头函数用宿主箭头函数实现(arrowFunctionExpression.ts:10),词法 this 的核心就是两行(arrowFunctionExpression.ts:16-17):
const lastThis = scope.hasBinding(THIS);
newScope.declareConst(THIS, lastThis ? lastThis.value : null);定义时沿作用域链查到当前的 this 绑定,原样钉进自己的函数作用域。之后这个箭头函数无论被谁、以什么方式调用,this 都不变,因为调用方根本没有机会改写这个绑定。
白送的有两件。一是 new (() => {}) 报错:宿主箭头函数本来就不是构造器,宿主替你抛 TypeError,一行判断不用写。二是 arguments 穿透:箭头函数的作用域里没有声明 ARGUMENTS,查找时沿作用域链落进外层普通函数的 arguments,恰好就是标准语义。
也有没白嫖到的角落。参数绑定只认 Identifier(arrowFunctionExpression.ts:13 把 node.params[i] 直接强转),解构参数走不通。babel 会把参数解构编译进函数体,跑产物没事;把手写源码直接喂给 VM,解构参数会被静默丢掉。普通函数的参数绑定走了 visitor 分发所以支持解构,箭头函数这里省掉了,算一处不对齐。
new:14 行,和一个零实现的特例
NewExpression 全文 14 行(newExpression.ts:5-18):
export function NewExpression(path: Path<t.NewExpression>) {
const { node, stack } = path;
const func = path.visitor(path.createChild(node.callee));
const args: any[] = node.arguments.map((arg) => path.visitor(path.createChild(arg)));
func.prototype.constructor = func;
newFunction.set(func, true);
let entity = new func(...args);
newFunction.delete(func);
// stack track for Error constructor
if (func === Error || entity instanceof Error) {
entity = overrideStack(entity, stack, node);
}
return entity;
}关键在 new func(...args)。func 是宿主函数,宿主的 new 会完成建对象、接原型、绑 this、执行函数体这一整套流程。JS 里那条最绕的规则,构造器 return 一个对象则 new 的结果是那个对象、return 原始值则忽略,jsvm2 一行代码都没写。函数体里 return 的值通过上一篇讲的 Signal 通道传出来,在 function.ts:73-74 拆包后直接作为宿主函数的返回值,宿主 new 语义自动接管这条特例。动手之前我在这块设计过一版自己建对象、自己接原型的方案,写完 new 这一行发现全是废品,删了。
最后三行是给 Error 的特判:VM 里 new Error() 时把解释器自己的调用栈拼进 error.stack(overrideStack),不然业务方拿到的错误栈只有宿主栈,定位不到 VM 内部是哪一行。沙箱里跑的是远程下发的代码,报错能定位是刚需,这个特判省不掉。
原型链整条线都是白嫖的。func.prototype 是宿主对象,func.prototype.constructor = func 只是顺手把 constructor 指回去;__proto__、Object.create、instanceof 全是宿主语义,instanceof 就是运算符表里的一行 (a, b) => a instanceof b(binaryExpression.ts:15)。面试五连问,Function.__proto__ === Function.prototype、foo instanceof Foo 之类,在 VM 里全部自然成立,因为 VM 里的函数就是宿主函数,共享同一套原型设施。
这条路不是一开始就选对的。utils.ts:31-36 有一段注释掉的 Prototype 类,memberExpression.ts:42-51 也有注释掉的分支:取属性时判断 obj instanceof Prototype,是的话改走 obj.constructor.prototype 查找。那是自建原型设施的残骸。那条路意味着 VM 的原型链和宿主平行存在,instanceof、Object.create、原型方法查找全要自己模拟一遍,而且必然模拟不全。写完一段就意识到是在把宿主已经做对的事情重做一遍,整段废弃,换成现在的零实现。注释没删,留着当路标。
newFunction 标记和它的可重入 bug
宿主 new 帮不了的只有一件事:函数体得知道自己是不是在被构造调用。规范里这是随调用帧传递的构造意图,jsvm2 用的是另一个 WeakMap 标记。new 之前 newFunction.set(func, true)(newExpression.ts:10),函数体开头查标记(function.ts:37),没有 return 信号时显式 return this(function.ts:75-76):
// function.ts:73-79
if (result instanceof Signal) {
return result.value;
} else if (shouldReturnInstance) {
return this;
} else {
return result;
}这个「先挂号、后取号」的设计有可重入问题,我现在能写出两个稳定复现的例子。
第一个,窗口期误判。标记在 new func(...args) 之前挂上、之后摘除,整个构造过程都在窗口期内。这期间同一个函数被普通调用,也会被当成构造调用:
function Foo(recurse) {
if (recurse) inner = obj.Foo();
}
var obj = { Foo: Foo };
var inner;
new Foo(true);
// inner === obj,标准语义应为 undefinedobj.Foo() 是普通成员调用,但执行到 function.ts:37 时 newFunction.has(func) 为 true,于是走到 return this,把 obj 返回了。
第二个,异常残留。newExpression.ts 里没有 try/finally,构造器抛异常时 newFunction.delete(func) 执行不到,标记永久残留:
function Foo(shouldThrow) {
if (shouldThrow) throw new Error('boom');
}
try { new Foo(true); } catch (e) {}
var o = { f: Foo };
var r = o.f();
// r === o,标准语义应为 undefined正确做法不难说:意图应该随调用帧传递,而不是通过一个全局表反查函数。比如在宿主 new 生成的实例上挂一个只有引擎认识的标记(WeakSet 存实例),函数体通过 this 判断自己是否处于构造调用。当时没改,一是这两个 case 在 babel 产物和 lodash 回归里都没踩到,二是改动要穿过 function.ts 和 newExpression.ts 之间的接口。诚实地说,这属于知道怎么修、没排上期的债。
new.target:一个会失效的启发式
new.target 的实现也是借来的。构造调用时 this 是新建的实例,this.__proto__.constructor 就是构造器(function.ts:60-66):
funcScope.declareConst(NEW, {
target:
this && this.__proto__ && this.__proto__.constructor
? this.__proto__.constructor
: undefined,
});普通调用时 this 是 undefined,target 就是 undefined,正好符合规范。失效场景也清楚:原型被换过又没把 constructor 指回来时,比如 Foo.prototype = Object.create(Base.prototype) 之后忘了修正 constructor,new.target 会报成 Base;实例的原型链被 Object.setPrototypeOf 手动改过,同样会带偏。它读的是实例的出身,不是调用意图,而出身是可以被改的。
小结
把这一篇盘一遍,jsvm2 在函数这块自己写的代码少得可怜:函数对象、参数绑定、arguments、call/apply/bind、原型链、instanceof、构造器返回对象特例、严格模式、箭头函数不能 new,全来自宿主。手写的只有三样,定义时捕获 scope、用 WeakMap 通道传 this、用 WeakMap 标记传构造意图。后两样本质上都是调用意图的带外通道,也都付了代价:runtimeThis 有边界漏网,newFunction 有可重入 bug。白嫖不是没成本,省下的实现量会变成通道设计的复杂度,只是这笔账算下来仍然划算。回头看,函数这一层最能说明白嫖宿主的前提:得先摸清宿主的语义边界在哪,才知道哪块能白拿、哪块必须自己补。补的地方越少,bug 的表面积越小,这篇里的两个 bug 恰好都长在自己手写的那三样东西上。
下一篇讲表达式求值的现实主义:左值怎么建模、运算符怎么全部委给宿主,以及 ES2015 那些「够用就好」的裁剪,包括 readme 里打了钩但其实没实现的 for-of。

