Javascript变量声明
Javascript社区为了和其它语言保持统一,使用了let
作为新的变量声明关键字。到ECMAScript 2017为止,Javascript中一共有6种声明变量的关键字:var
, let
, const
, function
, class
, import
。本文旨在记录在学习ES6第一部分–变量声明中的一些疑惑。
变量提升(variable/function hoisting)
我们知道var
和function
都存在变量提升,function
不仅仅是声明提升,而是整个函数定义都提升,即你可以在函数声明之前正确调用该函数:
|
|
而let
, const
却不会出现类似的情况:
|
|
那么这样声明的变量到底提升没有?
let
const
变量“提升”
let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated.
这些变量在其所在的词法环境初始化的时候就被创建,但是需要等到执行完变量绑定后才能被访问。
可以看到,规格并没有直接使用”hoist”这个词,但是这些变量同样也是在块级作用域开始初始化的时候就被创建了(存在在TDZ中)。这种行为也可以说是“提升”,但是是字面意义上的提升(literally),而不是我们约定的“变量提升”。
Temporary Dead Zone(临时死区)
如上面引用里提到的,这些变量虽然被创建了,但是状态是为初始化且不可访问,直到这个变量的绑定完成为止。
|
|
我们再来明确一下TDZ结束的时间点:
|
|
这条语句同样会报错,因为右边x
的访问发生在绑定的同时,而没有等到绑定完全结束,所以TDZ还没有结束。
函数默认参数中的”TDZ”
很容文章和书中都提到函数默认参数中也有“TDZ”,这里对于初学ES6的人(我),一提到TDZ就联想到let
和const
,所以会让人误解,以为参数也是通过let
方式来声明的,这样下面的代码就变得不可理解:
|
|
如果参数真的是用let
声明的话,其等同于:
|
|
应该会报重声明的错才对。要解释这个问题,我们需要深入规格,来探究JS在初始化函数对象的时候到底做了什么。
如上面这段代码,当调用func()
语句时,引擎进入函数调用流程(详见规格9.2.1 [[Call]] ( thisArgument, argumentsList)),即调用函数对象内部的[[Call]]
方法。然后在第7步,调用OrdinaryCallEvaluateBody时,进入函数声明实例化过程(FunctionDeclarationInstantiation)。
(详细的算法步骤可以看这篇文章里的中文翻译)
我们以下面这段代码为例,来讲解具体步骤:
|
|
9.2.12 FunctionDeclarationInstantiation (func, argumentsList)
When an execution context is established for evaluating an ECMAScript function a new function Environment Record is created and bindings for each formal parameter are instantiated in that Environment Record. Each declaration in the function body is also instantiated. If the function’s formal parameters do not include any default value initializers then the body declarations are instantiated in the same Environment Record as the parameters. If default value parameter initializers exist, a second Environment Record is created for the body declarations. Formal parameters and functions are initialized as part of FunctionDeclarationInstantiation. All other bindings are initialized during evaluation of the function body.
当参数存在默认值得时候,在这个函数的执行上下文里,会创建两个环境记录(一个是参数体的–envRecParam,一个是函数体的envRecBody)。
在原算法第21步,开始函数参数的绑定:
- for each paramName in parameterNames:
- Perform envRecParam.CreateMutableBinding(paramName, false);
那么现在envRecParam存在了两个变量(x, y),但并没有初始化(created but uninitialized)。然后在第23和25步,处理argumentsList并开始IteratorBindingInitialization遍历绑定。
这里的BindingItem是一个SingleNameBinding,可以看到在IteratorBindingInitialization算法的第5步里,对右值(这里是y)进行求值,而y还处在未初始化状态,则报出ReferenceError.
(规格中有很多个IteratorBindingInitialization,第一次我看的是Function Definitions里的定义,完全一头雾水。但其实规格把函数的参数都当做数组结构模板来解析,可以看到kangax大神当初也是和我一样摸不着头脑啊~)
其实原理是和let
是一致的,但不能简单地理解参数就是用let
来声明的,比如重复的参数名在非strict模式下是被允许的。
再探function参数
function中参数的作用域
在函数默认参数的”TDZ”一节,提到了当函数初始化的时候会为函数创建一个子作用域用于绑定参数,如果函数参数带有默认值,则会再创建一个环境记录用于绑定函数体内的参数。我们还是通过几个例子来测试一下,这两个作用域(环境记录)之间的关系。
|
|
通过上面这个例子,可以得出参数作用域和函数体作用域都是外层作用域的子作用域,在两个作用域内都可以访问到外层变量,而且可以使用let
来覆盖外层变量。
函数体内显然是可以访问到参数变量的,但是并不可以使用let
去定义一个重名变量,所以函数体作用域并不是参数作用域的子作用域,那么这两个作用域之间到底是什么关系呢?
|
|
参数作用域在这里被称作是一个中间作用域,在函数体作用域中可以访问和修改参数作用域中的变量,但是不可以重声明(除了var),而这么做的原因,规格里也说明了:
NOTE: A separate Environment Record is needed to ensure that closures created by expressions in the formal parameter list do not have visibility of declarations in the function body.
因为ES6中,参数默认值可以是一个函数,所以需要把参数限定在一个作用域中来保证参数内闭包对函数体内变量无访问权限。
|
|
function中变量提升吗?
|
|
FunctionDeclarationInstantiation里27.f.i.5 ~ 27.f.i.6阐述了原因:设置函数体内和参数重名的var变量的初始值为该参数的默认值,即上面的函数实际上应该解释为:
|
|
for中的蹊跷
for循环的作用域
直接上代码:
|
|
可以看到for中的第一个表达式是在外层作用域中,而循环体是在一个子作用域中。
for循环中的let
每当一本JS书籍要讲解闭包时,总会出现下面的例子:
|
|
上面的代码显然没有达到我们的意图,而利用闭包则可以正确的完成这个逻辑。但是在let
出现后,我们不需要闭包同样能完成相同的工作:
|
|
那么这里用var
和let
的区别在哪里呢?
(详细的规格算法在CreatePerIterationEnvironment描述)
原因是,当你在for循环语句使用let
或const
,引擎会为每一次循环创建一个新的词法环境。在这个环境里,会重新建立一个绑定,然后用上一个环境的值来作为初始化值,见:Explanation of let
and block scoping with for loops。所以即使你使用的是const
,它做的并不是简单的赋值(给已存在的绑定赋值)所以不会报错。