ES6中是否真的不能声明同名的let与var?

首先,作为一个常识,在ES6之后的JavaScript中,不能声明同名的letvar,也同样不能声明同名的const。但是,在极客时间的课程中,Elmer同学问出了下面这样的问题:

// 示例1
function test(x = 2) {
  var x = 3;
  console.log(x);
}
test(); // 3

在这段代码中,既然参数中的x是一个let风格的声明,那么为什么还能声明var x呢?

其中,

参数xlet风格声明的变量,

是一个少有人知的事实。简单来看,它可以通过如下方式来验证:

// 示例2
function test(x=x) {}
test(); // ReferenceError: x is not defined

由于这个xlet风格的声明,因此在当它作为右边的x被访问时,会出现x未声明这样的异常(这种情况下,x是在当前作用域中是一个未绑定初值的声明)。

那么,问题是:“示例1”中的var x究竟又怎么会成功声明了呢?

语法错误是怎么来的?

传统上,我们认为所谓“语法错误(SyntaxError)”就是在语法分析期出现的错误,也就是说,这些错误通过对代码文本的在静态语法检查就能识别出来,因此它可以在正式执行之前就被抛出(throw)。

通常情况下,这是正确的。并且,这也是JavaScript可以“阻止错误代码的执行”的原因。例如:

x = 100;
function x() {
  /5
}

如果其中的函数x()是语法错,那么整个代码块都不能执行,所以x将是undefined;如果x()不导致语法错,那么x就将是100。——因为第一行代码是可以正常执行的。当然,现实是:JavaScript选择了前一种方案,因此整个代码块都失效了(如果这是一个模块,那么该模块就不能被载入)。

但并不是所有的SyntaxError都是这样处理的,因为有些语法错误是不能通过静态语法检测来发现的。对于JavaScript来说尤其如此,因为它存在“严格/非严格”这样的模式,而这个模式是运行期决定的,不是简单的静态代码声明。

也许有人会提出异议:"use strict"是必须写在代码块的头部的,因此是可以静态语法检查的。答案未见得如此,因为至少存在两种特例,一种是引擎初始化,例如Node.js以node --use-strict参数来启动,另一种则是eval()代码块。

所以,JavaScript中的语法错误,不见得总是静态语法分析的结果。——它也有可能是在执行过程中抛出的。

“重复的变量名”是哪一种语法错?

那么就具体的情况来说,比如Elmer提出的“示例1”,其中的“变量名重复”究竟是哪一种语法错呢?静态语法检查的,还是动态执行的?

答案是:不一定。

这是JavaScript中的另一个混乱之地:引擎并不总是“完全地”按ECMAScript规范来实现。更确切地说,事实上在ECMAScript中,“变量名重复”并没有被规范成一种静态语法检查的错误,而是在作用域(环境)初始化时,由初始化过程动态发现的。——也就是说,当引擎初始化一个环境块时,如果

  • “发现了”词法声明与变量声明中存在重复项,则抛出SyntaxError异常;或者,
  • “发现了”代码在尝试声明一个变量名(或在当前环境中登记)并与一个已存在的词法声明冲突,则抛出SyntaxError异常;
  • ……

然而这其中就存在更多复杂的细节了。怎么说呢?要知道,“引擎初始化一个环境块”这件事情,其实也是有两个阶段的,第一个是该环境块在词法作用域(Scope)中被找到(也就是静态的语法分析结果),并在生成一个作用域对应的环境块时初始化该函数实例(FunctionInitialize(F, kind, ParameterList, Body, Scope));第二个是函数在执行前对函数内的所有声明做实例化(FunctionDeclarationInstantiation(F, argumentsList))。

在前一个阶段中,参数是形式参数,所以基本上就是将形式参数直接保存到函数实例的[[FormalParameters]]域中,而在后一阶段中,参数是实际参数,所以会参考之前的形参声明(F.[[FormalParameters]]),将值一一绑定到闭包(函数实例的环境/作用域)中。

同样,上述的两个阶段中对“重复的变量名”理解也是不一致的。按之前所说,在前一阶段中的名字冲突是由语法解析器来决定的,其结果影响[[FormalParameters]]是否创建;后一个阶段则由EvaluateBody这个内部过程来决定,影响的是函数体(Body)是否执行。

无论如何,这两个过程都是在执行期的,不过如果在其中发现错误,抛出的仍然是SyntaxError异常。

var重复声明的理解

在更具体地解释“示例1”的代码之前,还需要再解释一个语法现象,就是:

在JavaScript中如何理解“var变量的重复声明”?

例如:

// 示例3
function f(x, x) {
 ...
}

 // 示例4
function f() {
  var x = 1;
  var x = 2;
}

// 示例5
function f(x) {
  var x = 3;
}

在JavaScript中,var变量声明会被解析成一个名字(name),这个名字将在函数实例化时被填写到环境中。上述**“示例3”说明了JavaScript接受参数名重复,“示例4”则说明也同时接受多次var声明重复,所以事实上JavaScript在语法上是允许*varNames***或***parameterNames***之任一为重复的。

在具体处理这两种情况时,JavaScript将首先按参数名(parameterNames)来创建,因此它只需要在扫描变量名(varNames)时“忽略已创建的名字”即可。而这,就是在ECMAScript规范中,有关FunctionDeclarationInstantiation()的实现代码里有一段叙述逻辑:

  • 当函数使用简单参数时(hasParameterExpressions is false),从将参数列表直接抄写为一份“已初始化变量名列表(instantiatedVarNames)”;然后,
  • 列举所有变量名(varNames),将那些未初始化的名字(n)初始化到当前的环境记录中(envRec.InitializeBinding(n, undefined))。

亦即是说,对于上述**“示例5”**的情况,事实上var x = 3根本就没有起到“声明var”的作用,因为名字ninstantiatedVarNames中已经出现了,所以varNames中的声明就被忽略了。——同样,像示例4中那样的情况,也在处理第二个var声明时因为instantiatedVarNames中存在既有项而被忽略。

这样一来,下面的**“示例6”**——这是一种传统的为x指定缺省值的写法——才能得到合理的解释:

// 示例6
function f(x) {
  var x = x || 3;  // 这里的`var`也是被忽略的
}

答案1:在函数与全局中对var声明的不同处理

因此也就出现了下面这个问题,即:

  • 即使function f(x=1)中的x是let声明,那么也仍然需要代码中的var x = x || 3是合法的逻辑。

亦即是说,需要实现下面的代码与“示例6”在语法上的一致性:

// 示例7
function f(x=false) {
  var x = x || 3;
}

我们之前说过,示例7使用了缺省参数,因此它是“非简单参数”,并且因此参数名x将会是let声明。——而这,又与本文最开始所讲语法约定“不能声明同名的varlet”互相冲突。

为了解决这个问题,ECMAScript在这里对引擎约定了另外一个处理:

  • 如果函数参数是“非简单参数”,那么就为函数体中的所有varNames再创建一个环境块(亦即是词法作用域)

所以ECMAScript中在这里也有一段特殊处理:

  • 当函数使用非简单参数时(hasParameterExpressions),置varEnvRec为新环境,并将instantiatedVarNames置为空列表。

这样一来,“示例1”和“示例7”中的变量名声明就不会与参数名冲突了。但是需要注意的是,这里只是处理varNames的规则,而let/const声明是在lexicalNames中,所以不受这一处理逻辑的影响。例如:

// 示例8
function f(x=1) {
  let x = 100; // SyntaxError
}

而与此相关的,在全局代码中,由于varName和lexicalNames所对应的环境是同一个,所以它总是冲突的:

// 示例9(参考示例7的语义,但在全局代码中总是抛出异常)
let x = false;
var x = x || 3; // SyntaxError

答案2:在解析期与执行期对var的不同处理

“示例8”会带来另一个层面的思考:声明该函数f()时并不会导致异常,直到执行f()时,代码才会抛出SyntaxError。

// 示例8(执行期)
function f(x=1) {
  let x = 100; // SyntaxError
}
f()

然而这并不是绝对的。

因为在ECMAScript的规范中,“变量名重复”从来就不是一个静态语法分析期的错误。——所以严格按照该规范来实现的话,所有的这类SyntaxError都应当是在运行期抛出的。然而,不同的引擎在实现时,采用的语法解析引擎并不相同,这其中的差异就非常巨大。

更不幸的是:ECMAScript并没有规定“Parser怎么写”。——当然也没有规定它解析出来的AST(抽象语法树)是什么样子。

所以,这些错误究竟是在哪个阶段抛出,就变得不可知了。例如上面说Node.js在语法解析时并不会处理示例8的语法错,而TypeScript在代码转换(也就是处理语法解析而并不执行)时就会抛出错误:

> cat t.ts
// 示例8
function f(x=1) {
  let x = 100;
}

# Nothing
> node -c t.ts

# Error
> tsc --allowJs t.ts
t.ts:1:12 - error TS2300: Duplicate identifier 'x'.
...

究竟哪些解析器是在语法阶段处理这种错误的(以及对应的引擎是否延迟到运行期才抛出Syntax),可以参考:

https://astexplorer.net/

例如经典引擎esprima和flow,事实上对“示例9”的全局代码都不会解析出语法错,而@babel/parser或者acorn,连“示例8”都提前到了语法解析期出错。

小结

所以,因名字重复导致的语法错误,一方面可能是语法分析期的(这取决于Parser的实现),另一方面也可以是执行期的,并且后者总是由ECMAScript规范约定的。

仅对于ECMAScript规范来说,“(var/let/const和参数声明所致的)名字重复”总是在执行期才抛出的语法错误。并且,为了在执行期兼容旧式的函数声明与使用惯例,ECMAScript约定在“非简单参数类型”的函数内为varNames多创建了一层对应的环境,从而使var名字与参数名(即使它采用的是let风格的声明)不再冲突——这是对“示例1”的最确切的解释。

NOTE:

  • 极少数名字重复是ECMAScript约定过的静态语法错误,例如catch(x,x)以及与它的代码块中的var/let/const重名,又例如在严格模式或非简单参数模式下的函数参数名重名。

  • 示例8是一个很好的例子,它强调了某些语法错误“是只有在执行时才会抛出的”,这也说明了lint类工具的重要性:在执行前进行更严格的语法检查,从而避免引擎差异。