读懂 ECMAScript 规格:《ECMAScript 6入门》规格阅读指南
2026/9/20 13:55:46 网站建设 项目流程

读懂 ECMAScript 规格:《ECMAScript 6入门》规格阅读指南

【免费下载链接】es6tutorial《ECMAScript 6入门》是一本开源的 JavaScript 语言教程,全面介绍 ECMAScript 6 新增的语法特性。项目地址: https://gitcode.com/gh_mirrors/es/es6tutorial

规格文件(Specification)是计算机语言的官方标准,详细描述了语法规则与实现方法。当 JavaScript 遇到"语法书里找不到答案"的疑难问题时,规格文件就是解决问题的"最后一招"。本文是《ECMAScript 6入门》教程中读懂规格一章的完整展开,先带你了解 ES6 规格的整体结构,再拆解抽象操作、Record、[[Notation]]、Completion Record 等核心术语,最后以相等运算符、数组空位、map()方法三个真实案例演示"查规格、读算法、对实现"的完整方法论。读完本文,你将具备直接阅读 ECMA-262 规格并定位疑难语法问题根因的能力。

为什么需要读懂规格

规格文件是计算机语言的官方标准,详细描述语法规则和实现方法。一般来说,没有必要阅读规格,除非你要写编译器。因为规格写得非常抽象和精炼,又缺乏实例,不容易理解,而且对于解决实际的应用问题帮助不大。但是,如果你遇到疑难的语法问题,实在找不到答案,这时可以去查看规格文件,了解语言标准是怎么说的——规格是解决问题的"最后一招"。

这对 JavaScript 语言尤其必要。它的使用场景复杂,语法规则不统一,例外很多,各种运行环境的行为不一致,导致奇怪的语法问题层出不穷,任何语法书都不可能囊括所有情况。查看规格,不失为一种解决语法问题的最可靠、最权威的终极方法。

在《ECMAScript 6入门》教程中,读懂规格是全书的第 25 章(见 SUMMARY.md)。它与教程正文的关系是"方法论"与"知识"的关系:正文各章(如数组的扩展、函数的扩展、数值的扩展)讲解 ES6 的语法特性,而规格一章教会你如何回到语言标准的源头去验证和深挖这些行为。

ES6 规格文件的结构与阅读策略

ECMAScript 6 的规格,可以在 ECMA 国际标准组织的官方网站(www.ecma-international.org/ecma-262/6.0/)免费下载和在线阅读,对应官方文件为《ECMAScript® 2015 Language Specification》(参见参考链接中的"官方文件"一节)。

这个规格文件相当庞大,一共有 26 章,A4 打印的话足足有 545 页。它的特点就是规定得非常细致,每一个语法行为、每一个函数的实现都做了详尽的清晰的描述。基本上,编译器作者只要把每一步翻译成代码就可以了。这很大程度上保证了所有 ES6 实现都有一致的行为。

26 章之中,各部分的定位如下:

  • 第 1 章到第 3 章:对文件本身的介绍,与语言关系不大;
  • 第 4 章:对这门语言总体设计的描述,有兴趣的读者可以读一下;
  • 第 5 章到第 8 章:语言宏观层面的描述。第 5 章是规格的名词解释和写法的介绍,第 6 章介绍数据类型,第 7 章介绍语言内部用到的抽象操作,第 8 章介绍代码如何运行;
  • 第 9 章到第 26 章:介绍具体的语法。

对于一般用户来说,除了第 4 章,其他章节都涉及某一方面的细节,不用通读,只要在用到的时候查阅相关章节即可。这是一种"按需查阅"式的阅读策略:把规格当成字典,而不是教科书。

规格术语:读懂 ES6 规格的四个关键概念

ES6 规格使用了一些专门的术语,了解这些术语,可以帮助你读懂规格。下面介绍其中最重要的四个。

抽象操作(abstract operations)

所谓"抽象操作"就是引擎的一些内部方法,外部不能调用。规格定义了一系列的抽象操作,规定了它们的行为,留给各种引擎自己去实现。

举例来说,Boolean(value)的算法,第一步是这样的:

  1. LetbbeToBoolean(value).

这里的ToBoolean就是一个抽象操作,是引擎内部求出布尔值的算法。许多函数的算法都会多次用到同样的步骤,所以 ES6 规格将它们抽出来定义成"抽象操作",方便描述。抽象操作是规格的"词汇表":ToBooleanToNumberToLengthToPrimitiveGetHasPropertyCall等,都会在后面的算法中反复出现。

Record 和 field

ES6 规格将键值对(key-value map)的数据结构称为Record,其中的每一组键值对称为field。也就是说,一个 Record 由多个 field 组成,而每个 field 都包含一个键名(key)和一个键值(value)。可以把它理解为规格内部使用的一种抽象数据结构,用来描述对象、属性表等运行时状态。

[[Notation]] 内部属性记号

ES6 规格大量使用[[Notation]]这种书写法,比如[[Value]][[Writable]][[Get]][[Set]]等等。它用来指代 field 的键名。

举例来说,obj是一个 Record,它有一个Prototype属性。ES6 规格不会写obj.Prototype,而是写obj.[[Prototype]]。一般来说,使用[[Notation]]这种书写法的属性都是对象的内部属性。

所有的 JavaScript 函数都有一个内部属性[[Call]],用来运行该函数:

F.[[Call]](V, argumentsList)

上面代码中,F是一个函数对象,[[Call]]是它的内部方法,F.[[Call]]()表示运行该函数,V表示[[Call]]运行时this的值,argumentsList则是调用时传入函数的参数。

Completion Record

每一个语句都会返回一个 Completion Record,表示运行结果。每个 Completion Record 有一个[[Type]]属性,表示运行结果的类型。[[Type]]属性有五种可能的值:

  • normal
  • return
  • throw
  • break
  • continue

如果[[Type]]的值是normal,就称为 normal completion,表示运行正常。其他的值都称为 abrupt completion(异常完成)。其中,开发者只需要关注[[Type]]throw的情况,即运行出错;breakcontinuereturn这三个值都只出现在特定场景,可以不用考虑。

抽象操作的标准流程:ReturnIfAbrupt、? 与 !

抽象操作的运行流程,一般是下面这样:

  1. LetresultbeAbstractOp().
  2. Ifresultis an abrupt completion, returnresult.
  3. Setresulttoresult.[[Value]].
  4. returnresult.

上面的第一步调用了抽象操作AbstractOp(),得到result,这是一个 Completion Record。第二步,如果result属于 abrupt completion,就直接返回。如果此处没有返回,表示result属于 normal completion。第三步,将result的值设置为result.[[Value]]。第四步,返回result

ES6 规格将这个标准流程,使用简写的方式表达:

  1. LetresultbeAbstractOp().
  2. ReturnIfAbrupt(result).
  3. returnresult.

这个简写方式里面的ReturnIfAbrupt(result)就代表了上面的第二步和第三步,即"如果有报错,就返回错误,否则取出值"。

甚至还有进一步的简写格式:

  1. Letresultbe? AbstractOp().
  2. returnresult.

上面流程的?代表AbstractOp()可能会报错。一旦报错就返回错误,否则取出值。也就是说,?ReturnIfAbrupt的浓缩记号。

除了?,ES6 规格还使用另一个简写符号!

  1. Letresultbe! AbstractOp().
  2. returnresult.

上面流程的!代表AbstractOp()不会报错,返回的一定是 normal completion,总是可以取出值。

这三个符号构成了阅读规格算法的"速记三件套":?表示"可能抛错,需传播"、!表示"保证成功"、显式的ReturnIfAbrupt则是未浓缩的完整写法。掌握它们之后,规格里那些动辄十几步的算法,读起来会顺畅得多。

实战案例一:相等运算符(==)为什么0 == null是 false

相等运算符(==)是一个很让人头痛的运算符,它的语法行为多变,不符合直觉。这个小节就看看规格怎么规定它的行为。

请看下面这个表达式,请问它的值是多少:

0 == null

如果你不确定答案,或者想知道语言内部怎么处理,就可以去查看规格。规格对每一种语法行为的描述,都分成两部分:先是总体的行为描述,然后是实现的算法细节。相等运算符的总体描述只有一句话:

"The comparisonx == y, wherexandyare values, producestrueorfalse."

这句话的意思是,相等运算符用于比较两个值,返回truefalse

下面是算法细节:

  1. ReturnIfAbrupt(x).
  2. ReturnIfAbrupt(y).
  3. IfType(x)is the same asType(y), then
    1. Return the result of performing Strict Equality Comparisonx === y.
  4. Ifxisnullandyisundefined, returntrue.
  5. Ifxisundefinedandyisnull, returntrue.
  6. IfType(x)is Number andType(y)is String, return the result of the comparisonx == ToNumber(y).
  7. IfType(x)is String andType(y)is Number, return the result of the comparisonToNumber(x) == y.
  8. IfType(x)is Boolean, return the result of the comparisonToNumber(x) == y.
  9. IfType(y)is Boolean, return the result of the comparisonx == ToNumber(y).
  10. IfType(x)is either String, Number, or Symbol andType(y)is Object, then return the result of the comparisonx == ToPrimitive(y).
  11. IfType(x)is Object andType(y)is either String, Number, or Symbol, then return the result of the comparisonToPrimitive(x) == y.
  12. Returnfalse.

上面这段算法一共有 12 步,翻译如下:

  1. 如果x不是正常值(比如抛出一个错误),中断执行。
  2. 如果y不是正常值,中断执行。
  3. 如果Type(x)Type(y)相同,执行严格相等运算x === y
  4. 如果xnullyundefined,返回true
  5. 如果xundefinedynull,返回true
  6. 如果Type(x)是数值,Type(y)是字符串,返回x == ToNumber(y)的结果。
  7. 如果Type(x)是字符串,Type(y)是数值,返回ToNumber(x) == y的结果。
  8. 如果Type(x)是布尔值,返回ToNumber(x) == y的结果。
  9. 如果Type(y)是布尔值,返回x == ToNumber(y)的结果。
  10. 如果Type(x)是字符串或数值或Symbol值,Type(y)是对象,返回x == ToPrimitive(y)的结果。
  11. 如果Type(x)是对象,Type(y)是字符串或数值或Symbol值,返回ToPrimitive(x) == y的结果。
  12. 返回false

由于0的类型是数值,null的类型是 Null(这是规格中"Null 类型"的规定,是内部Type运算的结果,跟typeof运算符无关)。因此上面的前 11 步都得不到结果,要到第 12 步才能得到false

0 == null // false

这个案例很好地演示了规格阅读的完整路径:遇到疑问 → 定位相关小节 → 读总体描述 → 逐条翻译算法 → 得出结论。规格中 12 步算法里第 8、9 步对布尔值的ToNumber转换、第 10、11 步对对象的ToPrimitive转换,正是==各种"不符合直觉"行为(如null == 0为 false、[] == 0为 true)的根源,而这些行为在普通语法书中往往被一笔带过。

实战案例二:数组的空位(hole)为什么与 undefined 不同

再看另一个例子:

const a1 = [undefined, undefined, undefined]; const a2 = [, , ,]; a1.length // 3 a2.length // 3 a1[0] // undefined a2[0] // undefined a1[0] === a2[0] // true

上面代码中,数组a1的成员是三个undefined,数组a2的成员是三个空位。这两个数组很相似,长度都是 3,每个位置的成员读取出来都是undefined

但是,它们实际上存在重大差异:

0 in a1 // true 0 in a2 // false a1.hasOwnProperty(0) // true a2.hasOwnProperty(0) // false Object.keys(a1) // ["0", "1", "2"] Object.keys(a2) // [] a1.map(n => 1) // [1, 1, 1] a2.map(n => 1) // [, , ,]

上面代码一共列出了四种运算,数组a1a2的结果都不一样。前三种运算(in运算符、数组的hasOwnProperty方法、Object.keys方法)都说明数组a2取不到属性名;最后一种运算(数组的map方法)说明数组a2没有发生遍历。

为什么a1a2成员的行为不一致?数组的成员是undefined或空位,到底有什么不同?

规格中《数组的初始化》(Array Initializer)一节给出了答案:

"Array elements may be elided at the beginning, middle or end of the element list. Whenever a comma in the element list is not preceded by an AssignmentExpression (i.e., a comma at the beginning or after another comma), the missing array element contributes to the length of the Array and increases the index of subsequent elements. Elided array elements are not defined. If an element is elided at the end of an array, that element does not contribute to the length of the Array."

翻译如下:

"数组成员可以省略。只要逗号前面没有任何表达式,数组的length属性就会加 1,并且相应增加其后成员的位置索引。被省略的成员不会被定义。如果被省略的成员是数组最后一个成员,则不会导致数组length属性增加。"

上面的规格说得很清楚,数组的空位会反映在length属性,也就是说空位有自己的位置,但是这个位置的值是未定义,即这个值是不存在的。如果一定要读取,结果就是undefined(因为undefined在 JavaScript 语言中表示不存在)。

这就解释了为什么in运算符、数组的hasOwnProperty方法、Object.keys方法都取不到空位的属性名:因为这个属性名根本就不存在,规格里面没说要为空位分配属性名(位置索引),只说要为下一个元素的位置索引加 1。

这个案例在《ECMAScript 6入门》的数组的扩展一章中有更完整的延伸:教程指出"空位不是undefined,某一个位置的值等于undefined,依然是有值的。空位是没有任何值,in运算符可以说明这一点",并系统对比了各方法对空位的处理——forEach()filter()reduce()every()some()会跳过空位;map()跳过空位但保留该值;join()toString()将空位视为undefined;而 ES6 的Array.from()、扩展运算符(...)、fill()for...of循环则会把空位当作普通位置处理。由于空位的处理规则非常不统一,教程给出的实用建议是:尽量避免在代码中出现数组空位

实战案例三:Array.prototype.map 为什么会跳过空位

规格中定义了数组的map方法。该小节先是总体描述map方法的行为,里面没有提到数组空位。后面的算法描述是这样的:

  1. LetObeToObject(this value).
  2. ReturnIfAbrupt(O).
  3. LetlenbeToLength(Get(O, "length")).
  4. ReturnIfAbrupt(len).
  5. IfIsCallable(callbackfn)isfalse, throw a TypeError exception.
  6. IfthisArgwas supplied, letTbethisArg; else letTbeundefined.
  7. LetAbeArraySpeciesCreate(O, len).
  8. ReturnIfAbrupt(A).
  9. Letkbe 0.
  10. Repeat, whilek<len
    1. LetPkbeToString(k).
    2. LetkPresentbeHasProperty(O, Pk).
    3. ReturnIfAbrupt(kPresent).
    4. IfkPresentistrue, then
      1. LetkValuebeGet(O, Pk).
      2. ReturnIfAbrupt(kValue).
      3. LetmappedValuebeCall(callbackfn, T, «kValue, k, O»).
      4. ReturnIfAbrupt(mappedValue).
      5. LetstatusbeCreateDataPropertyOrThrow (A, Pk, mappedValue).
      6. ReturnIfAbrupt(status).
    5. Increasekby 1.
  11. ReturnA.

翻译如下:

  1. 得到当前数组的this对象
  2. 如果报错就返回
  3. 求出当前数组的length属性
  4. 如果报错就返回
  5. 如果 map 方法的参数callbackfn不可执行,就报错
  6. 如果 map 方法的参数之中指定了this,就让T等于该参数,否则Tundefined
  7. 生成一个新的数组A,跟当前数组的length属性保持一致
  8. 如果报错就返回
  9. 设定k等于 0
  10. 只要k小于当前数组的length属性,就重复下面步骤
    1. 设定Pk等于ToString(k),即将k转为字符串
    2. 设定kPresent等于HasProperty(O, Pk),即求当前数组有没有指定属性
    3. 如果报错就返回
    4. 如果kPresent等于true,则进行下面步骤
      1. 设定kValue等于Get(O, Pk),取出当前数组的指定属性
      2. 如果报错就返回
      3. 设定mappedValue等于Call(callbackfn, T, «kValue, k, O»),即执行回调函数
      4. 如果报错就返回
      5. 设定status等于CreateDataPropertyOrThrow (A, Pk, mappedValue),即将回调函数的值放入A数组的指定位置
      6. 如果报错就返回
    5. k增加 1
  11. 返回A

仔细查看上面的算法可以发现:当处理一个全是空位的数组时,前面步骤都没有问题;进入第 10 步中第 2 步时,kPresent会得到false,因为空位对应的属性名对于数组来说是不存在的。由于回调函数只在kPresenttrue时才会被调用(第 10.4 步),空位就被整个跳过了:

const arr = [, , ,]; arr.map(n => { console.log(n); return 1; }) // [, , ,]

上面代码中,arr是一个全是空位的数组,map方法遍历成员时发现是空位,就直接跳过,不会进入回调函数。因此回调函数里面的console.log语句根本不会执行,整个map方法返回一个全是空位的新数组。

值得注意的是,这一步的关键是规格算法第 10.4 步的kPresent(即HasProperty(O, Pk)的返回值)——这正是前一节《数组的空位》的结论在map算法中的直接体现:空位不拥有属性名,HasProperty返回false,于是循环体直接进入k加 1。两个案例在此汇合,形成了"规格内部互相印证"的阅读体验。

与引擎实现的对照:V8 的 ArrayMap

规格的价值不仅在于规定行为,还在于它直接决定了引擎的实现结构。V8 引擎对map方法的实现如下,可以看到跟规格的算法描述完全一致:

function ArrayMap(f, receiver) { CHECK_OBJECT_COERCIBLE(this, "Array.prototype.map"); // Pull out the length so that modifications to the length in the // loop will not affect the looping and side effects are visible. var array = TO_OBJECT(this); var length = TO_LENGTH_OR_UINT32(array.length); return InnerArrayMap(f, receiver, array, length); } function InnerArrayMap(f, receiver, array, length) { if (!IS_CALLABLE(f)) throw MakeTypeError(kCalledNonCallable, f); var accumulator = new InternalArray(length); var is_array = IS_ARRAY(array); var stepping = DEBUG_IS_STEPPING(f); for (var i = 0; i < length; i++) { if (HAS_INDEX(array, i, is_array)) { var element = array[i]; // Prepare break slots for debugger step in. if (stepping) %DebugPrepareStepInIfStepping(f); accumulator[i] = %_Call(f, receiver, element, i, array); } } var result = new GlobalArray(); %MoveArrayContents(accumulator, result); return result; }

对照可以发现 V8 实现与规格算法的对应关系:

  • TO_OBJECT(this)对应规格第 1 步ToObject(this value)
  • TO_LENGTH_OR_UINT32(array.length)对应规格第 3 步ToLength(Get(O, "length"))
  • if (!IS_CALLABLE(f))对应规格第 5 步的IsCallable检查;
  • 循环中的if (HAS_INDEX(array, i, is_array))正是规格第 10.4 步的HasProperty(O, Pk)判断,只有存在属性索引才读取元素、调用回调并写入累加器;
  • accumulator[i] = %_Call(f, receiver, element, i, array)对应规格第 10.4.c 步Call(callbackfn, T, «kValue, k, O»)

也就是说,规格中每一步抽象算法,都可以在引擎源码中找到对应的实现行。这正是规格"基本上,编译器作者只要把每一步翻译成代码就可以了"这一特点的具体体现,也是所有 ES6 实现行为一致性的根本保证。

把规格阅读融入日常开发

从上面三个案例可以看到,阅读规格并不是编译器的专利。当你遇到以下场景时,规格就是你最可靠的参考:

  1. 遇到"不符合直觉"的运算符行为(如==的类型转换),直接查对应运算的算法小节,逐条对照即可定位答案;
  2. 需要区分"看起来一样"的数据形态(如undefined成员与数组空位),规格对"是否定义了属性"给出了明确的判定标准(HasProperty);
  3. 想理解某个方法"为什么这样做"(如map跳过空位、forEachmap的差异),规格的算法步骤就是最权威的行为说明书,还可以进一步与 V8 等引擎源码互相印证。

阅读规格时,建议掌握以下几个要点:

  • 先定位章节:ES6 规格第 5~8 章是宏观术语与抽象操作,第 9~26 章是具体语法,按需查阅,不必通读;
  • 先总述后算法:每个语法行为都分"总体描述 + 算法细节"两部分,先用总体描述建立直觉,再逐条翻译算法;
  • 掌握速记符号?表示"可能报错并传播"、!表示"保证成功"、ReturnIfAbrupt是完整的显式写法;
  • 用代码验证:阅读算法得出的结论,最终都要回到 Node.js 或浏览器控制台用代码验证一遍,形成"规格 → 实现 → 验证"的闭环。

本教程的其他章节同样与规格有着深层的联系。例如数值的扩展中Number.EPSILON的定义就"根据规格"表示 1 与大于 1 的最小浮点数之间的差;函数的扩展中提到 ES6 第一次明确规定所有 ECMAScript 实现都必须部署"尾调用优化",这正是规格对引擎行为的强制性规定。理解了规格,你就等于拿到了理解这些语法设计动机的钥匙。

【免费下载链接】es6tutorial《ECMAScript 6入门》是一本开源的 JavaScript 语言教程,全面介绍 ECMAScript 6 新增的语法特性。项目地址: https://gitcode.com/gh_mirrors/es/es6tutorial

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询