帶有「非簡單參數」的函數爲何不能包含 "use strict" 指令

非簡單參數就是 ES6 裏新加的參數語法,包括:1.默認參數值、2.剩餘參數、3.參數解構。本文接下來要講的就是 ES7 爲何禁止在使用了非簡單參數的函數裏使用 "use strict" 指令:html

function f(foo = "bar") {
  "use strict" // SyntaxError: Illegal 'use strict' directive in function with non-simple parameter list
}

ES5 引入的嚴格模式禁用了一些語法,好比傳統的八進制數字寫法:git

"use strict"
00 // SyntaxError: Octal literals are not allowed in strict mode.

上面這個報錯的原理是:解析器先解析到了腳本開頭的 "use strict" 指令,該指令代表當前整個腳本都處於嚴格模式中,而後在解析到 00 的時候就會直接報錯。github

除了放在腳本開頭,"use strict" 指令還能夠放在函數體的開頭,代表整個函數處於嚴格模式,像這樣:web

function f() {
  "use strict"
  00 // SyntaxError: Octal literals are not allowed in strict mode. 
}

須要注意的一點是,"use strict" 指令所處的位置是函數體的開頭,而不是整個函數的開頭,這就意味着解析器在解析函數開頭到函數體開頭的這段源碼裏,遇到嚴格模式所禁用的語法後,它不知道該不應報錯(除非上層做用域已經處於嚴格模式),由於它不知道後面的函數體裏會不會包含 "use strict" 指令,好比:frontend

function f(foo, foo) // 解析到這裏不知道該不應報錯,由於後面的函數體多是 {},也多是 {"use strict"}

"use strict" 指令左邊可能存在的語法結構有函數名、參數列表、存在於函數體內且在 "use strict" 左邊的其它的指令序言,這三種結構均可能包含違反嚴格模式的語法,在 ES5 裏的話,這些語法包括下面 4 種:jsp

1. 函數名或參數名爲嚴格模式下專有的保留字,包括 implements、interface、let、package、private、protected、public、static、yield,好比:ide

function let() {
"use strict"
}
function f(yield) {
"use strict"
}

2. 函數名或參數名爲 eval 或 arguments,好比:函數

function eval() {
"use strict"
}
function f(arguments) {
"use strict"
}

3. 參數名重複,好比:性能

function f(foo, foo) {
  "use strict"
}

4. "use strict" 左邊的指令序言裏包含了傳統的八進制轉譯序列,好比:測試

function f() {
  "\00"
  "use strict"
}

當解析器遇到這幾種語法時,若是函數的上層做用域已是嚴格模式了,那好說,直接報錯,若是不是呢?

SpiderMonkey 在 2009 年實現嚴格模式的時候,對於前 3 種語法錯誤的檢測方法是:把函數名和全部的參數名先存下來,等到解析完函數體後,知道了當前函數是不是嚴格模式後,再去檢查那些名字,這裏引用一段當年的 SpiderMonkey 源碼中用來檢查參數名的 CheckStrictParameters 方法中的註釋:

/*
 * In strict mode code, all parameter names must be distinct, must not be
 * strict mode reserved keywords, and must not be 'eval' or 'arguments'.  We
 * must perform these checks here, and not eagerly during parsing, because a
 * function's body may turn on strict mode for the function head.
 */
static bool
CheckStrictParameters(JSContext *cx, JSTreeContext *tc)
{

這段註釋最後一句也提到了,對函數頭的檢查須要延遲到解析函數體後才能進行。

對第 4 種語法錯誤的檢測,SpiderMonkey 是經過一個叫 TSF_OCTAL_CHAR 的標誌位實現的,相關源碼:

TSF_OCTAL_CHAR = 0x1000, /* observed a octal character escape */

下面是這個標誌位的 gettersetter

void setOctalCharacterEscape(bool enabled = true) { setFlag(enabled, TSF_OCTAL_CHAR); }
bool hasOctalCharacterEscape() const { return flags & TSF_OCTAL_CHAR; }

下面的代碼是在說,當解析到八進制轉義序列時,若是已經處於嚴格模式中,則直接報錯,不然,不報錯,只經過 setOctalCharacterEscape 方法記錄下標誌位:

/* Strict mode code allows only \0, then a non-digit. */
if (val != 0 || JS7_ISDEC(c)) {
    if (!ReportStrictModeError(cx, this, NULL, NULL,
                               JSMSG_DEPRECATED_OCTAL)) {
        goto error;
    }
    setOctalCharacterEscape();
}

最後要作的就是在看到 "use strict" 後,經過 hasOctalCharacterEscape 方法檢查前面的指令序言有沒有設置那個標誌位,有的話就報錯,註釋也寫的很清楚:

if (directive == context->runtime->atomState.useStrictAtom) {
    /*
     * Unfortunately, Directive Prologue members in general may contain
     * escapes, even while "use strict" directives may not.  Therefore
     * we must check whether an octal character escape has been seen in
     * any previous directives whenever we encounter a "use strict"
     * directive, so that the octal escape is properly treated as a
     * syntax error.  An example of this case:
     *
     *   function error()
     *   {
     *     "\145"; // octal escape
     *     "use strict"; // retroactively makes "\145" a syntax error
     *   }
     */
    if (tokenStream.hasOctalCharacterEscape()) {
        reportErrorNumber(NULL, JSREPORT_ERROR, JSMSG_DEPRECATED_OCTAL);
        return false;
    }

整體上來講,SpiderMonkey 當年針對 ES5 裏這 4 種出如今 "use strict" 指令左側的嚴格模式錯誤的檢測都是經過記錄信息,延遲報錯的方式來實現的。

2012 年,SpiderMonkey 實現了 ES6 裏的默認參數值,默認參數值是一個表達式,這個表達式的解析模式(是不是嚴格模式)應該和當前函數相同,因此下面的這個代碼也應該報錯:

delete foo // 非嚴格模式,不報錯
function f(p = delete foo) { // 嚴格模式,報錯
  "use strict"
}

因爲函數頭裏面能夠寫表達式了,因此上面說的 ES5 裏應該報的那 4 種嚴格模式的錯誤,範圍更擴大了,多了八進制數字、delete 一個變量,這到不算什麼,再多記兩種錯誤類型而已。關鍵還存在一種特殊的、能包含任意語句的表達式 - 函數表達式,致使全部嚴格模式特有的解析錯誤都得特殊處理了,好比 with 語句、嚴格模式特有的保留字做爲標識符等,好比:

function f(a = function() {
  with({}) {} // SyntaxError: Strict mode code may not include a with statement
}) {
  "use strict"
}

並且那個函數表達式還能夠包含更多層嵌套的子函數,會致使記錄函數頭裏的這些錯誤變的很是複雜。SpiderMonkey 當年前後用了兩種實現方法來解決這個難題:

1. 和老的實現方式相似,按照嚴格模式的規則解析函數頭,但並不當即報錯,而是把錯誤信息記下來,等解析完整個函數,知道了這個函數是否是嚴格模式後,再看用不用真的報錯。

2. 按照非嚴格模式的規則解析,假如真的遇到了 "use strict" 指令,解析器回退到函數起始處,從新按照嚴格模式的規則解析一遍,遇到錯誤就直接報錯,也就是二次解析(reparse)。

SpiderMonkey 先用第一種方式實現了,核心思路就是用一個 queuedStrictModeError 屬性記錄下在解析函數頭時遇到的第一個嚴格模式錯誤,若是後面解析到 "use strict" 的話,把那個錯誤拋出來:

// A strict mode error found in this scope or one of its children. It is
// used only when strictModeState is UNKNOWN. If the scope turns out to be
// strict and this is non-null, it is thrown.
CompileError    *queuedStrictModeError;

而後過了半年,當初按照第 1 種方式實現的那我的,跳出來講本身後悔了,說先前的實現方式很複雜並且易碎,而後就用第二種 reparse 的方式從新實現了一遍,下面是第二種實現方式的代碼裏的一段關鍵註釋,說的很清楚:

// If the context is strict, immediately parse the body in strict
// mode. Otherwise, we parse it normally. If we see a "use strict"
// directive, we backup and reparse it as strict.

SpiderMonkey 說完了,再來講說 V8,若是沒有 V8 的牽頭,也不會有本篇文章。V8 在 2011 年實現了嚴格模式,對於上面說的 ES5 裏那 4 種報錯的實現,大致上和 SpiderMonkey 09 年的實現相仿,就是記錄下相關信息,延遲決定是否要報錯。然而 V8 在 2015 年實現默認參數值的時候,也遇到了和 SpiderMonkey 在 12 年的一樣的問題,在 V8 裏可行的辦法也是那兩個,要不延遲報錯,要不實現 reparse。然而 V8 哪一種實現方式都不想作,V8 的開發者專門作了個 slides,在 TC39 的會議上提議,應該禁止在使用 ES6 引入的新的參數語法的同時使用 "use strict",這裏有會議記錄

關於延遲報錯的實現方式,V8 的人表示實現起來很麻煩,並且可能影響性能。具體的麻煩除了「要比 ES5 記錄更多的錯誤類型」外,V8 的人還重點指出了 ES6 裏的箭頭函數也會給這種實現方式帶來困難:

(foo = 00 // 解析到這裏時,要記錄錯誤信息嗎?

(foo = 00) // 若是完整的代碼行只是個賦值語句,那錯誤信息就白記了

(foo = 00) => {"use strict"} // 若是完整的代碼行是個箭頭函數呢

(foo = function(){/* 這裏面的代碼也有一樣的問題 */}) // 後面跟着的可能就是 => {"use strict"}

也就是說,由於箭頭函數沒有標明函數起始位置的 function 關鍵字,致使解析任何一個被小括號擴住的賦值表達式和逗號表達式時,都要把它當成是箭頭函數的參數列表,把全部遇到的嚴格模式錯誤記下來,V8 源碼裏有一段註釋明確指出瞭解析箭頭函數的這一難點:

// When this function is used to read a formal parameter, we don't always
// know whether the function is going to be strict or sloppy.  Indeed for
// arrow functions we don't always know that the identifier we are reading
// is actually a formal parameter.  Therefore besides the errors that we
// must detect because we know we're in strict mode, we also record any
// error that we might make in the future once we know the language mode.

除了上面全部這些因嚴格模式特有的報錯引發的實現難點外,V8 的人還指出了另一個實現難點,那就是塊級做用域的函數聲明出如今默認參數值裏的狀況:

(function f(foo = (function(bar) {
  {
    function bar() {}
  }
  return bar
})(1)) {
  "use strict"
  alert(foo) // 嚴格模式彈出 1,非嚴格模式彈出函數 bar 
})()

ES6 在引入塊級函數聲明的時候,爲了保證向後兼容,規定在非嚴格模式下代碼塊裏的函數仍然會提高到函數做用域(附錄 B 3.3),這就致使了在解析塊級函數的時候,若是當前是嚴格模式,則應該把該函數放到那個塊級做用域裏,不然把它放進上層的函數做用域裏。這種信息怎麼記錄,何況上面的例子僅僅是最簡單的狀況,實際狀況還可能有任意多個的處於不一樣嵌套層級的 bar,如何延遲肯定它們的做用域,又是個實現的難點。

整體來看,針對這件事情,用 reparse 的方式實現比起用記錄信息,延遲報錯的方式實現更簡單,然而 V8 不想實現 reparse,並無詳細解釋爲何。

在那個 slides 裏, V8 的人有頁總結:

1. 這東西實現起來太複雜。

2. 影響性能,解析器是引擎性能的瓶頸

3. 之後 TC39 在制定新的規範時還可能被這個問題困擾,要趁早扼殺掉

4. 這種寫法會愈來愈少見(class 和 module 默認嚴格模式),這東西實現起來性價比不高

所以 V8 在那次會議上提議,在 ES7 裏,禁止在使用 ES6 引入的新的參數語法的同時使用 "use strict",也就是把函數級別的 "use strict" 須要倒着解析的麻煩保持在 ES5 的級別不動了。

目前,各主流引擎已經相繼實現了 ES7 裏的這一改動:

V8 於去年 8 月份 https://crrev.com/77394fa05a63a539ac4e6858d99cc85ec6867512

ChakraCore 於今年 1 月份 https://github.com/Microsoft/ChakraCore/commit/d8bef2e941de27e7d666e0450a14013764565020

JavaScriptCore 於今年 7 月份 https://bugs.webkit.org/show_bug.cgi?id=159790

SpiderMonkey 今年 10 月份(上週)https://bugzilla.mozilla.org/show_bug.cgi?id=1272784

其中 SpiderMonkey 在實現這一改動的時候已經把當初實現的 reparse 的邏輯刪掉了:Part 2: Don't reparse functions with 'use strict' directives. 從 ChakraCore 和 JavaScriptCore 在實現這一改動時沒有刪除額外的代碼(包括測試代碼)來看,我猜它倆和 V8 同樣,歷來沒有實現過 ES6 中 「默認參數值也應該遵循函數的嚴格模式」 這一規定 。

那些用 JS 寫的解析器有沒有實現過 ES6 的這一規定以及它們是怎麼實現的?我看 Esprima 是沒有實現,Shift Parser 實現過(如今已經按 ES7 的規則報錯了),並且當初 Shift Parser 實現的時候,也是從那兩種實現方式裏選了 reparse

上面說過,當外部做用域已是嚴格模式的時候,引擎在解析函數頭時沒必要糾結,是否是能夠不用執行這項禁令了?

function f() {
  "use strict" // 已是嚴格模式了
  function g(foo = "bar") { // 解析這行不用糾結
    "use strict" // 這裏不必報錯了吧
  }  
}

ChakraCore 當初的確實現過這個「體驗優化」,但因最終規範並無這麼規定,又回滾了,規範沒這麼規定的緣由我覺的很簡單,就是不必把事情搞複雜,原本這個報錯就是爲了減小引擎實現的複雜度而產生的。

這件事情中全部複雜度其實都是默認參數值帶來的,但爲何剩餘參數也會受到牽連:

function f(...rest) {
  "use strict" // 也會報錯
}

我想緣由還是爲了減小複雜度,由於 ES6 的規範裏已經有了簡單參數列表(simple parameter list)的概念,同時存在一個叫 IsSimpleParameterList() 的抽象方法,它在 ES6 裏有兩個使用場景,分別是:1. 當函數包含非簡單參數時,禁止 arguments 對象和形參雙向綁定(即使是非嚴格模式) 2.當函數包含非簡單參數時,禁止參數同名(即使是非嚴格模式)。ES7 裏的這個改動也用這個方法判斷,豈不是很方便,難道還要再寫個抽象方法,好比叫 IsParameterListWhichContainsInitializer(),也就是把剩餘參數和不包含默認參數值的解構參數從這項禁令裏排除,但不必搞這麼麻煩,規範裏概念少一點,規則統一一點,也方便記憶。

若是你想讓一個包含非簡單參數的函數進入嚴格模式,就在它外面包一層不帶參數的函數,在那個外層函數裏寫 "use strict":

(function () { // 外層函數不要帶參數
  "use strict"
  function f(foo = "bar") { 
    // 內層函數不用寫 "use strict" 了 
  }
})()

固然,前面也提到了,面向將來的話,class 和 module 都是默認嚴格模式的,不必你寫 "use strict" 了。

相關文章
相關標籤/搜索