带有“非简单参数”的函数为什么不能包含 "use strict" 指令
非简单参数就是 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" 了。