Writing

Javascript变量声明

Javascript社区为了和其它语言保持统一,使用了let作为新的变量声明关键字。到ECMAScript 2017为止,Javascript中一共有6种声明变量的关键字:var, let, const, function, class, import。本文旨在记录在学习ES6第一部分–变量声明中的一些疑惑。

变量提升(variable/function hoisting)

我们知道varfunction都存在变量提升,function不仅仅是声明提升,而是整个函数定义都提升,即你可以在函数声明之前正确调用该函数:

1
2
3
4
5
console.log(x); // undefined
var x = 1;
f(1); // f不是undefined,这里可以正确调用,输出1
function f(a) {console.log(a)};

let, const却不会出现类似的情况:

1
2
3
console.log(x); // ReferenceError
let x = 1;
// const x = 1;

那么这样声明的变量到底提升没有

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(临时死区)

如上面引用里提到的,这些变量虽然被创建了,但是状态是为初始化且不可访问,直到这个变量的绑定完成为止。

1
2
3
4
5
6
function func() { // ------ 从这里开始
var x = 1;
let x = 2; // ------ 到这一行为止为TDZ
}
func(); // SyntaxError: Identifier 'x' has already been declared

我们再来明确一下TDZ结束的时间点:

1
let x = x; // SyntaxError: Identifier 'x' has already been declared

这条语句同样会报错,因为右边x的访问发生在绑定的同时,而没有等到绑定完全结束,所以TDZ还没有结束。

函数默认参数中的”TDZ”

很容文章和中都提到函数默认参数中也有“TDZ”,这里对于初学ES6的人(我),一提到TDZ就联想到letconst,所以会让人误解,以为参数也是通过let方式来声明的,这样下面的代码就变得不可理解:

1
2
3
function func(arg = 2) {
var arg = 1;
}

如果参数真的是用let声明的话,其等同于:

1
2
let arg = 2;
var arg = 1;

应该会报重声明的错才对。要解释这个问题,我们需要深入规格,来探究JS在初始化函数对象的时候到底做了什么。

如上面这段代码,当调用func()语句时,引擎进入函数调用流程(详见规格9.2.1 [[Call]] ( thisArgument, argumentsList)),即调用函数对象内部的[[Call]]方法。然后在第7步,调用OrdinaryCallEvaluateBody时,进入函数声明实例化过程(FunctionDeclarationInstantiation)。

(详细的算法步骤可以看这篇文章里的中文翻译)

我们以下面这段代码为例,来讲解具体步骤:

1
2
3
function func(x = y, y = 1) {
return ;
}

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步,开始函数参数的绑定:

那么现在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”一节,提到了当函数初始化的时候会为函数创建一个子作用域用于绑定参数,如果函数参数带有默认值,则会再创建一个环境记录用于绑定函数体内的参数。我们还是通过几个例子来测试一下,这两个作用域(环境记录)之间的关系。

1
2
3
4
5
6
7
8
9
let a = 1;
let b = 2;
function func(x = a) {
let b = 3;
console.log(x);
console.log(b);
}
func(); //1 3

通过上面这个例子,可以得出参数作用域和函数体作用域都是外层作用域的子作用域,在两个作用域内都可以访问到外层变量,而且可以使用let来覆盖外层变量。

函数体内显然是可以访问到参数变量的,但是并不可以使用let去定义一个重名变量,所以函数体作用域并不是参数作用域的子作用域,那么这两个作用域之间到底是什么关系呢?

funtion_scope

1
2
3
4
5
6
7
8
9
let y =1;
function foo(x = function(){console.log(y)},y=2) {
x(); // 2
var y = 3; // if use let, then throw error
console.log(y); // 3
x(); // 2
}
foo();
console.log(y); // 1

参数作用域在这里被称作是一个中间作用域,在函数体作用域中可以访问和修改参数作用域中的变量,但是不可以重声明(除了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中,参数默认值可以是一个函数,所以需要把参数限定在一个作用域中来保证参数内闭包对函数体内变量无访问权限。

1
2
3
4
5
6
7
8
var x = 1;
function foo(y = function() { return x; }) { // capture `x`
var x = 2;
return y();
}
foo(); // correctly 1, not 2

function中变量提升吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
function func(x = 1) {
console.log(x);
var x = 2;
}
// 变量提升的话:
function func(x = 1) {
var x;
console.log(x);
x = 2;
}
func(); // 这里输出的是1 为什么不是undefined呢?

FunctionDeclarationInstantiation里27.f.i.5 ~ 27.f.i.6阐述了原因:设置函数体内和参数重名的var变量的初始值为该参数的默认值,即上面的函数实际上应该解释为:

1
2
3
4
5
function func(x = 1) {
var x = 1; // 同名变量, 初始化为参数默认值
console.log(x);
x = 2;
}

for中的蹊跷

for循环的作用域

直接上代码:

1
2
3
4
5
6
7
8
9
10
11
let i = 1;
for(var i = 2; i < 5; i++){ // SyntaxError: Identifier 'i' has already been declared
console.log(i);
}
for(let j = 0; j < 3; j++) {
let j = 'abc';
console.log(j);
}
// 输出3个abc

可以看到for中的第一个表达式是在外层作用域中,而循环体是在一个子作用域中。

for循环中的let

每当一本JS书籍要讲解闭包时,总会出现下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var arr = [];
for(var i = 0; i < 3; i++) {
arr[i] = function () {
console.log(i);
}
}
arr[0](); // 2
var arr = [];
for(var i = 0; i < 3; i++) {
arr[i] = function (num) {
return function () {
console.log(num);
}
}(i);
}
arr[0](); // 0

上面的代码显然没有达到我们的意图,而利用闭包则可以正确的完成这个逻辑。但是在let出现后,我们不需要闭包同样能完成相同的工作:

1
2
3
4
5
6
7
8
9
var arr = [];
for(let i = 0; i < 3; i++) {
arr[i] = function () {
console.log(i);
}
}
arr[0](); // 0
arr[2](); // 2

那么这里用varlet的区别在哪里呢?

(详细的规格算法在CreatePerIterationEnvironment描述)

原因是,当你在for循环语句使用letconst,引擎会为每一次循环创建一个新的词法环境。在这个环境里,会重新建立一个绑定,然后用上一个环境的值来作为初始化值,见:Explanation of let and block scoping with for loops。所以即使你使用的是const,它做的并不是简单的赋值(给已存在的绑定赋值)所以不会报错。