27. 讀懂 ECMAScript 6 規格檔案

語言: CN / TW / HK

1. ECMAScript 規格檔案

規格檔案是計算機語言的官方標準,詳細描述語法規則和實現方法。

一般來說,沒有必要閱讀規格檔案,但是,如果遇到疑難的語法問題,實在找不到答案,這時可以去檢視規格檔案,瞭解語言標準是怎麼說的。規格檔案是解決問題的“最後一招”

這對 JavaScript 語言很有必要。因為它的使用場景複雜,語法規則不統一,例外很多,各種執行環境的行為不一致,導致奇怪的語法問題層出不窮,任何語法書都不可能囊括所有情況。檢視規格,不失為一種解決語法問題的最可靠、最權威的終極方法。

ECMAScript 6 的規格,可以在 ECMA 國際標準組織的官方網站(www.ecma-international.org/ecma-262/6.0/)免費下載和線上閱讀。

image.png

這個規格檔案,一共有 26 章,有 500多頁。它的特點就是規定得非常細緻,每一個語法行為、每一個函式的實現都做了詳盡的清晰的描述。這很大程度上,保證了所有 ES6 實現都有一致的行為。

ECMAScript 6 規格的 26 章之中, - 第 1 章到第 3 章是對檔案本身的介紹,與語言關係不大。 - 第 4 章是對這門語言總體設計的描述,有興趣的讀者可以讀一下。第 5 章到第 8 章是語言巨集觀層面的描述。 - 第 5 章是規格的名詞解釋和寫法的介紹, - 第 6 章介紹資料型別, - 第 7 章介紹語言內部用到的抽象操作, - 第 8 章介紹程式碼如何執行。 - 第 9 章到第 26 章介紹具體的語法。

1.gif

對於一般使用者來說,除了第 4 章,其他章節都涉及某一方面的細節,不用通讀,只要在用到的時候,查閱相關章節即可。

2. ES6 規格檔案使用的一些專門術語

ES6 規格使用了一些專門的術語,瞭解這些術語,可以幫助你讀懂規格。本節介紹其中的幾個。

2.1 抽象操作——abstract operations

所謂“抽象操作”(abstract operations)就是引擎的一些內部方法,外部不能呼叫。規格定義了一系列的抽象操作,規定了它們的行為,留給各種引擎自己去實現。

image.png

舉例來說,Boolean(value)的演算法,第一步是這樣的。

  1. Let b be ToBoolean(value).

這裡的ToBoolean就是一個抽象操作,是引擎內部求出布林值的演算法。

image.png

許多函式的演算法都會多次用到同樣的步驟,所以 ES6 規格將它們抽出來,定義成“抽象操作”,方便描述。

2.2 Record 和 field——field 指代一個 key-valu鍵值對

ES6 規格將鍵值對(key-value map)的資料結構稱為 Record,其中的每一組鍵值對稱為 field。這就是說,一個 Record 由多個 field 組成,而每個 field 都包含一個鍵名(key)和一個鍵值(value)。

image.png

2.3 [[Notation]] ——指代鍵值對 的鍵名

image.png

ES6 規格大量使用[[Notation]]這種書寫法,比如[[Value]][[Writable]][[Get]][[Set]]等等。它用來指代 field 的鍵名

舉例來說,obj是一個 Record,它有一個Prototype屬性。ES6 規格不會寫obj.Prototype,而是寫obj.[[Prototype]]。一般來說,使用[[Notation]]這種書寫法的屬性,都是物件的內部屬性。

image.png 所有的 JavaScript 函式都有一個內部屬性[[Call]],用來執行該函式。

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

image.png

上面程式碼中,F是一個函式物件,[[Call]]是它的內部方法,F.[[call]]()表示執行該函式,V表示[[Call]]執行時this的值,argumentsList則是呼叫時傳入函式的引數。

2.4 Completion Record —— 表示執行結果

每一個語句都會返回一個 Completion Record,表示執行結果。每個 Completion Record 有一個[[Type]]屬性,表示執行結果的型別。

image.png

[[Type]]屬性有五種可能的值。

  • normal
  • return
  • throw
  • break
  • continue

如果[[Type]]的值是normal,就稱為 normal completion,表示執行正常。其他的值,都稱為 abrupt completion。其中,開發者只需要關注[[Type]]throw的情況,即執行出錯breakcontinuereturn這三個值都只出現在特定場景,可以不用考慮。

3.抽象操作的標準流程——abstract operations

抽象操作的執行流程,一般是下面這樣。

  1. Let result be AbstractOp().
  2. If result is an abrupt completion, return result.
  3. Set result to result.[[Value]].
  4. return result.
  • 上面的第一步呼叫了抽象操作AbstractOp(),得到result,這是一個 Completion Record。
  • 第二步,如果result屬於 abrupt completion,就直接返回。如果此處沒有返回,表示result屬於 normal completion。
  • 第三步,將result的值設定為resultCompletionRecord.[[Value]]
  • 第四步,返回result

ES6 規格將這個標準流程,使用簡寫的方式表達。

  1. Let result be AbstractOp().
  2. ReturnIfAbrupt(result).
  3. return result.

image.png

這個簡寫方式裡面的ReturnIfAbrupt(result),就代表了上面的第二步和第三步,即如果有報錯,就返回錯誤,否則取出值。

甚至還有進一步的簡寫格式。

  1. Let result be ? AbstractOp().
  2. return result.

上面流程的?,就代表AbstractOp()可能會報錯。一旦報錯,就返回錯誤,否則取出值。

除了?,ES 6 規格還使用另一個簡寫符號!

  1. Let result be ! AbstractOp().
  2. return result.

上面流程的!,代表AbstractOp()不會報錯,返回的一定是 normal completion,總是可以取出值。

4. 相等運算子==——規格檔案7.2.12 小節

下面通過一些例子,介紹如何使用這份規格。

相等運算子(==)是一個很讓人頭痛的運算子,它的語法行為多變,不符合直覺。這個小節就看看規格怎麼規定它的行為。

請看下面這個表示式,請問它的值是多少。

0 == null

如果你不確定答案,或者想知道語言內部怎麼處理,就可以去檢視規格,7.2.12 小節是對相等運算子(==)的描述。

規格檔案對每一種語法行為的描述,都分成兩部分:先是總體的行為描述,然後是實現的演算法細節。

image.png

相等運算子的總體描述,只有一句話。

“The comparison x == y, where x and y are values, produces true or false.”

上面這句話的意思是,相等運算子用於比較兩個值,返回truefalse

下面是演算法細節。

  1. ReturnIfAbrupt(x).

  2. ReturnIfAbrupt(y).

  3. If Type(x) is the same as Type(y), then

    1. Return the result of performing Strict Equality Comparison x === y.
  4. If x is null and y is undefined, return true.

  5. If x is undefined and y is null, return true.

  6. If Type(x) is Number and Type(y) is String, return the result of the comparison x == ToNumber(y).

  7. If Type(x) is String and Type(y) is Number, return the result of the comparison ToNumber(x) == y.

  8. If Type(x) is Boolean, return the result of the comparison ToNumber(x) == y.

  9. If Type(y) is Boolean, return the result of the comparison x == ToNumber(y).

  10. If Type(x) is either String, Number, or Symbol and Type(y) is Object, then return the result of the comparison x == ToPrimitive(y).

  11. If Type(x) is Object and Type(y) is either String, Number, or Symbol, then return the result of the comparison ToPrimitive(x) == y.

  12. Return false.

上面這段演算法,一共有 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(這是規格4.3.13 小節的規定,是內部 Type 運算的結果,跟typeof運算子無關)。因此上面的前 11 步都得不到結果,要到第 12 步才能得到false

0 == null // false

image.png

5. 陣列的空位元素——12.2.5 小節

下面再看另一個例子。

``` const a1 = [undefined, undefined, undefined]; const a2 = [, , ,];

a1.length // 3 a2.length // 3

a1[0] // undefined a2[0] // undefined

a1[0] === a2[0] // true ```

image.png

上面程式碼中,陣列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) // [, , ,] ```

image.png

上面程式碼一共列出了四種運算,陣列a1a2的結果都不一樣。前三種運算(in運算子、陣列的hasOwnProperty方法、Object.keys方法)都說明,陣列a2取不到屬性名。最後一種運算(陣列的map方法)說明,陣列a2沒有發生遍歷。

為什麼a1a2成員的行為不一致?陣列的成員是undefined或空位,到底有什麼不同?

規格的12.2.5 小節《陣列的初始化》給出了答案。

image.png

“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。

至於為什麼陣列的map方法會跳過空位,請看下一節。

6. 陣列的 map 方法——規格的22.1.3.15 小節

規格的22.1.3.15 小節定義了陣列的map方法。該小節先是總體描述map方法的行為,裡面沒有提到陣列空位。

image.png

後面的演算法描述是這樣的。

  1. Let O be ToObject(this value).

  2. ReturnIfAbrupt(O).

  3. Let len be ToLength(Get(O, "length")).

  4. ReturnIfAbrupt(len).

  5. If IsCallable(callbackfn) is false, throw a TypeError exception.

  6. If thisArg was supplied, let T be thisArg; else let T be undefined.

  7. Let A be ArraySpeciesCreate(O, len).

  8. ReturnIfAbrupt(A).

  9. Let k be 0.

  10. Repeat, while k < len

    1. Let Pk be ToString(k).

    2. Let kPresent be HasProperty(O, Pk).

    3. ReturnIfAbrupt(kPresent).

    4. If kPresent is true, then

      1. Let kValue be Get(O, Pk).
      2. ReturnIfAbrupt(kValue).
      3. Let mappedValue be Call(callbackfn, T, «kValue, k, O»).
      4. ReturnIfAbrupt(mappedValue).
      5. Let status be CreateDataPropertyOrThrow (A, Pk, mappedValue).
      6. ReturnIfAbrupt(status).
    5. Increase k by 1.

  11. Return A.

翻譯如下。

  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會報錯,因為空位對應的屬性名,對於陣列來說是不存在的,因此就會返回,不會進行後面的步驟。

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

image.png

上面程式碼中,arr是一個全是空位的陣列,map方法遍歷成員時,發現是空位,就直接跳過,不會進入回撥函式。因此,回撥函式裡面的console.log語句根本不會執行,整個map方法返回一個全是空位的新陣列。

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; } ```