非簡單參數就是 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 */
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" 了。
