如何读 ECMAScript 规范:符号、抽象操作、算法步骤
你不需要读完 ECMAScript 规范——全文超过900页,没有人能全记住。你只需要知道当某个 JavaScript 行为让你困惑时,怎么用10分钟从规范里找到精确答案。这个技能会在你遇到 [] + {} 和 {} + [] 结果不同时救你。
🔹 Level 1 · 你需要知道的
3步找到任何 JS 行为的规范解释
步骤 1:打开 tc39.es/ecma262(最新版)或 ecma-international.org/publications-and-standards/standards/ecma-262/
步骤 2:用 Ctrl+F 搜索操作名称
步骤 3:照着算法步骤逐行推导
实际查询示例 1:查 typeof 的行为
搜索关键词:typeof
规范第13.5.3节(The typeof Operator)给出:
规范伪代码:
1. Let val be the result of evaluating UnaryExpression.
2. If val is a Reference Record, then
a. If IsUnresolvableReference(val) is true, return "undefined".
b. Set val to ? GetValue(val).
3. If val is undefined, return "undefined".
4. If val is null, return "object". ← 这就是 typeof null === 'object' 的规范依据
5. If val is a Boolean, return "boolean".
6. If val is a Number, return "number".
7. If val is a String, return "string".
8. If val is a Symbol, return "symbol".
9. If val is a BigInt, return "bigint".
10. val must be an Object.
a. If val has a [[Call]] internal method, return "function".
b. Return "object".
不需要理解所有细节,你只需要看到第4步就能确认:规范明确规定 null 返回 "object"。
实际查询示例 2:查 == 的隐式转换规则
搜索关键词:IsLooselyEqual 或 Abstract Equality Comparison
规范第7.2.14节给出12条规则,直接查某一条即可:
// 为什么 null == undefined 是 true?
// 规范第7.2.14节第3条:
// "If x is null and y is undefined, return true."
// 无需任何转换,规范直接特殊处理了这个组合
console.log(null == undefined); // true
console.log(null === undefined); // false
console.log(null == 0); // false(不遵循一般转换规则)
console.log(null == ''); // false(不遵循一般转换规则)
// null 只等于 null 和 undefined,这是规范写死的
实际查询示例 3:查 Array.prototype.push 的行为
搜索关键词:Array.prototype.push
规范第23.1.3.20节:
Array.prototype.push ( ...items )
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. Let argCount be the number of elements in items.
4. If len + argCount > 2^53 - 1, throw a TypeError exception.
5. For each element E of items, do
a. Perform ? Set(O, ! ToString(F(len)), E, true). ← 转换索引为字符串
b. Set len to len + 1.
6. Perform ? Set(O, "length", F(len), true). ← 更新 length 属性
7. Return F(len).
这解释了一个奇怪行为:push 为什么可以用在非数组的对象上?
// 任何有 length 属性的对象都能用 push
const obj = { length: 0 };
Array.prototype.push.call(obj, 'a', 'b');
console.log(obj); // { '0': 'a', '1': 'b', length: 2 }
// 规范里 push 操作的是 ToObject(this),不要求是 Array
🔸 Level 2 · 它是怎么运行的
规范的整体结构
ECMAScript 规范(2024年版,ECMA-262,第15版)分为以下主要部分:
ECMA-262 结构图:
┌──────────────────────────────────────────────────────────────┐
│ 第1-5章 引言、一致性要求、术语与约定 │
│ 第6章 符号约定(Notational Conventions)← 最重要 │
│ 第7章 抽象操作(Abstract Operations) │
│ 第8章 语法导向操作(Syntax-Directed Operations) │
│ 第9章 可执行代码与执行上下文 │
│ 第10-12章 词法、表达式、语句 │
│ 第13-14章 函数、类 │
│ 第15章 脚本与模块 │
│ 第16章 错误处理与语言扩展 │
│ 第19-28章 全局对象、基础类型、集合、反射等内置对象 │
│ Annex A 语法摘要 │
│ Annex B Web 兼容性遗留特性 │
│ Annex C 严格模式差异 │
└──────────────────────────────────────────────────────────────┘
抽象操作:规范的"内部函数"
抽象操作(Abstract Operations) 是规范内部使用的函数,不是 JavaScript 代码能直接调用的,但它们描述了引擎必须执行的操作。
最常见的抽象操作:
┌──────────────────────────────────────────────────────────────┐
│ 操作名称 │ 用途 │
├─────────────────────┼────────────────────────────────────────┤
│ ToPrimitive(input) │ 将对象转为基础类型(影响 == 和 +) │
│ ToNumber(argument) │ 转换为数字(影响算术运算) │
│ ToString(argument) │ 转换为字符串(影响字符串拼接) │
│ ToBoolean(argument)│ 转换为布尔(影响 if 判断) │
│ ToObject(argument) │ 转换为对象(影响 this 值处理) │
│ GetValue(V) │ 从 Reference 中取出实际值 │
│ PutValue(V, W) │ 给 Reference 赋值 │
│ IsCallable(arg) │ 检查是否可调用(有 [[Call]] 内部方法) │
│ SameValueZero(x,y) │ 比较算法(被 includes/Map/Set 使用) │
└──────────────────────┴────────────────────────────────────────┘
ToPrimitive 的完整逻辑:
// ToPrimitive(input, hint) 的 hint 可以是 "number"、"string" 或 "default"
// hint = "number" 时(如算术运算):先调用 valueOf(),再调用 toString()
// hint = "string" 时(如模板字符串):先调用 toString(),再调用 valueOf()
// hint = "default" 时(如 + 运算符):与 "number" 相同(对 Date 对象例外)
const obj = {
valueOf() { return 42; },
toString() { return 'hello'; }
};
console.log(obj + 1); // 43 — hint=default → valueOf() 先调用 → 42+1
console.log(`${obj}`); // 'hello' — hint=string → toString() 先调用
console.log(obj > 10); // true — 比较运算 hint=number → valueOf() → 42 > 10
算法步骤的符号系统
规范算法步骤使用一套固定的伪代码语法:
规范符号说明:
┌──────────────────────────────────────────────────────────────┐
│ 符号/词汇 │ 含义 │
├─────────────────────────────┼──────────────────────────────── │
│ Let x be ... │ 声明变量 x,赋值 │
│ Set x to ... │ 修改已有变量 x 的值 │
│ Return ... │ 函数返回 │
│ Throw a TypeError │ 抛出类型错误 │
│ Assert: ... │ 不变式断言(必定为真) │
│ If ... is true, then ... │ 条件分支 │
│ Else if ... is ... │ else if 分支 │
│ For each element E of ... │ 迭代 │
│ NOTE: ... │ 非正式说明(不影响语义) │
│ ? Operation(args) │ 可能抛出错误,如果抛出则传播 │
│ ! Operation(args) │ 不可能抛出错误(断言) │
│ [[Slot]] │ 内部槽(对象的私有内部属性) │
│ F(n) │ 将整数 n 转为规范的 Number 类型 │
└─────────────────────────────┴──────────────────────────────── │
? 和 ! 前缀的精确含义
? 和 ! 是 Completion Record 的简写:
? Operation(args) 等价于:
Let result be Operation(args).
If result is an abrupt completion (error), return result.
Set result to result.[[Value]].
! Operation(args) 等价于:
Let result be Operation(args).
Assert: result is not an abrupt completion.
Set result to result.[[Value]].
简单说:
? → 这个操作可能失败,失败时把错误向上传播(类似 try/catch)
! → 这个操作一定成功(否则是规范本身的 bug)
实际例子:
// 规范中 Array.prototype.push 的部分步骤:
// 4. If len + argCount > 2^53 - 1, throw a TypeError exception.
// 5a. Perform ? Set(O, ! ToString(F(len)), E, true).
// ↑ ↑
// ?→ Set 可能失败 !→ ToString(数字) 不可能失败
// 现实中对应的错误场景:
const arr = new Array(2 ** 53 - 1); // 规格 2^53-1 = 9007199254740991
arr.push(1); // TypeError: Invalid array length
内部槽 [[Slot]]:对象的私有内部属性
双方括号表示内部槽,这些是 JavaScript 代码无法直接访问的内部属性:
常见内部槽:
┌──────────────────────────────────────────────────────────────┐
│ 内部槽 │ 类型 │ 描述 │
├──────────────────────┼───────────────┼───────────────────────│
│ [[Prototype]] │ Object/null │ 原型链指针 │
│ [[Extensible]] │ Boolean │ 是否可添加新属性 │
│ [[Value]] │ any │ 基础值(用于包装对象) │
│ [[Writable]] │ Boolean │ 属性是否可写 │
│ [[Call]] │ 内部方法 │ 函数调用 │
│ [[Construct]] │ 内部方法 │ new 调用 │
│ [[BoundThis]] │ any │ bind 绑定的 this │
│ [[PromiseState]] │ String │ Promise 状态 │
│ [[PromiseResult]] │ any │ Promise 结果值 │
└──────────────────────┴───────────────┴───────────────────────│
// 内部槽无法从 JS 代码直接访问
const promise = Promise.resolve(42);
// promise.[[PromiseState]] → 无法访问
// 但可以通过行为观察:
promise.then(v => console.log(v)); // 42
// Reflect API 暴露了部分内部操作(但不是槽本身)
const obj = { x: 1 };
Reflect.get(obj, 'x'); // 1 — 对应规范的 [[Get]] 内部方法调用
规范类型 vs 语言类型
JavaScript 有8种语言类型(开发者看到的):Undefined、Null、Boolean、Number、BigInt、String、Symbol、Object。
规范还定义了规范类型(Spec Types),这些类型只在规范内部使用,不是 JavaScript 值:
规范类型(不是 JS 值!):
┌──────────────────────────────────────────────────────────────┐
│ Completion Record │ 包含:type(normal/throw/return/break/continue) │
│ │ value(携带的值) │
│ │ target(for break/continue with label) │
│ │
│ Reference Record │ 包含:base(基础值) │
│ │ referencedName(属性名/变量名) │
│ │ strict(是否严格模式) │
│ │ 用途:解释 foo.bar 为什么能赋值 │
│ │
│ Property Descriptor │ 包含:[[Value]]、[[Writable]]、 │
│ │ [[Get]]、[[Set]]、 │
│ │ [[Enumerable]]、[[Configurable]] │
│ │
│ List │ 有序序列(不是 JS Array) │
│ Record │ 结构体(不是 JS Object) │
└──────────────────────────────────────────────────────────────┘
🔺 Level 3 · 规范怎么定义的
第6章 Notational Conventions 原文解读
规范第6章明确定义了算法的书写规范,关键段落:
6.1 Syntactic and Lexical Grammars The productions of the grammars in this specification are written using a modified BNF (Backus-Naur Form). Each production has an abstract symbol, called a nonterminal, as its left-hand side...
6.2 Algorithm Conventions The algorithms in this specification use the following conventions:
- Algorithm steps are numbered. If two steps are at the same indentation level, they are sequential.
- Steps may have sub-steps, indicated by indentation.
- A step that says "perform" or "let" specifies an action.
- A step that says "if" specifies a conditional action.
- The phrase "such that" means "with the property that."
- The phrase "unless" in a step means "if the negation of the condition is true."
关于 Completion Record,规范第6.2.4节:
6.2.4 The Completion Record Specification Type The Completion specification type is used to explain the runtime propagation of values and control flow such as the behaviour of statements (
break,continue,return, andthrow) that perform nonlocal transfers of control.Values of the Completion type are Records with the fields defined in Table 9:
Field Name Value Meaning [[Type]] normal, break, continue, return, or throw The type of completion [[Value]] any ECMAScript language value or empty The value that was produced [[Target]] a String or empty The target label for directed control transfers
这解释了为什么 return、break、continue、throw 能"逃离"当前执行流:它们都产生非 normal 类型的 Completion Record,规范算法中的 ? 前缀会检测这种情况并向上传播。
规范第12章:Source Text(源代码)
12.1 Source Text ECMAScript code is expressed using Unicode code points. ECMAScript source text is a sequence of code points. All Unicode code point values from U+0000 to U+10FFFF, including surrogate code points, may occur in ECMAScript source text where permitted by the ECMAScript grammars.
这是规范声明 JavaScript 源代码是 Unicode 的地方,这也解释了为什么以下代码是合法的:
// 这些变量名是合法的 JavaScript(因为规范允许任意 Unicode)
const π = 3.14159;
const δ = 0.001;
const 変数 = 'hello'; // 日文变量名
const café = true; // 带重音符的 ASCII
💎 Level 4 · 边界与陷阱
陷阱 1:规范中的 NOTE 和 LEGACY 标记
规范标记的含义:
┌──────────────────────────────────────────────────────────────┐
│ 标记 │ 含义 │
├────────────────┼─────────────────────────────────────────────│
│ NOTE │ 非规范性注释,帮助理解但不改变语义 │
│ LEGACY │ 遗留特性,仍在规范中但不推荐使用 │
│ OPTIONAL │ 可选实现,可以不实现 │
│ deprecated │ 已废弃,未来可能移除(在规范中很少见) │
│ Annex B │ Web 兼容性特性,非浏览器环境不需要实现 │
└──────────────────┴─────────────────────────────────────────── │
实际例子:在规范第13.5.3节(typeof),你会看到:
"NOTE: The result of applying the typeof operator to an Object that implements the exotic [[Call]] internal method is the String "function"..."
这个 NOTE 解释了为什么 typeof class {} 返回 "function"(class 也有 [[Call]] 的等效实现),但这不是主要算法步骤,只是辅助说明。
陷阱 2:规范不等于实现
规范定义可观察的语义,但不规定具体实现方式:
规范 vs 实现的自由度:
┌──────────────────────────────────────────────────────────────┐
│ 规范规定了什么? │
│ - 哪些操作必须抛出什么错误 │
│ - 运算符的结果是什么 │
│ - 原型链的查找顺序 │
│ │
│ 规范没有规定什么? │
│ - 如何在内存中表示对象(V8用Hidden Class,其他引擎可以不同) │
│ - 什么时候进行垃圾回收 │
│ - JIT 编译的时机和策略 │
│ - 属性的内部存储顺序(只规定枚举顺序) │
└──────────────────────────────────────────────────────────────┘
// 规范规定了枚举顺序(ES2015+):
// 1. 整数索引(升序)
// 2. 字符串键(插入顺序)
// 3. Symbol 键(插入顺序)
const obj = { b: 1, a: 2, 2: 3, 1: 4 };
console.log(Object.keys(obj)); // ['1', '2', 'b', 'a']
// 整数索引先按数字排序,然后是字符串键按插入顺序
// 但V8引擎的内部存储可以和枚举顺序完全不同——这是实现细节
陷阱 3:用规范推导 [] + {} 和 {} + [] 的不同结果
这是一个令人困惑的经典案例,完整推导如下:
案例 A:[] + {}
表达式:[] + {}
运算符:+(加号)
规范步骤(第13.15.3节 ApplyStringOrNumericBinaryOperator):
1. Let lprim be ? ToPrimitive([], hint=default)
2. Let rprim be ? ToPrimitive({}, hint=default)
展开步骤1:ToPrimitive([], hint=default)
→ 数组有 [Symbol.toPrimitive]? 没有
→ hint=default,等价于 hint=number
→ 先调用 valueOf():[].valueOf() = [] (还是对象,无效)
→ 再调用 toString():[].toString() = '' ← 空字符串!
展开步骤2:ToPrimitive({}, hint=default)
→ 对象没有 [Symbol.toPrimitive]
→ hint=default,等价于 hint=number
→ 先调用 valueOf():{}.valueOf() = {} (还是对象,无效)
→ 再调用 toString():{}.toString() = '[object Object]'
继续主算法:
3. lprim = ''(字符串),rprim = '[object Object]'(字符串)
4. If Type(lprim) is String OR Type(rprim) is String:
→ 两个都是字符串,执行字符串拼接
5. Return ToString(lprim) + ToString(rprim)
= '' + '[object Object]'
= '[object Object]'
console.log([] + {}); // '[object Object]'
案例 B:{} + []
表达式:{} + []
但是!这取决于解析上下文:
情况1:作为语句(在控制台独占一行输入):
{} 被解析为空代码块(Block Statement),不是对象字面量
+[] 是一元加号运算符应用于 []
→ +[] = ToNumber([]) = ToNumber('') = 0
情况2:作为表达式(被括号包围或在赋值右侧):
({} + []) — {} 被解析为对象字面量
→ ToPrimitive({}) = '[object Object]'
→ ToPrimitive([]) = ''
→ '[object Object]' + '' = '[object Object]'
// 验证两种结果:
console.log({} + []); // 0(作为语句,{} 是代码块)
console.log(({} + [])); // '[object Object]'(强制作为表达式)
const x = {} + [];
console.log(x); // '[object Object]'(赋值右侧是表达式)
ASCII 图总结:
+ 运算符的决策树:
a + b
│
ToPrimitive(a) 和 ToPrimitive(b)
│
┌──────────────┴──────────────┐
任意一个是 String? 否
│ │
字符串拼接 ToNumber(两者)
ToString(a) + ToString(b) 数字相加
本章小结
-
规范不需要全读,关键技能是用
Ctrl+F搜索操作名(如typeof、IsLooselyEqual、ToPrimitive),找到对应章节,照算法步骤推导。遇到奇怪行为,规范是唯一可信的信息源。 -
?和!是 Completion Record 的简写:?表示该操作可能抛出错误,错误会自动向上传播;!表示该操作绝对不会抛出(否则是规范 bug)。这两个符号在规范中无处不在,理解它们后规范算法的可读性大幅提升。 -
内部槽
[[Slot]]是对象的私有内部属性,JavaScript 代码无法直接访问,但通过Reflect、Proxy、Object.getOwnPropertyDescriptor等 API 可以间接观察部分内部操作。 -
规范类型(Completion Record、Reference Record 等)不是 JavaScript 值,它们是规范用来描述算法的工具。理解 Reference Record 能解释为什么
foo.bar = 1可以正确赋值而不只是读取bar的值。 -
[] + {}和{} + []结果不同的根本原因:前者是两个操作数都经过ToPrimitive转为字符串后拼接;后者在作为语句时,{}被解析为空代码块而非对象字面量,导致+[]变成一元运算,结果为0。