前面几篇讲的都是「大机制」:骨架、作用域、控制流、函数。这一篇讲表达式。表达式看起来是解释器里最简单的部分,子节点求值、按运算符组合,似乎没有什么设计可言。实际做下来,我在表达式上花的时间不比函数少,原因有两个:赋值的左值不是值,是引用;ES2015 的表面积太大,必须做取舍。这篇前半讲左值和运算符的设计,后半讲 ES2015 裁剪,包括一次对 readme 的实测打假。
左值不是值,是引用
a = 1 这行代码,右边求值得 1,很容易。左边麻烦。在 jsvm2 里,Identifier 节点的求值结果是值:identifier.ts:84-89 沿作用域链做 hasBinding,拿到就返回 $var.value,拿不到抛 ReferenceError。但赋值要写的不是值,是这个绑定本身。ECMA-262 把左边求值的结果叫 Reference,一个「指向绑定的指针」,赋值走的是 PutValue。
jsvm2 没有实现规范里的 Reference 类型。规范里的 Reference 是一个带 base(对象或环境记录)和 name 的持久结构,赋值、取值、delete、复合赋值都围绕它定义,完整实现意味着所有左值相关的求值都要重写一遍。实际用的是一招可以叫「伪左值」的办法:不管左边是什么形态,最后都收敛到一个带 getter/setter 的 Var 对象上,赋值操作统一写成 $var.value = v。Reference 的读和写两个面都保住了,丢掉的是「引用本身可以传递」这个能力,而 JS 语法里引用本来就不能传递,所以丢得起。
左边是标识符时最简单,assignmentExpression.ts:79-99 直接用 scope.hasBinding(name) 把作用域里的 Var 拿出来。Var 本身就是带 getter/setter 的类(见第 2 篇),天然就是引用。这里顺带做两件事:标识符未声明时抛 ErrNotDefined(wxml 模式下退化到非严格语义,在全局作用域补声明);$var.kind === Kind.const 时抛 Assignment to constant variable.,const 的不可写语义就落在这里(assignmentExpression.ts:104-110)。
左边是成员表达式时没有现成的 Var 可拿,a.x 只是对象上的一个属性。做法是现场造一个(assignmentExpression.ts:144-173):
$var = {
kind: Kind.var,
set value(value: any) {
// (此处略去一段注释掉的原型链特判代码)
if (object == null) {
throw overrideStack(ErrCanNotSetProperty(property, typeof object), stack, node);
} else {
object[property] = value;
}
object[property] = value;
},
get value() {
return object[property];
},
};object 和 property 是提前求值好的局部变量,setter 闭包捕获它们。这个匿名对象活不过这一次赋值,但够用了:赋值语义需要的全部就是「能读当前值、能写新值」。
有了伪左值,13 种赋值运算符收敛成一张表(assignmentExpression.ts:9-62):表项是 ($var, v) => { $var.value += v; return $var.value; } 这样的小函数,AssignmentExpression 处理器把左值和右值准备好之后,最后一行 AssignmentExpressionMap[node.operator]($var, rightValue) 统一收尾。=、+=、-=、位运算复合赋值都在表里,连 ES2016 的 **= 都有,用 Math.pow 实现。这一点记住,后面打假要用。
一道面试题的全文推演
assignmentExpression.ts:112-123 有一段我当时写下的中文注释,推演的是那道经典面试题:
var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
// 问:执行完 a 和 b 分别是什么答案是 a = {n:2},b = {n:1, x:{n:2}}。关键是赋值的求值顺序:先把左边的引用解析出来并挂起,再算右边,最后通过挂起的引用写入。左引用解析时 a.x 还不存在,JS 会在堆里的旧对象上创建成员 x,引用指向这个新成员。接着算右边,a = {n:2} 是个简单赋值,a 这个名字重新绑到新对象上,但旧对象因为有 b 占用不会被回收。最后一步把 {n:2} 写入刚才挂起的引用,写入的是旧对象的 x。所以 b.x 等于新的 a。
这段推演能自然落地,是因为代码顺序恰好对了:assignmentExpression.ts:128 先求 object,137 行再求 rightValue,setter 闭包捕获的是旧对象。实测跑了一遍,输出 {"n":2} 和 {"n":1,"x":{"n":2}},和规范一致。
但同一文件里藏着一个顺序错误。计算属性的左值,property 在 139-141 行才求值,位置在 rightValue 之后。规范要求对象和属性都先求值再算右边。我写了个用例实测:
var order = [];
function g() { order.push('obj'); return {}; }
function h() { order.push('prop'); return 'k'; }
function r() { order.push('rhs'); return 1; }
g()[h()] = r();
// 规范: obj,prop,rhs
// jsvm2 实测: obj,rhs,prop属性求值和右边求值颠倒,平时写不出有副作用的属性表达式就暴露不了,属于语义偏差里藏得比较深的一种。
运算符语义全部委给宿主
和左值的手工形成对照,运算符的部分几乎全白嫖。binaryExpression.ts:5-30 是一张运算符表,每个运算符映射到一个宿主 lambda:'<<': (a, b) => a << b,'==': (a, b) => a == b,instanceof: (a, b) => a instanceof b,一共 21 个。处理器正文只有三行(binaryExpression.ts:33-39):左右子节点求值,查表,调用。
这么做的收益不是省了几行代码,是免费拿到了 JS 类型转换的全部语义。== 的抽象相等比较、+ 的字符串拼接规则、关系运算符的 ToPrimitive,规范里每一个都是好几节的算法,手写几乎一定写错。委给宿主之后这些规则一行都不用写,而且和宿主行为逐字节一致。instanceof 也顺带正确了,因为原型链本来就是宿主的(见第 5 篇)。in 同理,对象就是宿主对象,属性查找走宿主语义。
这张表能成立有一个前提:解释器里流动的值就是宿主值。jsvm2 不给值包包装类,数字就是宿主数字,对象就是宿主对象,所以宿主的 +、<、== 才能直接吃。这个前提在第 5 篇讲函数时已经反复用到,表达式这里是另一个受益点。一旦决定自己包装值,这张表就全部作废,每个运算符都得先拆包再算再包回去。
&& 和 || 的处理同样白嫖,但值得单独看(logicalExpression.ts:7-12):
return {
'||': () =>
path.visitor(path.createChild(node.left)) || path.visitor(path.createChild(node.right)),
'&&': () =>
path.visitor(path.createChild(node.left)) && path.visitor(path.createChild(node.right)),
}[node.operator]();右边子节点的求值包在 path.visitor(...) 里,而 path.visitor(...) 出现在宿主 || 的右操作数位置。宿主的短路惰性直接变成解释器的短路惰性:左边为真,右边那个子树根本不会被遍历。另一个容易做错的点是返回值,规范里 a || b 返回的是操作数本身而不是布尔值,这里宿主 || 返回什么解释器就返回什么,自然正确。如果当初自作主张包一层 Boolean(),就埋了一个 bug。
还有一个语义特例在 typeof。typeof 未声明变量 不抛错,返回 'undefined',这是规范里唯一容忍不可解析引用的运算符。实现在 unaryExpression.ts:41-48:参数是标识符时先查 scope.hasBinding,查不到直接返回 'undefined',根本不触发求值;参数是其他表达式才正常求值。对比 identifier.ts:84-89,同一个 Identifier 节点,独立求值时未声明抛 ReferenceError,在 typeof 上下文里返回 'undefined'。节点类型相同,语义由上下文决定,这也是为什么求值函数需要拿到 Path 而不只是裸节点。
坑集锦
除了上面计算属性的顺序问题,还有几个实测确认过的坑,一并记下来。
成员复合赋值的 setter 写了两遍。回看上面贴的代码,else 分支里写了一次 object[property] = value,if 结束后第 168 行又无条件写了一次。普通数据属性写两遍无害,实测 o.x += 5 结果是 6,功能正常;但如果属性带 setter 或者目标是 Proxy,副作用就执行两次。从结构看第 168 行像是重构残留。
UpdateExpression 双重求值。updateExpression.ts:22-36 求一遍 object 和 property 来构造伪 Var,47 行又对整个 node.argument 调了一次 path.visitor 拿当前值。成员是计算属性时副作用函数跑两遍,实测:
var calls = 0;
function f() { calls++; return 'x'; }
var o = { x: 1 };
o[f()]++;
// 规范: calls === 1
// jsvm2 实测: calls === 2delete 标识符只读没删。unaryExpression.ts:21-28 的标识符分支,拿到 this 绑定后做的是 return $this.value[node.argument.name],读了一次值当返回值,什么都没删。严格模式下 @babel/parser 直接拒绝 delete x 这种写法(解析期就报错),所以这个分支几乎不可达,但可达时行为也是错的。
这些坑的共同点:都是左值相关。右边求值没什么可错的,错的都在「引用怎么建模」这一侧,印证了开头的判断。
做不完的现实
到这里 ES5 的表达式就讲完了。转向 ES2015 时面对的不是设计问题,是工程量问题:ES2015 新增的语法节点几十个,每一个背后都是规范里的一节。jsvm2 的现实约束是业务只跑 babel 产物,不是所有语法形态都会出现,于是一开始就定了「够用就好」的方针。
最后 es2015 目录里只有 6 个文件 79 行,其中 index.ts 还是注册表,真正有语义的 5 个文件 66 行。能这么少,是因为大部分 ES2015 特性寄生在 ES5 的实现里:
- let/const 没有新文件。
variable.ts里scope.declare(kind, ...)按 kind 分发(scope.ts:60-62),const 的写检查在赋值处理器里,就是前面说的assignmentExpression.ts:104。TDZ 没有做,let 声明前访问会静默拿到外层同名绑定,这个偏差第 3 篇已经交代过,不再重复。 - 解构没有新文件,塞在
variable.ts:29-75。 - 默认参数没有新文件,
function.ts:48-50把 AssignmentPattern 节点连同实参值交给 visitor,由 es2015 目录里 18 行的assignmentPattern.ts声明成 const。这里有个细节:默认参数声明成了 const,函数体里对参数再赋值会抛Assignment to constant variable.,而规范里参数是可写的。普通参数走的是declareVar,只有带默认值的参数被升级成了 const,同一个函数里两种参数的可写性不一致。rest 参数同理(function.ts:51-53),同样落在 const 上。 - 箭头函数是独立文件,第 5 篇讲过,两行实现词法 this。
new.target由metaProperty.ts的 7 行实现,近似方案第 5 篇也交代过。
寄生策略让 ES2015 的增量成本变得很小,但代价是覆盖不完整,而且不完整得没什么规律。哪些路径断了,下面实测。
readme 实测打假
readme 里有一张语法支持清单,打钩的表示支持。我把 ES2015 之后的部分逐条拿到引擎里跑,发现两处打钩打早了。
第一处是 ForOfStatement,readme 打了钩,但 es5 目录只有 ForStatement 和 ForInStatement,es2015 目录根本没有循环语句的文件。实测:
for (const x of [1, 2]) {}
// jsvm2 实测: [jsvm] Not implement for 'ForOfStatement' syntax直接抛 ErrImplement。这符合「未实现就明确报错」的原则,但 readme 不该打钩。
第二处更微妙。readme 的 ES2016 一节给 BinaryExpression 打了钩。ES2016 唯一的语法新增就是幂运算符 **,这个钩的意思只能是「支持 **」。但 binaryExpression.ts:5-30 的运算符表里没有 **。实测:
2 ** 3
// jsvm2 实测: BinaryExpressionOperatorMap[node.operator] is not a function查表落空,抛一个连错误信息都不友好的 TypeError。而前面提到的 **= 倒是用 Math.pow 实现了,实测 var a = 2; a **= 3 得 8。同一族语义,复合赋值做了,二元运算符没做,readme 按「做了」宣传。
这两处偏差的原因我猜是同一个:清单按「打算支持」维护,不是按「实测支持」维护。第 1 篇里说这张清单是诚实的,现在要做个修正:打钩的 ES5 部分经得起核对,ES2015 之后的部分有两个名不副实的钩。特性清单也需要测试守护,靠自觉维护就会漂。
顺带还发现一个反向的偏差。readme 里 ObjectPattern 打了钩、ArrayPattern 没打钩,但 variable.ts 里两个分支都存在(variable.ts:29 和 50),实现程度差不多,都是「简单形态可用、复杂形态断」。数组解构没打钩属于低估了自己,但比起前面两个高估,这种偏差无害,只是清单和现实又对不上了一次。
解构的组合矩阵
解构是裁剪不完整的典型样本。把上下文(声明、赋值、函数参数)乘以模式(对象、数组)再乘以特性(默认值、重命名、嵌套、rest),组合出十几条路径,每条路径实现了大概 80%,断的位置各不相同。逐条实测的结果:
声明加对象解构是覆盖最好的一条路径。var {a, b: alias} = obj 正常。断掉的地方:...rest 被静默忽略,实测 var {p, ...rest} = {p:1, q:2} 之后 typeof rest 是 'undefined',rest 根本没声明,因为 variable.ts:36 只认 ObjectProperty,RestElement 直接跳过。重命名加默认值也断,var {m: alias = 9} = {m:1} 之后 alias 没声明,因为 variable.ts:39 取 n.value.name,而默认值形态下 n.value 是 AssignmentPattern 节点,没有 name。取不到键的属性同样不声明,规范里应该声明为 undefined,这里是压根没有绑定,后续访问会抛 ReferenceError 而不是拿到 undefined。
声明加数组解构只处理元素是普通标识符的情况(variable.ts:66 的 isIdentifier 判断)。var [q = 5] = [1] 里元素是 AssignmentPattern,被跳过,q 未声明,后面用到 q 时抛 q is not defined。嵌套模式和 rest 同理跳过。
赋值加解构整条路径都没有。AssignmentExpression 的左值只处理 Identifier 和 MemberExpression 两种形态,({z} = {z:1}) 走到的是初始桩 Var,它的 setter 直接 throw new Error('no implement')(assignmentExpression.ts:66-76)。报错信息简陋,但至少是明确的报错,不是静默写错地方。
参数加解构也是断的,而且断法最隐蔽:function.ts:54-56 对不认识的参数形态只 console.error('Function: 无效参数'),不抛错,参数静默不绑定。默认值加解构的组合(function f({a} = {}))则走 AssignmentPattern 处理器,左边不是标识符时抛 ErrImplement(assignmentPattern.ts:15-17),这一种又是报错。同一个「不支持」,三种表现:静默忽略、console 告警、明确抛错。
调用处的 spread 没有 flatten。spreadElement.ts:4-6 原样返回数组,callExpression.ts:69 对每个参数求值后直接传给 func.apply,没有展开。实测 f(...[1,2,3]) 里 arguments.length 是 1,整个数组作为第一个参数传进去了。
这份矩阵摆出来不好看,但它在生产上没出过一次问题。原因是输入形态被收窄过:业务代码全部是 babel 产物,解构大多被编译期降级,残留的也是 var {a} = obj 这种最简单的形态。矩阵里断掉的路径,恰恰是工具链永远不会生成的路径。「编译期收窄解释期」这条主线,在解构上体现得最彻底:解释器的语义缺口和转译层的输出形态是配套的。
裁剪的哲学
回头看,ES2015 这块的处理可以归纳成三条。
能委给宿主的委给宿主,这是全系列反复讲的,运算符表是收益最大的样本,免费的类型转换和免费的短路。
实现不起的明确报错,ErrImplement 和桩 setter 的 no implement 都是这个思路。引擎表现为严格模式,本来就把一类语义(with、八进制字面量、非严格 delete)挡在了 parser 层,报错是默认姿态。
裁剪要诚实,这一条做得不够好。明确的报错是诚实,readme 上的两个错钩不是。语义缺口本身可以接受,业务输入是收窄的,缺口不暴露;但文档把缺口说成已支持,就把「知道自己不支持」变成了「不知道自己不支持」,这两者的差别在排障时是小时和天的差别。
至于那些半实现的角落,比如解构矩阵和 UpdateExpression 的双重求值,我的态度是记档比修优先。修当然都应该修,但引擎的维护资源有限,缺口又都有编译层兜底,把这些路径的实际行为如实写下来,比悄悄修掉其中一两个更有价值。读者拿 jsvm2 跑自己的代码时,需要的是一张准确的「什么会断、怎么断」的地图,而不是一张更好看的特性清单。这一篇把实测结果都摆出来,就是补记档这一步。
下一篇预告:语义覆盖度说了不算,测试说了算,下一篇讲 jsvm2 怎么用五层测试证明一台 JS 引擎是对的。

