第 3 章

如何读 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:查 == 的隐式转换规则

搜索关键词:IsLooselyEqualAbstract 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, and throw) 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

这解释了为什么 returnbreakcontinuethrow 能"逃离"当前执行流:它们都产生非 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)         数字相加

本章小结

  1. 规范不需要全读,关键技能是用 Ctrl+F 搜索操作名(如 typeofIsLooselyEqualToPrimitive),找到对应章节,照算法步骤推导。遇到奇怪行为,规范是唯一可信的信息源。

  2. ?! 是 Completion Record 的简写? 表示该操作可能抛出错误,错误会自动向上传播;! 表示该操作绝对不会抛出(否则是规范 bug)。这两个符号在规范中无处不在,理解它们后规范算法的可读性大幅提升。

  3. 内部槽 [[Slot]] 是对象的私有内部属性,JavaScript 代码无法直接访问,但通过 ReflectProxyObject.getOwnPropertyDescriptor 等 API 可以间接观察部分内部操作。

  4. 规范类型(Completion Record、Reference Record 等)不是 JavaScript 值,它们是规范用来描述算法的工具。理解 Reference Record 能解释为什么 foo.bar = 1 可以正确赋值而不只是读取 bar 的值。

  5. [] + {}{} + [] 结果不同的根本原因:前者是两个操作数都经过 ToPrimitive 转为字符串后拼接;后者在作为语句时,{} 被解析为空代码块而非对象字面量,导致 +[] 变成一元运算,结果为 0

本章评分
4.8  / 5  (89 评分)

💬 留言讨论