帶有“非簡單引數”的函式為什麼不能包含 "use strict" 指令

紫雲飛發表於2016-11-01

非簡單引數就是 ES6 裡新加的引數語法,包括:1.預設引數值、2.剩餘引數、3.引數解構。本文接下來要講的就是 ES7 為什麼禁止在使用了非簡單引數的函式裡使用 "use strict" 指令:

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

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

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

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

除了放在指令碼開頭,"use strict" 指令還可以放在函式體的開頭,表明整個函式處於嚴格模式,像這樣:

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

需要注意的一點是,"use strict" 指令所處的位置是函式體的開頭,而不是整個函式的開頭,這就意味著解析器在解析函式開頭到函式體開頭的這段原始碼裡,遇到嚴格模式所禁用的語法後,它不知道該不該報錯(除非上層作用域已經處於嚴格模式),因為它不知道後面的函式體裡會不會包含 "use strict" 指令,比如:

function f(foo, foo) // 解析到這裡不知道該不該報錯,因為後面的函式體可能是 {},也可能是 {"use strict"}

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

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

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" 了。

相關文章