《理解 ECMAScript 6》第一章:块绑定
以前,变量声明方式是JavaScript编程中一个令人困惑的部分。在大部分类C语言里,变量(或者说作用域)在声明的地方生成。但是在JavaScript中,情况就不是这样了。变量实际上创建的地方取决于你声明它的方式,而ECMAScript 6提供了一些可选的方式来让你更容易地控制作用域。本章将会告诉你为什么经典的var
声明会令人感到困惑,并且会介绍ECMAScript 6中的块级作用域,然后会给出如何使用它们的最佳实践。
1.1 变量声明和提升
使用var
的变量声明,会被当做处于当前所在函数的顶部(如果在函数外声明的话则是全局作用域)中的声明那样处理———无论它实际的声明位置在哪,这被称作提升。为了展示提升做了什么,看看下面的函数定义:
function getValue(condition) {
if (condition) {
var value = "blue";
// 其他代码
return value;
} else {
// 这里value的值是undefined
return null;
}
// 这里value的值是undefined
}
如果你对JavaScript不熟悉,你可能会觉得变量value
只有当condition
的值为真的时候才会被创建。实际上,变量value
无论如何都会被创建。在背地里,JavaScript引擎会将getValue
函数变换成类似这种形式:
function getValue(condition) {
var value;
if (condition) {
value = "blue";
// 其他代码
return value;
} else {
return null;
}
}
value
的声明被提升到了顶部,而值的初始化依然留在了原来的地方。这意味着变量value
实际上在else
语句中依然可以被访问到,而如果在那里被访问,变量只会拥有一个undefined
的值,因为它没有被初始化。
通常需要一些时间来让新的JavaScript开发者习惯于声明提升。误解了JavaScript中这个独特的行为常常会造成一些bug,因此,ECMAScript 6提供了一个块级作用域的可选方式来更好地控制一个变量的生命周期。
1.2 块级声明
块级声明是那些变量在给定块作用域外不能访问的声明方式。块级作用域,也叫词法作用域,被创建于:
- 一个函数内部
- 一个块内部 (通过
{
和}
字符来标识)
块级作用域是大量类C语言的工作方式,ECMAScript 6块级声明的引入是为了把同样的灵活性(和一致性)带入到JavaScript中。
1.2.1 let声明
let
声明语法和var
的语法相同。你可以简单地把var
替换成let
来声明一个变量,这样做的话就会把这个变量的作用域限定在当前代码块里(这里有一些其他的细微差别,之后将会讨论)。
因为let
声明不会被提升到当前块的顶部,你可能总是想要把let
声明放在块最开始的地方,这样的话她们就可以在整个块中能够访问了。这里有个例子:
function getValue(condition) {
if (condition) {
let value = "blue";
// 其他代码
return value;
} else {
// 这里不存在变量value
return null;
}
// 这里不存在变量value
}
这个版本的getValue
函数的行为更接近于你在其他类C语言中所期望的样子。由于变量value
用let
来替代var
进行声明,声明并没有被提升到函数定义的头部,并且一旦执行流到了if
块的外面,变量value
便无法访问到。如果condition
的值为false
的话,变量value
就永远不会被声明或者初始化。
1.2.2 没有重复声明
如果一个标识符已经在一个作用域中定义,再在那个作用域中使用let
声明同一个标识符就会导致抛出错误。比如说:
var count = 30;
// 错误的语法
let count = 40;
在这个例子中,count
被声明了两次:一次使用var
,一次使用let
。因为let
永远不会重复定义一个已经存在在相同作用域的标识符,所以let
声明会抛出一个错误。
var count = 30;
// 没有抛出错误
if (condition) {
let count = 40;
// 更多的代码
}
这里let
声明不会抛出错误,因为它在if
语句里创建了一个叫做count
的新变量,而不是在被包含块中创建的。在if
块里面,这个新的变量隐藏了全局的count
变量,阻止了在块内对它(全局变量)的访问。
1.2.3 常量声明
在ECMAScript 6里你也可以使用const
声明语法来定义变量。使用const
来声明的变量会被认为是一个常量,意味着一旦被设置了,他们的值无法再改变。因此,每一个const
变量必须要在声明 的时候初始化,正如下面例子那样:
// 合法的常量
const maxItems = 30;
// 语法错误:未初始化
const name;
变量maxItems
被初始化了,因此它的const
声明能够正常运行。不过变量name
则会在运行中抛出语法错误,因为name
未被初始化。
const声明和let声明
常数声明(即用const
声明的变量)就像let
声明一样,是一种块级的声明。这意味着一旦执行流到了块的外面,这些常数便无法再被访问,并且声明也不会被提升,就像下面这个例子中所展现的一样:
if (condition) {
const maxItems = 5;
// 更多的代码
}
// maxItems 无法在这里被访问到
在这段代码里,常量maxItems
在if
语句中声明,一旦语句执行完毕,maxItems
在块外便无法访问。
另外一个和let
类似的点是,如果使用const
定义一个已经在当前作用域定义的变量就会致使系统抛出错误——无论那个变量是使用var
(在全局或者函数作用域中)定义还是使用let
(在块作用域中)定义。比如说,考虑下面的代码:
var message = "Hello!";
let age = 25;
// 下面的两条语句都会抛出错误
const message = "Goodbye!";
const age = 30;
这两条const
声明单独存在的话都是合法的,不过在前面已经使用var
和let
声明的情况下,它们都不会如你所期望那样执行。
尽管在let
和const
之间有这么多的相似点,他们之间还是有一个很大的不同。无论在严格模式下还是非严格模式下,尝试去分配const
到一个已定义的常量会抛出错误:
const maxItems = 5;
maxItems = 6; // 抛出错误
类似其他语言中的常数,变量maxItems
不能再被分配一个新的值。不过,有一点不像其他语言中的常量,如果这个常量是一个对象的话,它是可以被改变的。
使用const声明对象
const
声明阻止了绑定的改变,而不是值本身的改变。这意味着使用const
声明的对象不会阻止这些对象的改变。
const person = {
name: "Nicholas"
};
// 正常运行
person.name = "Greg";
// 抛出错误
person = {
name: "Greg"
};
这里,person
由一个带有一个属性的对象初始值所创建。改变person.name
的值并不会导致错误,因为这只改变了person
包含的东西,而没有改变person
本身的绑定。当代码尝试去给person
分配一个新值的时候(因此也就尝试去改变绑定本身),系统就会抛出一个错误。const
配合对象的工作方式中这个细微的点很容易遭到误解。只需记住,const
只阻止了绑定本身的改变,而没有阻止已绑定的值的改变。
1.2.4 暂时性死区
一个使用let
或const
声明的变量只有在声明之后才能被访问到。如果尝试在声明之前访问的话就会导致一个引用错误(reference error)——即使使用一般来说较为安全的操作符如下面例子中的typeof
操作符:
if (condition) {
console.log(typeof value); // ReferenceError!
let value = "blue";
}
这个例子中,变量value
使用let
来定义和初始化,但是这条语句永远不会被执行,因为前一行抛出了一个错误。它的问题就是value
存在于这个被JavaScript社区称作暂时性死区(temporal dead zone, TDZ)的地方。TDZ从未在ECMAScript标准中明确命名,不过它常常用来解释用let
和const
声明的变量在它们的声明之前无法被访问的现象。这块内容包含了由于TDZ的存在而造成的关于声明位置的一些细节,这里的例子中都是以let
作为示范,不过这些也同样适用于const
。
当JavaScript引擎执行到一个块中,检测到一个变量声明时,它可能把这个声明提升到函数的顶部,或者全局作用域中(对var
),也可能把声明放到TDZ中(对let
和const
)。任何想要尝试访问一个处于TDZ中的变量的行为都会导致运行错误。只有当执行流到了变量声明的地方的时候,这个变量才会从TDZ中移除,这样才能安全地使用。
当你尝试去使用一个用let
或者const
声明的变量的时候,在声明前是不能够访问的。正如前一个例子中所展示的那样,这条规则甚至对与通常上来说安全的typeof
操作符也是适用的。不过,你可以在变量声明的块的外面使用typeof
——虽然这么做不会得到你想要的结果。看看下面的代码:
console.log(typeof value); // "undefined"
if (condition) {
let value = "blue";
}
当typeof
操作符执行的时候变量value
不在TDZ中,因为它存在于value
声明的块的外面。这意味着这里没有value
的任何绑定,typeof
也就简单地返回"undefined"
TDZ只是块绑定中一个特殊的方面。另外一个特殊的方面就是它们在循环中的使用方式。
1.3 循环中的块绑定
或许开发者们最想要变量块级作用域的地方就是在for
循环中了,这意味着一次性使用的计数变量只能在循环中使用。举个例子,在JavaScript中这样的代码十分常见:
for (var i = 0; i < 10; i++) {
process(items[i]);
}
// i在这里依然可以被访问到
console.log(i); // 10
在其他语言中,块级作用域默认存在,这个例子会如所期望那样执行,即变量i只有在循环中才能被访问到。然而在JavaScript中,变量i在循环结束后依然可以被访问到,因为var
声明被提升了。如果使用let
来替代它,如下面代码中那样,就能得到预想那样的行为。
for (let i = 0; i < 10; i++) {
process(items[i]);
}
// i在这里不能被访问 - 抛出了一个错误
console.log(i);
在这个例子中,变量i只是存在于for
循环内部。一旦循环结束,这个变量再也不能在其他位置访问到了。
1.3.1 循环中的函数
var
的特性使得在循环中创建函数变得十分困难,因为循环中的变量在循环的范围的外面依然可以被访问到。看看下面的代码:
var funcs = [];
for (var i = 0; i < 10; i++) {
funcs.push(function() { console.log(i); });
}
funcs.forEach(function(func) {
func(); // 输出了数字“10”十次
});
你或许会认为这段代码会打印出数字0到9,但是实际上它在一行中输出了数字10十次。这是因为i
在循环的每次迭代中被共用,这意味着在循环中创建的所有函数都拥有对同一个变量的引用。变量i
在循环结束的时候的值为10
,因此当console.log(i)
被调用的时候,每次都会打印同一个值。
为了解决这个问题,开发者们在循环中使用了立即处理函数表达式(IIFEs)来在每次迭代的时候强制创建变量的一个新的副本,正如下面例子那样:
var funcs = [];
for (var i = 0; i < 10; i++) {
funcs.push((function(value) {
return function() {
console.log(value);
}
}(i)));
}
funcs.forEach(function(func) {
func(); // 输出 0, 然后 1, 然后 2, 直到 9
});
这个版本在循环中使用了IIFE。变量i
传到IIFE里,在IIFE中创建了它值的副本并且作为value
保存下来。这是在当次迭代中的函数所使用的值,因此在循环从0增加到9的时候,调用每个函数都会返回所期望的值。幸运的是,ECMAScript 6中使用let
和const
的块级绑定可以为你简化这种循环。
1.3.2 循环中的let声明
let
声明通过有效地模仿前面例子中IIFE的行为来简化了循环。在每次的迭代中,循环会创建一个新的变量并且用和上一个迭代中同样的变量值和变量名来初始化它。这意味着你可以抛弃IIFE并得到你所期望的结果,就像这样:
var funcs = [];
for (let i = 0; i < 10; i++) {
funcs.push(function() {
console.log(i);
});
}
funcs.forEach(function(func) {
func(); // 输出 0, 然后 1, 然后 2, 直到 9
})
这个循环工作起来像极了使用了var
和IIFE的循环,不过,看得出来,这更加简洁。在每次循环中let
声明创建了一个新的i
变量,这使得在循环中创建的每一个函数都获得了它自己对于i
的副本。每一个i
的副本都拥有在每个循环迭代的开始它被创建的地方所分配的值。这在for-in
和for-of
循环中都是适用的,就像这里所展示的那样:
var funcs = [],
object = {
a: true,
b: true,
c: true
};
for (let key in object) {
funcs.push(function() {
console.log(key);
});
}
funcs.forEach(function(func) {
func(); // 输出了 "a", 接着 "b", 接着 "c"
});
在这个例子中,for-in
循环表现出了和for
循环中相同的行为。每次循环的时候,会创建一个新的key
绑定,并且每个函数都拥有它自己的key
变量的副本。如果使用var
来声明key
,所有的函数都会输出"c"
。
let
声明在循环中的行为是规定中一个特殊的行为而不是和let
不被提升的特性有关,理解这点很重要。实际上,早期对let
的实现并没有包含这个行为,直到后来才被添加。
1.3.3 循环中的const声明
ECMAScript 6 的说明中并没有明确不允许在循环中使用const
声明。不过,基于这个类型的行为将会和你在循环中使用的有所不同。对于一个普通的for
循环,你可以使用const
来初始化,不过如果你尝试去改变它的值的话,循环会抛出一个警告。比如说:
var funcs = [];
// 在一次迭代后抛出错误
for (const i = 0; i < 10; i++) {
funcs.push(function() {
console.log(i);
});
}
在这段代码中,变量i
作为一个常量被声明。循环的第一次迭代中,i
的值为0,执行成功。当i++
执行的时候,就抛出了一个错误,因为尝试去修改一个常量的值。因此,你只能在循环的初始化中使用const
声明一个不会修改的变量。
另一方面,当在for-in
和for-of
循环中使用的时候,一个const
变量的表现和let
变量一致。因此下面的例子不会造成错误:
var funcs = [],
object = {
a: true,
b: true,
c: true
};
// 不会造成错误!
for (const key in object) {
funcs.push(function() {
console.log(key);
});
}
funcs.forEach(function(func) {
func(); // 输出 "a", 接着 "b", 接着 "c"
});
这段代码几乎和“循环中的let声明”节中第二个例子的表现一致。唯一的不同就是key
的值在循环中不能改变。for-in
与for-of
和const
之所以能够良好地工作是因为循环初始化器在每次迭代的时候创建一个新的变量,而不是尝试修改已存在变量的值(和前面例子中使用for
而不是for-in
的情况一样)。
1.4 全局块绑定
let
和const
另外一个不同于var
的地方是他们在全局作用域的表现。当var
在全局作用域中使用时,它会创建一个全局变量,也是全局对象上的属性(浏览器中为window
对象)。这意味着你使用var
可能偶然地覆写一个已经存在的全局变量,比如:
// 在浏览器中
var RegExp = "Hello!";
console.log(window.RegExp); // "Hello!"
var ncz = "Hi!";
console.log(window.ncz); // "Hi!"
即使全局对象RegExp
在window
中已经定义,它由于会被var
声明给覆写而不安全。这个例子声明了一个新的全局变量RegExp
,它覆写了初始的全局变量。类似的,ncz
也被定义为一个全局变量,并且立即定义了一个window
上的属性。这是JavaScript一贯工作的方式。
如果你替代性地在全局作用域中使用let
或者const
,在全局作用域中会创建一个新的变量绑定,但是不会给全局对象上添加属性。这也意味着你不能通过let
或者const
来覆写一个全局变量——你只能隐藏它。这里有个例子:
// 在浏览器中
let RegExp = "Hello!";
console.log(RegExp); // "Hello!"
console.log(window.RegExp === RegExp); // false
const ncz = "Hi!";
console.log(ncz); // "Hi!"
console.log("ncz" in window); // false
这里,对RegExp
的let
声明创建了一个变量绑定,它隐藏了全局的RegExp
。这意味着window.RegExp
和RegExp
不相等,并且这对全局作用域不会造成任何问题。同样的,对ncz
的const
声明创建了一个变量绑定,也同样不会创建一个全局对象上的属性。这个特性使得在全局作用域中使用let
和const
更加安全——如果你不想给全局对象添加属性的话。
如果你想要一段代码在全局对象上可访问,你可能依然想要在全局作用域中使用var
。如果你想要跨框架或窗口访问代码的话,这种情况就挺常见了。
1.5 块绑定的最佳实践
在ECMAScript发展的过程中,有一个广为传播的思想,就是你应该用let
来取代var
作为默认的变量声明方式。对很多JavaScript开发者来说,let
的表现正如他们期望var
所应该的那样,因此这种直接的替换符合逻辑上的感觉。这时,你需要对那些对修改保护有需求的变量使用const
。
不过,随着更多的开发者转向ECMAScript 6,一个可选的方式受到了欢迎:默认使用const
,只有当你知道一个变量的值会被改变的时候使用let
。根本原因是大部分变量在初始化后不会被改变,而出乎意料的值的改变是bug之源。这个观点有大量的支持者,如果你采用ECMAScript 6的话,这个观点值得你在代码中践行。
1.6 小结
let
和const
的块变量绑定把块级词法作用域引入到了JavaScript。这些变量不会被提升并且只存在于它们声明处所在的块中。它提供了更加类似其他语言的表现,因为变量现在可以在它们需要的地方声明,造成出乎意料的错误的可能性也小了。不过有一个副作用,就是你不能再在变量声明之前访问到这个变量了,使用类似typeof
这样安全的操作符也不行。尝试去在块绑定变量声明前访问它会导致错误,因为绑定变量存在于暂时性死区(TDZ)中。
在很多情况下,let
和const
和var
的表现很近似,不过,在循环上有所不同。无论是对let
还是const
,for-in
和for-of
循环会在每次循环迭代的时候创建一个新的绑定变量。这意味着在循环体中创建的函数可以访问到当前迭代中循环绑定变量的值,而不是访问到在循环结束后的值(var
的行为)。同样,对在for
循环中的let
声明也是如此。如果尝试在for
循环中使用const
声明,可能会致使错误。
现阶段对块变量绑定的最佳实践是默认使用const
,只有当你知道一个变量的值需要改变的时候才去使用let
。这保证了代码最低限度的改变,而这对阻止特定类型错误有所帮助。