这篇文章的是写给想读规范而不知如何下手的同学的一个入门手册。在实际工作中其实很少遇到奇奇怪怪的 JS 问题,倒是在 JS 测验题中经常看到一些稀奇古怪的问题。对于这些问题最好的解释就是规范文档,规范文档已经解释的清晰明了,何必舍近求远去搜寻其他人的解释呢。如果你觉得规范太难懂,或许是你打开的方式不对。这儿是最新的规范 ecma262
概览
首先不建议像读一本书那样阅读规范,规范更应该像一本手册。首先要了解哪些东西属于规范哪些不属于,简单来说语法语义和内置属性方法属于规范,与宿主环境有关的都不属于规范范畴。接下来,我们看一下规范文档的组织,1 - 3章略过,第 4 章概述可以浏览一下,除了术语和定义之外,介绍了文档的组织: 第 5 章定义了整个文档所使用的约定和惯例;6 - 9 章定义运行相关的执行环境;10 - 16 章 定义实际语言的语法特性和运行时语义;17 - 26 定义标准库;27 章内存一致性模型。大概可以看出,5 - 9 章是可能在阅读其它章节需要的,第 10 - 16 章是实际比较关心的。上面说过,不建议你从上到下的去阅读,而是根据自己的需要阅读。接下来就以实际的例子来带你阅读这部分内容,我会描述自己怎么查找这个手册的。
开始之前
可能你已经迫不及待了,但是在开始之前我们还是有必要先做一些准备工作。想必你对执行环境已经有所了解。如果不是很清楚,可以翻下我之前的一篇文章 最简解释器实现-解释作用域与闭包。简单来说,我们遇到的一些变量不过是标识符,我们需要维护一个环境记录项 Environment Record,可以理解为只是 <key, value> 的一张表,同时这个环境记录项还可以指向外部的环境记录项。我们常说的执行环境就对应着一个环境记录项,每次进入函数会创建一个新的环境,也就有一份新的环境记录项。
在规范的第 6 章定义了语言类型和规范类型。语言类型就是 JS 支持的那几种类型。规范类型就是解释这些规范的时候会用到的数据结构,有两种会经常见到,所以需要解释一下:Reference,Completion。
Reference
我们需要从解释器的角度去理解 Reference 类型的作用,我们看到的变量只是一个标识符,在解释执行代码时候不一定是直接想要取得它(标识符引用)的值,比如我们可能对它进行赋值。这个时候我们使用一个叫 Reference 引用类型 的对象,这是一个简单的数据结构,包括基值 BaseValue 和引用名 ReferencedName。比如我们看到一个内部变量 name ,解析得到的引用类型 ref,引用名就是字符串 'name',基值就是它所在的环境记录项。有了这样的数据结构我们才可以对一个变量赋值或者解析它的值。
标识符定义在 12.1 节 首先我们会看到前面是静态语义相关的小节,以 SS 或 Static Semantics 开头,这部分会定义早期的解析错误,产生式。如果没有遇到语法错误我们一般不会关心这部分。直接找到 RS: Evaluation 这个才是我们关注的主题,运行时语义就是已经有了语法树的情况下解释器要怎么解释执行这段代码。
你会看到对标识符的解释执行只是调用了 ResolveBinding。还有下面一段注释:
The result of evaluating an IdentifierReference is always a value of type Reference. 强调了它一定返回一个引用类型。
如果你继续点击 ResolveBinding 查看的话,看到它又只是加了当前执行环境作为参数调用 GetIdentifierReference 内部方法。 GetIdentifierReference 方法会递归的向外部环境记录项查找,直到最外层或者在一个执行环境中找到了这个标识符。返回的引用基值就是那个环境记录项或者 undefined。
对于形如 user.name 或者 user.displayName 成员属性访问的解释执行,得到的引用基值你可以自己尝试查找以下吗?12.3.2 Property Accessors
Completion
Completion 完结类型是用来描述一些状态控制的,我们在解释执行一条语句的时候,不只是得到了一个值,我们需要一个数据结构描述这个语句的类型,比如是否是终止,跳出循环或者返回类型。Completion 的结构如 { Type, Value, Target }。Type 可能为 normal, break, continue, return 和 throw。
ReturnIfAbrupt 是对返回的结果进行检查,如果 Type 是 Normal 则返回值 Value,否则返回这个完结类型。
另外算法在描述返回完结类型时,很多都是缩写形式。
Return "Infinity".意思是Return NormalCompletion("Infinity").? OperationName().表示ReturnIfAbrupt(OperationName()).! OperationName().会进行断言该完结类型是 Normal 的
建议开始阅读时先不考虑完结类型,不考虑控制转移时不影响你对规范的理解。这儿对它解释是为了减少大家阅读规范时不必要的困惑。
很难找到一个合适的词来翻译这些术语,本文的翻译使用颜海镜的中文版规范术语翻译 yanhaijing.com/es5/
这儿并没有对执行环境->环境记录项(词法环境,变量环境)细分。
实践
1.连续赋值
先从简单的开始,下面是一个赋值的问题。
var a = {n: 1};
var b = a;
a.x = a = {n: 2};
console.log(a.x) // undefined
console.log(b.x) // {n:2}
这道题我们只需要搞清楚 a.x = a = {n: 2}; 的执行顺序即可,这是一个赋值的问题,如果你碰巧没有做对,可以在规范中查下答案。在规范中搜索 assignment 找到赋值操作这一节内容,直接定位到 RS 部分:
12.15.4 Assignment Operators - Runtime Semantics Evaluation

第一行是确定左侧不是对象字面量或者数组字面量,毕竟现在也是支持 [a, b] = [b, a] 的。接着第二行 a 意思是令 lref 为解释执行左侧表达式的结果,我们要解释执行的是 a.x(也是上面提到的成员属性), 得到的是一个引用类型,形如 { BaseValue: a, ReferencedName: 'x' }。接下来我们只看正常情况 d ,解释执行右侧表达式,进行求值得到 rval。最后才会执行赋值操作,并且返回 rval。
至于 PutValue 和 GetValue 根据名词也可以理解它的含义,这儿不过多解释。
2. Bool 值 和 非严格比较
先看这个奇怪的等式 [] == ![]
要解释这个,我们先解释 ! 操作符,这个是一元操作符,直接找到 运行时语义部分
- Let expr be the result of evaluating UnaryExpression.
- Let oldValue be ! ToBoolean(? GetValue(expr)).
- If oldValue is true, return false.
- Return true.
第一步解释执行操作符之后表达式,第二步拿到引用的值即空数组然后调用 ToBoolean。 ToBoolean 定义在 7.1.2 节,这是转换表:

另外值得指出的是 if 语句也是调用 ToBoolean 判断的。 至于为什么 [] == ![] 成立, 我们要需要看 == 的定义了,找到 12.11.3 RS:Evaulation of Equality Expression
,这个是根据 7.2.15 Abstract Equality Comparison
来判断的。这节算法步骤比较多,但是只需要找到我们对应的条目即可,现在我们知道右侧的值是 false 就是相当于比较 [] == false,可以找到它对应第 9 条
- If Type(y) is Boolean, return the result of the comparison x == ! ToNumber(y).
然后我们相当于比较 [] == 0,这个又对应的是第 11 条。
- If Type(x) is Object and Type(y) is either String, Number, BigInt, or Symbol, return the result of the comparison ? ToPrimitive(x) == y.
这个 ToPrimitive 方法将一个 Object 转换为一个原始类型。这个方法就只是加个 hint 参数再调用 OrdinaryToPrimitive(input, hint) 方法,至于 hint 是啥,下面的注释解释的很清楚了:一般是 Number,但是你可以覆盖。规范里面只有 Date 和 Symbol 覆盖了默认值,Date 的是 String。
然后接着看 OrdinaryToPrimitive 方法,这个方法调用对象的 toString 和 valueOf,找到不是对象的原始类型。至于顺序嘛,就是取决于我们传的 hint 了。这儿 [] 会调用 toString (因为 valueOf 依然返回对象类型) 返回空字符串 ''。 然后接着比较 '' == 0 结果为 true。
3. 隐式类型转换
'5' - '2' 和 5 + '2'
表达式相关的规范在第 12 章,加减操作的定义在 12.8 节 可以看到只有一条,二元运算都是调用 EvaluateStringOrNumericBinaryExpression 这个函数只是传入的操作符参数不同。
- Return ? EvaluateStringOrNumericBinaryExpression(AdditiveExpression, -, MultiplicativeExpression).
接着查看这个函数的规范定义 Runtime Semantics: EvaluateStringOrNumericBinaryExpression ( leftOperand, opText, rightOperand )
- Let lref be the result of evaluating leftOperand.
- Let lval be ? GetValue(lref).
- Let rref be the result of evaluating rightOperand.
- Let rval be ? GetValue(rref).
- Return ? ApplyStringOrNumericBinaryOperator(lval, opText, rval).
同样的是解释执行左右两侧,并且对齐进行求值,然后调用 ApplyStringOrNumericBinaryOperator,接着查看 ApplyStringOrNumericBinaryOperator 的定义:
12.15.5 Runtime Semantics: ApplyStringOrNumericBinaryOperator

在左侧或右侧存在字符串时,返回字符串拼接结果。
4. this
如果觉得上面的太简答,可以尝试下读下 this 部分,定义在 this keyword 这个只是调用了 ResolveThisBinding()。然后查看 8.3.4 ResolveThisBinding ( ):
- Let envRec be GetThisEnvironment().
- Return ? envRec.GetThisBinding().
再看 8.3.3 GetThisEnvironment() 的定义:
- Let env be the running execution context's LexicalEnvironment.
- Repeat,
a. Let exists be env.HasThisBinding(). b. If exists is true, return env.
c. Let outer be env.[[OuterEnv]].
d. Assert: outer is not null.
e. Set env to outer.
整个过程就是检查执行环境有没有 this 绑定,没有就去外部查,直到找到一个。我们知道箭头函数所创建的执行环境是没有 this 绑定的。至于什么时候会创建一个新的执行环境,或者在函数调用的时候又做了哪些事情,我们可以查看函数调用章节 12.3.6 Function calls,这一段内容有点多。建议先只考虑最简单的情况阅读,比如 user.dispalyName() 这种最简单的情况。这种情况对应着 12.3.6.1 RS of function calls 比较简单情况:
- Let ref be the result of evaluating CallExpression.
- Let func be ? GetValue(ref).
- Let thisCall be this CallExpression.
- Let tailCall be IsInTailPosition(thisCall).
- Return ? EvaluateCall(func, ref, Arguments, tailCall).
第一步解释执行左侧表达式,这儿得到一个引用类型,还记得解析 user.displayName 的解析结果吧。
第二步将引用的值赋值给 func,这儿不考虑尾递归的,接下来就需要看 EvaluateCall 的定义了。

这儿先检查是不是一个引用,有时左侧表达式得到的不是引用,比如之前常见的一些写法 (0, XX.fn)() 逗号表达式会返回引用的值。不管怎么样,第 a 条 ref 基值是一个对象,所以执行步骤 i。
这儿的情况 GetThisValue(ref) 就是 GetBase(ref),也就是 user。
函数调用还是蛮多逻辑的,这儿就不细说了。最后可以在 9.2.1 [[Call]] 看到创建新的执行环境,绑定 this 等操作。
其它
变量绑定过程;Arguments 在严格模式和非严格模式下的区别;let const
总结
总的来说,规范就相当于伪代码的注释一样,其实还是比较容易理解的,把这个手册利用起来并不难。这篇文章主要想根据实际场景来带大家熟悉熟悉这本手册,大家完全可以只读自己感兴趣的部分。 这儿有一篇旧题可以用来测试一下,JavaScript Puzzlers!