How to Read the ECMAScript Specification(2020)

本篇原文作于 2020 年,作者一直没有更新,但是文中介绍的方法值得学习。如果有和最新规范不一致的地方,以最新规范为准。

How to Read the ECMAScript Specification

1. 前言

注意:在本文档中,将仅使用术语“ECMAScript”来指代规范本身,并在其他任何地方使用“JavaScript”。然而,这两个术语指的是同一件事。

为什么要阅读 ECMAScript 规范

ECMAScript 规范是 JavaScript 实现的权威资源,不论是在浏览器、通过 Node.js 的服务器、还是物联网终端。JavaScript 引擎的所有开发人员都依赖于规范来确保他们闪亮的新特性按照预期的方式工作,就像其他 JavaScript 引擎一样。

作者认为,了解 ECMAScript 规范不仅对 JavaScript 引擎开发者有帮助,对于仅使用 JavaScript 进行编码的人,了解 ECMAScript 规范也是非常有用的。

举例:

> Array.prototype.push(42)
1
> Array.prototype
[ 42 ]
> Array.isArray(Array.prototype)
true
> Set.prototypeadd(42)
Uncaught TypeError: add method called on incompatible Set.prototype
> Set.prototype
Set {}

为什么 Array 的 push 方法可以作用到它的原型上,Set 的 add 方法就不行?Google 有时在你最需要的时候帮不了你,甚至是 Stack Overflow。

读规范可以帮你。

或者你想知道松散相等运算符的详细用法,MDN 帮不了你太多。

读规范可以帮你。

不推荐刚学习 JavaScript 的开发者读规范。

什么属于 ECMAScript 规范,什么不属于

True/False
Syntax of syntactic elements (i.e., what a valid for .. in loop looks like) T
Semantics of syntactic elements (i.e., what typeof null, or { a: b } returns) T
import a from 'a'; F
Object, Array, Function, Number, Math, RegExp, Proxy, Map, Promise, ArrayBuffer, Uint8Array, globalThis, … T
console, setTimeout(), setInterval(), clearTimeout(), clearInterval() F
Buffer, process, global* F
module, exports, require(), __dirname, __filename F
window, alert(), confirm(), the DOM (document, HTMLElement, addEventListener(), Worker, …) F
  • import a from 'a'; ECMAScript 规范指定了如此声明的语法,以及它们的意思,却没有指定模块是如何加载的。
  • console, setTimeout(), setInterval(), clearTimeout(), clearInterval() 这些内容存在于浏览器和 Node.js 端,但不是规范中的。
  • Buffer, process, global* 仅存在于 Node.js 中,在全局上下文起作用。与 global 不同, globalThis 是 ECMAScript 的一部分,在浏览器中也有实现。
  • module, exports, require(), __dirname, __filename 仅存在于 Node.js 的全局上下文。
  • window, alert(), confirm(), the DOM (document, HTMLElement, addEventListener(), Worker, …) 仅存在于浏览器。

ECMAScript 在哪里

最新更新:tc39.es/ecma262

导航规范

不要从头到尾读规范,这样做费时间低效率。要有针对性地阅读规范,比如,你今天对字符串感兴趣,就专门找字符串相关的章节阅读,这样一个主题一个主题地读,不至于半途而废。

2. 运行时语义(Runtime semantics)

语言和 API 的运行时语义是规范中最重要的部分,通常也是人们最关心的部分。

算法步骤

  1. Let a be 1.
  2. Let b be a+a.
  3. If b is 2, then

    1. Hooray! Arithmetics isn’t broken.
  4. Else

    1. Boo!

进一步阅读:5.2 Algorithm Conventions

抽象操作

有时会在规范中看到一些像函数的调用。Boolean() 函数的第一步是:

例子:

当 Boolean 带着参数值调用时,会采用以下步骤:

  1. Let b ! ToBoolean(value).

“ToBoolean”函数被称为抽象操作:说它抽象是因为,它并不是真的作为一个函数供 JavaScript 调用。这是一个标记,规范作者发明的避免重复写相同内容的方法。

[[This]] 是什么

[[Notation]] 是一个标记,在不同语境下有不同的含义。以下是三个主要意义:

一、记录的字段

ECMAScript 规范使用 Record 指代键值图,具有固定键的集合——有点像 C 语言中的结构体。Record 中的每个键值对被叫做 field。因为 Records 只出现在规范中,实际 JavaScript 代码并不存在。那使用 [[Notation]] 指代 Record 的 fields 就说得通了。

例子:

很明显,属性描述符也由带有 fields [[Value]] , [[Writable]] , [[Get]] , [[Set]] , [[Enumerable]] , 和 [[Configurable]] 的 Records 建模。isDataDescriptor 抽象操作经常使用这种标记。

当属性描述符 Desc 调用抽象操作 isDataDescriptor 时,会进行以下步骤:

  1. Desc undefined,返回 false
  2. Desc.[[Value]]Desc.[[Writable]] 未设定,返回 false
  3. 返回 true

进一步阅读:The List and Record Specification Types

二、JavaScript 对象的内部槽

例子:

大多数 JavaScript 对象有内部槽 [[Prototype]] 用来指代它们继承的对象,内部槽的值通常是 Object.getPrototypeOf() 返回值。在 OrdinaryGetPrototypeOf 抽象操作中,内部槽的值可以这样得到:

对象 O 调用抽象操作 OrdinaryGetPrototypeOf,进行以下步骤:

  1. 返回 O.[[Prototype]] .

注意: Object 和 Record 字段的内部槽在外观上是相同的,但是可以通过查看这种表示法的前例(点之前的部分)来消除它们的歧义,无论它是 Object 还是 Record。从上下语境来看,这一点通常相当明显。

三、JavaScript 对象的内部方法

这些内部方法无法在 JavaScript 中直接访问到。

例子:

所有 JavaScript 函数都有内部方法 [[Call]] ,用来执行该函数。Call 抽象操作的执行步骤:

  1. 返回 ? F.[[Call]](V, argumentsList)

完成记录; ?!

ECMAScript 规范中的每个运行时语义都显式或隐式地返回一个报告其结果的完成记录。这个完成记录是一个包含三个可能字段的记录:

  • a [[Type]] ( normal , return , throw , breakcontinue )
  • 如果 [[Type]] 是 nomral, return, throw,还会有 [[Value]] (看看返回或者抛出什么)
  • 如果 [[Type]] 是 break, continue,那么它可以有选择地携带一个 [[Target]] 标签,脚本执行从这里开始

[[Type]] 是 normal 的完成记录是 normal completion,其他的情况称为 abrupt completion。

很多时候,只需要处理 [[Type]] 为 throw 的 abrupt completion。其他三种 abrupt completion 类型只有在查看如何计算特定语法元素时才有用。实际上,在内置函数的定义中,你永远不会看到任何其他类型,因为 break , continue , return 不能跨函数边界工作。

进一步阅读:The Completion Record Specification Type

由于完成记录的定义,JavaScript 中的细节就像冒泡错误,在 try-catch 块出现在规范以前,是不存在的。实际上,错误(或更确切地,abrupt completions)是显式处理的。

如果没有任何简写,对抽象操作的普通调用(可能返回计算结果,也可能抛出错误)的规范文本如下:

例子:

一些步骤可以调用一个抽象操作,它可以不使用任何速记方法而抛出:

  1. Let resultCompletionRecord be AbstractOp().
  2. If resultCompletionRecord is an abrupt completion, return resultCompletionRecord. 注意:如果是一个 abrupt completion,resultCompletionRecord 将直接返回。换句话说,转发 AbstractOp 中抛出的错误,并中止其余步骤。
  3. Let result be resultCompletionRecord.[[Value]] . 注意: 在确保获得 normal completion 之后,现在可以展开 Completion Record 以获得所需计算的实际结果。
  4. result is the result we need. We can now do more things with it.

但是为了减少这些繁琐的步骤,ECMAScript 规范的编辑器添加了一些简短的代码。自 ES2016 以来,相同的规范文本可以用以下两种等效的方式编写:

例子:

调用一个抽象操作的几个步骤可能会与 ReturnIfAbrupt 一起抛出:

  1. Let result be AbstractOp().
  2. ReturnIfAbrupt(result). 注意:returnIfAbrupt 通过转发处理任何可能的 abrupt completions,并自动将结果打开到它的 [[Value]]
  3. result is the result we need. We can now do more things with it.

或者,采用更简洁的方式,用一个特殊的问号(?)标记:

例子:

调用抽象操作的几个步骤可能会抛出一个问号(?):

  1. Let result be ? AbstractOp(). 注意,在这个表示法中,我们根本不处理完成记录。? 标记会处理一切,结果可以立即使用。
  2. result is the result we need. We can now do more things with it.

有时,如果规范中使用了 ! 符号,就表明:针对 AbstractOp 的特别调用不会返回一个 abrupt completion。

例子:

  1. Let result be ! AbstractOp(). 主题:虽然 ? 转发了我们可能得到的任何错误,但是 ! 断言我们从未从这个调用中得到任何 abrupt completions,如果我们得到了,那将是规范中的一个错误。与 ? 的情况一样,我们根本就不处理 completion records。
  2. result is the result we need. We can now do more things with it.

小心:

诚然,如果它看起来像一个有效的 JavaScript 表达式,那么 ! 可能会变得相当令人困惑:

  1. Let b be ! ToBoolean(value). 节选自 Boolean()。

这里 ! 只是意味着我们确信这个 ToBoolean 调用永远不会返回异常,而不是结果是反的!

进一步阅读:ReturnIfAbrupt Shorthands

JavaScript 对象

规范中,每个对象都有一些内定方法,以使规范的其他部分能够调用。这些内部方法有:

  • [[Get]] ,能获得对象属性(像 obj.prop
  • [[Set]] ,能设置对象属性(像 obj.prop = 42
  • [[GetPrototypeOf]] ,能得到对象的原型(像 Object.getPrototypeOf(obj)
  • [[GetOwnProperty]] ,能得到对象自有属性的属性描述符(像 Object.getOwnPropertyDescriptor(obj, "prop")
  • [[Delete]] ,能删除对象属性(像 delete obj.prop

详细列表在:Object Internal Methods and Internal Slots

基于这些定义,函数对象(或只叫函数),只是对象附带上 [[Call]] 内部方法,可能也附带有 [[Construct]] 内部方法;因此,这些函数也被称为可调用对象(callable objects)。

之后,规范将对象分成两类:普通的和外来的。大多数对象是普通对象,这意味着它们的内部方法是默认的那些。

但是,ECMAScript 规范还定义了几种外来对象,它们可能会覆盖那些内部方法的默认实现。对于外来对象允许做什么,有一些最小的限制,但是一般来说,被覆盖的内部方法可以做很多更改而不违反规范。

例子:

Array 就是外来对象。 Array 对象的 length 属性周围的一些特殊语义无法使用普通对象可用的工具来实现。

其中之一是,设置 Array 对象的 length 属性可以从对象中删除属性,但 length 属性似乎只是一个普通的数据属性。与此相反, new Map().size 只是在 Map.prototype 上定义的 getter 函数,并没有 length 属性。

const arr = [0, 1, 2, 3]
arr.length = 1
console.log(arr)
console.log(Object.getOwnPropertyDescriptor([], "length"))
console.log(Object.getOwnPropertyDescriptor(new Map(), "size"))
console.log(Object.getOwnPropertyDescriptor(Map.prototype, "size"))
[0]
{ value: 0, writable: true, enumerable: false, configurable: false }
undefined
{ get: size(), set: undefined, enumerable: false, configurable: true }

这一行为通过覆盖 [[DefineOwnProperty]] 内部属性实现,进一步阅读:Array Exotic Objects

ECMAScript 规范还允许其他规范定义自己的外来对象。正是通过这种机制,浏览器能指定对跨源 API 访问的限制(参见 WindowProxy)。JavaScript 程序员也可以通过 Proxy API 创建自己的外来对象。

JavaScript 对象还可以具有定义为包含某些类型的值的内部槽。我倾向于认为内部插槽是 Symbol 命名的属性,甚至对 Object.getOwnPropertySymbols() 也是隐藏的。普通对象和外来对象都被允许有内部插槽。

两个内部槽( [[Prototype]][[GetPrototypeOf]] )的区别:

虽然大多数对象都有 [[Prototype]] 内部槽,但所有对象都实现了 [[GetPrototypeOf]] 内部方法。值得注意的是,Proxy 对象没有自己的 [[Prototype]] ,它的 [[GetPrototypeOf]] 内部方法服从于注册的处理程序或存储在 Proxy 对象的 [[ProxyTarget]] 内部槽中的目标原型。

因此,在处理 Object 时,引用适当的内部方法而不是直接查看内部插槽的值几乎总是一个好主意。

考虑对象、内部方法和内部槽之间关系的另一种方式是通过经典的面向对象透镜。“Object”就像一个接口,指定了几个必须实现的内部方法。普通对象提供默认实现,外来对象可以部分或全部重写这些实现。另一方面,内部槽就像对象的实例变量——对象的实现细节。

所有这些关系,都由下面的 UML 图进行总结:

例子: String.prototype.substring()

现在有一个问题:

不执行代码,下面的代码片段返回什么?the given code fragment throws a TypeError exception

String.prototype.substring.call(undefined, 2, 4)

有两种可能结果:

  1. 以上调用,会把 undefined 转成字符串 "undefined" ,然后返回字符串的第三和第四个字符。
  2. 从其他方面看,以上用法会报错,拒绝将 undefined 作为输入。

在规范左上方的搜索栏搜索“substring”,找到 String.prototype.substring(start, end)。先想想已知什么:知道 substring 是如何工作的(返回输入字符串的一部分)。不知道的是:当 this 值为 undefined 时,结果如何。所以,在接下来的算法步骤阅读过程中,会专注于思考有关 this 值的部分。

  1. Let O be ? RequireObjectCoercible(this value).

? 标记表明,RequireObjectCoercible 抽象操作可能报错。如果它抛出一个错误,这将与我们上面的第二个假设相对应。

当 RequireObjectCoercible 参数为 undefined 时,结果是抛出错误。

该规范只指定抛出错误的类型,而不指定它包含的消息。这意味着实现可以有不同的错误消息,甚至是本地化的错误消息。

例子: Boolean()String() 可以抛出例外吗

在编写关键任务代码时,必须将异常处理放在编程的最前面。因此,可能经常思考一个问题:“某些内置函数会抛出异常吗?”。

这个例子,会通过两个内置函数 Boolean()String() ,回答上面这个问题。

只研究对这些函数的直接调用,而非构造器形态。这很容易成为 JavaScript 中最不受欢迎的特性之一,而且几乎所有的 JS 样式指南都非常不鼓励这种做法。

看看规范中 Boolean() 的算法步骤:

This function performs the following steps when called:

  1. Let b be ToBoolean(value).
  2. If NewTarget is undefined, return b.
  3. Let O be ? OrdinaryCreateFromConstructor(NewTarget, "%Boolean.prototype%", « [[BooleanData]] »).
  4. Set O.[[BooleanData]] to b.
  5. Return O.

但另一方面,它并不完全简单明了,因为它涉及到一些围绕 OrdinaryCreateFromConstructor 的复杂技巧。更重要的是,还有一个 ? 简记在步骤 3 中可能表示此函数在某些情况下可能引发错误。

步骤 1 将 value(函数参数)转换为布尔值。有趣的是没有 ? 或者 ! 在此步骤中,但通常没有完成记录简写的意思与存在 ! 相同。因此步骤 1 不能引发异常。

第 2 步检查 NewTarget 是否是 undefined 。NewTarget 是规范中 new.target 元属性的等价物,它是在 ES2015 中首次添加的,允许规范区分 new Boolean() 调用(它是 Boolean)和 Boolean() 调用(它是 undefined)。因为我们现在只关注对 Boolean() 的直接调用,我们知道 NewTarget 总是未定义的,而且算法总是直接返回 b,不需要任何额外处理。

因为不使用 new 调用 Boolean() 只能访问 Boolean() 算法的前两个步骤,而这两个步骤都不能抛出异常,所以我们得出结论:不管输入是什么,Boolean() 都不会抛出异常。

现在看看 String()

This function performs the following steps when called:

  1. If value is not present, let s be the empty String.
  2. Else,

    1. If NewTarget is undefined and value is a Symbol, return SymbolDescriptiveString(value).
    2. Let s be ? ToString(value).
  3. If NewTarget is undefined, return s.
  4. Return StringCreate(s, ? GetPrototypeFromConstructor(NewTarget, "%String.prototype%")).

NewTarget 会返回 undefined,因此会跳过最后一步。Type and SymbolDescriptiveString 是安全的。因为 abrupt completions 不由它们俩处理。还有一个 ? 在 ToString() 前面。在规范中查找 ToString() 得到一个列表:

  1. If argument is a String, return argument.
  2. If argument is a Symbol, throw a TypeError exception.
  3. If argument is undefined, return "undefined".
  4. If argument is null, return "null".
  5. If argument is true, return "true".
  6. If argument is false, return "false".
  7. If argument is a Number, return Number::toString(argument, 10).
  8. If argument is a BigInt, return BigInt::toString(argument, 10).
  9. Assert: argument is an Object.
  10. Let primValue be ? ToPrimitive(argument, string).
  11. Assert: primValue is not an Object.
  12. Return ? ToString(primValue).

在 String() 中调用 ToString 的地方,value 可以是除了“符号”以外的任何值(在之前的步骤中会过滤掉这个值)。然而,还有两个 ? 对象的行中。

因此对于 String(),我们的结论是它从不抛出原始值的异常,但是可能抛出 Object 的错误。

参考资料

欢迎通过「邮件」或者点击「这里」告诉我你的想法
Welcome to tell me your thoughts via "email" or click "here"