1.循环中的块级绑定
开发者最需要使用变量的块级作用域的场景,或许就是在 for
循环内,也就是让一次性循环计数器仅能在循环内部使用。例如,以下代码在 JS 中并不罕见:
1 | for (var i = 0; i < 10; i++) { |
在其他默认使用块级作用域的语言中,只有 for
才能访问变量 i
。然而在 JS 中,循环结束后 i
仍然可被访问,因为 var
声明导致了变量提升。
若像如下代码那样换为使用 let
,则会看到预期行为:
1 | for (let i = 0; i < 10; i++) { |
本例中的变量 i
仅在 for
循环内部可用,一旦循环结束,该变量在任意位置都不可访问。
1.1 循环内的函数
长期以来,var
的特点使得循环变量在循环作用域之外仍然可被访问,于是在循环内创建函数就变得很有问题。例如:
1 | var funcs = []; |
你原本可能预期这段代码会输出 0
到 9
的数值,但它却在同一行将数值 10
输出了十次。这是因为变量 i
在循环的每次迭代中都被共享了,意味着循环内创建的那些函数都拥有对于同一变量的引用。在循环结束后,变量 i
的值会是 10
,因此当 console.log(i)
被调用时,每次都打印出 10
。
var
声明的变量 i
在全局范围内都有效,因此全局中只会有一个变量 i
,每一次循环,变量 i
的值都会发生变化,而循环内被赋给数组的函数内部的 i
是指向全局的 i
,也就是说,所有数组成员中的 i
都指向同一个全局环境中的 i
。
为了修正此问题,开发者在循环内使用立即调用函数表达式(IIFEs),以便在每次迭代中强制创建变量的一个新副本,例如:
1 | var funcs = []; |
这种写法在循环内使用了 IIFE。变量 i
被传递给 IIFE,从而创建了 value
变量作为自身副本并将值存储于其中。value
变量的值被迭代中的函数所使用,因此在循环从 0
到 9
的过程中调用每个函数都返回了预期的值。
幸运的是,使用 let
与 const
的块级绑定可以在 ES6 中简化这个循环。
1.2 循环内的 let 声明
let
声明通过有效模仿上例中 IIFE 的作用而简化了循环。在每次迭代中,都会创建一个新的 同名变量并对其进行初始化。这意味着可以完全省略 IIFE 而获得预期的结果,例如:
1 | var funcs = []; |
与使用 var
声明以及 IIFE 相比,这里的代码能达到相同的结果,但无疑更加简洁。在循环中,let
声明每次都创建了一个新的 i
变量,因此在循环内部创建的函数获得了各自的 i
副本,而每个 i
副本的值都在每次循环迭代声明变量的时候被确定了。
for
循环中由 let
声明的变量 i
只在本轮循环有效,每一次循环的 i
其实都是一个新的变量。在 for
循环中,JavaScript 引擎内部会记住上一次循环的值,初始化本轮的变量 i
时,就是在上一轮循环的基础上进行计算。另外,在 for
循环中,设置循环变量的那一部分是一个父作用域,而循环体内部是一个单独的子作用域。
这种方式在 for-in
和 for-of
循环中同样适用,例如:
1 | var funcs = [], |
本例中的 for-in
循环体现出了与 for
循环相同的行为。每次循环,一个新的 key
变量绑定就被创建,因此每个函数都能够拥有它自身的 key
变量副本,结果每个函数都输出了一个不同的值。而如果使用 var
来声明 key
,则所有函数都只会输出 "c"
。
let
声明在循环内部的行为是在规范中特别定义的,而与不提升变量声明的特征没有必然联系。事实上,早期 let
的实现中并没有这种行为,它是后来才添加的。
1.3 循环内的常量声明
ES6 规范没有明确禁止在循环中使用 const
声明,然而它会根据循环方式的不同而有不同行为。
在常规 for
循环中,可以在初始化时使用 const
,但循环会在你试图改变该变量的值时抛出错误,例如:
1 | var funcs = []; |
在此代码中,i
被声明为一个常量。循环的第一次迭代成功执行,此时 i
的值为 0
.在 i++
执行时,一个错误会被抛出,因为该语句试图修改常量的值。因此,在循环中只能使用 const
来声明一个不会被更改的变量。
另一方面,const
变量在 for-in
或 for-of
循环中使用时,与 let
变量效果相同,因此下面的代码不会导致错误:
1 | var funcs = [], |
这里与 let
声明的唯一区别是,key
的值在循环内不能被更改。const
能够在 for-in
与 for-of
循环内工作,是因为循环为每次迭代创建了一个新的变量绑定,而不是试图去修改已绑定的变量的值。
2.全局块级绑定
let
与 const
不同于 var
的另一个方面是在全局作用域上的表现。
当在全局作用域上使用 var
时,它会创建一个新的全局变量,并成为全局对象(浏览器中的 window
,nodejs 中的 global
)的一个属性。这意味着使用 var
可能会无意覆盖一个已有的全局属性,例如:
1 | // 浏览器中 |
尽管全局的 RegExp
是定义在 window
上的,它仍然不能防止被 var
重写。
这个例子声明了一个新的全局变量 RegExp
而覆盖了原有对象。类似的,ncz定义为全局变量后就立即成为了 window
的一个属性。这就是JS通常的工作方式。
然而若是在全局作用域上使用 let
或 const
,虽然在全局作用域上会创建新的绑定,但 不会有任何属性被添加到全局对象上。这意味着不能使用 let
或 const
来覆盖一个全局变量,而只能将其屏蔽,例如:
1 | // 在浏览器中 |
此代码的 let
声明创建了 RegExp
的一个绑定,并屏蔽了全局的 RegExp
。这表示 window.RegExp
与 RegExp
是不同的,因此全局作用域没有被污染。同样,const
声明创建了 ncz
的一个绑定,但并未在全局对象上创建属性。
当你不想在全局对象上创建属性时,这种特性会让 let
与 const
在全局作用域中更安全。
若想让代码能从全局对象中被访问,仍然需要使用 var
。在浏览器中跨越帧或窗口去访问代码时,这种做法非常普遍。
3.最佳实践
在 ES6 的发展阶段,被广泛认可的变量声明方式时:默认情况下应该使用 let
而不是 var
。对于多数 JS 开发者来说,let
的行为方式正式 var
本应有的方式,因此直接使用 let
替代 var
更符合逻辑。此时,应当对需要受到保护的变量使用 const
。
然而随着更多开发者迁移到 ES6 上,一种替代方案变得更为流行,那就是默认情况下使用 const
,并且只在知道变量值需要被更改的情况下才使用 let
。
其理论依据是大部分变量在初始化后都不应该被修改,因为预期外的改动是 BUG 的源头之一。