带有“非简单参数”的函数为何不能包含 "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" 了。

相关文章
相关标签/搜索