块儿作用域声明

你可能知道在JavaScript中变量作用域的基本单位总是function。如果你需要创建一个作用域的块儿,除了普通的函数声明以外最流行的方法就是使用立即被调用的函数表达式(IIFE)。例如:

  1. var a = 2;
  2. (function IIFE(){
  3. var a = 3;
  4. console.log( a ); // 3
  5. })();
  6. console.log( a ); // 2

let声明

但是,现在我们可以创建绑定到任意的块儿上的声明了,它(勿庸置疑地)称为 块儿作用域。这意味着一对{ .. }就是我们用来创建一个作用域所需要的全部。var总是声明附着在外围函数(或者全局,如果在顶层的话)上的变量,取而代之的是,使用let

  1. var a = 2;
  2. {
  3. let a = 3;
  4. console.log( a ); // 3
  5. }
  6. console.log( a ); // 2

迄今为止,在JS中使用独立的{ .. }块儿不是很常见,也不是惯用模式,但它总是合法的。而且那些来自拥有 块儿作用域 的语言的开发者将很容易认出这种模式。

我相信使用一个专门的{ .. }块儿是创建块儿作用域变量的最佳方法。但是,你应该总是将let声明放在块儿的最顶端。如果你有多于一个的声明,我推荐只使用一个let

从文体上说,我甚至喜欢将let放在与开放的{的同一行中,以便更清楚地表示这个块儿的目的仅仅是为了这些变量声明作用域。

  1. { let a = 2, b, c;
  2. // ..
  3. }

它现在看起来很奇怪,而且不大可能与其他大多数ES6文献中推荐的文法吻合。但我的疯狂是有原因的。

这是另一种实验性的(不是标准化的)let声明形式,称为let块儿,看起来就像这样:

  1. let (a = 2, b, c) {
  2. // ..
  3. }

我称这种形式为 明确的 块儿作用域,而与var相似的let声明形式更像是 隐含的,因为它在某种意义上劫持了它所处的{ .. }。一般来说开发者们认为 明确的 机制要比 隐含的 机制更好一些,我主张这种情况就是这样的情况之一。

如果你比较前面两个形式的代码段,它们非常相似,而且我个人认为两种形式都有资格在文体上称为 明确的 块儿作用域。不幸的是,两者中最 明确的 let (..) { .. }形式没有被ES6所采用。它可能会在后ES6时代被重新提起,但我想目前为止前者是我们的最佳选择。

为了增强对let ..声明的 隐含 性质的理解,考虑一下这些用法:

  1. let a = 2;
  2. if (a > 1) {
  3. let b = a * 3;
  4. console.log( b ); // 6
  5. for (let i = a; i <= b; i++) {
  6. let j = i + 10;
  7. console.log( j );
  8. }
  9. // 12 13 14 15 16
  10. let c = a + b;
  11. console.log( c ); // 8
  12. }

不要回头去看这个代码段,小测验:哪些变量仅存在于if语句内部?哪些变量仅存在于for循环内部?

答案:if语句包含块儿作用域变量bc,而for循环包含块儿作用域变量ij

你有任何迟疑吗?i没有被加入外围的if语句的作用域让你惊讶吗?思维上的停顿和疑问 —— 我称之为“思维税” —— 不仅源自于let机制对我们来说是新东西,还因为它是 隐含的

还有一个灾难是let c = ..声明出现在作用域中太过靠下的地方。传统的被var声明的变量,无论它们出现在何处,都会被附着在整个外围的函数作用域中;与此不同的是,let声明附着在块儿作用域,而且在它们出现在块儿中之前是不会被初始化的。

在一个let ..声明/初始化之前访问一个用let声明的变量会导致一个错误,而对于var声明来说这个顺序无关紧要(除了文体上的区别)。

考虑如下代码:

  1. {
  2. console.log( a ); // undefined
  3. console.log( b ); // ReferenceError!
  4. var a;
  5. let b;
  6. }

警告: 这个由于过早访问被let声明的引用而引起的ReferenceError在技术上称为一个 临时死区(Temporal Dead Zone —— TDZ) 错误 —— 你在访问一个已经被声明但还没被初始化的变量。这将不是我们唯一能够见到TDZ错误的地方 —— 在ES6中它们会在几种地方意外地发生。另外,注意“初始化”并不要求在你的代码中明确地赋一个值,比如let b;是完全合法的。一个在声明时没有被赋值的变量被认为已经被赋予了undefined值,所以let b;let b = undefined;是一样的。无论是否明确赋值,在let b语句运行之前你都不能访问b

最后一个坑:对于TDZ变量和未声明的(或声明的!)变量,typeof的行为是不同的。例如:

  1. {
  2. // `a` 没有被声明
  3. if (typeof a === "undefined") {
  4. console.log( "cool" );
  5. }
  6. // `b` 被声明了,但位于它的TDZ中
  7. if (typeof b === "undefined") { // ReferenceError!
  8. // ..
  9. }
  10. // ..
  11. let b;
  12. }

a没有被声明,所以typeof是检查它是否存在的唯一安全的方法。但是typeof b抛出了TDZ错误,因为在代码下面很远的地方偶然出现了一个let b声明。噢。

现在你应当清楚为什么我坚持认为所有的let声明都应该位于它们作用域的顶部了。这完全避免了偶然过早访问的错误。当你观察一个块儿,或任何块儿的开始部分时,它还更 明确 地指出这个块儿中含有什么变量。

你的块儿(if语句,while循环,等等)不一定要与作用域行为共享它们原有的行为。

这种明确性要由你负责,由你用毅力来维护,它将为你省去许多重构时的头疼和后续的麻烦。

注意: 更多关于let和块儿作用域的信息,参见本系列的 作用域与闭包 的第三章。

let + for

我偏好 明确 形式的let声明块儿,但对此的唯一例外是出现在for循环头部的let。这里的原因看起来很微妙,但我相信它是更重要的ES6特性中的一个。

考虑如下代码:

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

for头部中的let i不仅是为for循环本身声明了一个i,而且它为循环的每一次迭代都重新声明了一个新的i。这意味着在循环迭代内部创建的闭包都分别引用着那些在每次迭代中创建的变量,正如你期望的那样。

如果你尝试在这段相同代码的for循环头部使用var i,那么你会得到5而不是3,因为在被引用的外部作用域中只有一个i,而不是为每次迭代的函数都有一个i被引用。

你也可以稍稍繁冗地实现相同的东西:

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

在这里,我们强制地为每次迭代都创建一个新的j,然后闭包以相同的方式工作。我喜欢前一种形式;那种额外的特殊能力正是我支持for(let .. ) ..形式的原因。可能有人会争论说它有点儿 隐晦,但是对我的口味来说,它足够 明确 了,也足够有用。

letfor..infor..of(参见“for..of循环”)循环中也以形同的方式工作。

const声明

还有另一种需要考虑的块儿作用域声明:const,它创建 常量

到底什么是一个常量?它是一个在初始值被设定后就成为只读的变量。考虑如下代码:

  1. {
  2. const a = 2;
  3. console.log( a ); // 2
  4. a = 3; // TypeError!
  5. }

变量持有的值一旦在声明时被设定就不允许你改变了。一个const声明必须拥有一个明确的初始化。如果想要一个持有undefined值的 常量,你必须声明const a = undefined来得到它。

常量不是一个作用于值本身的制约,而是作用于变量对这个值的赋值。换句话说,值不会因为const而冻结或不可变,只是它的赋值被冻结了。如果这个值是一个复杂值,比如对象或数组,那么这个值的内容仍然是可以被修改的:

  1. {
  2. const a = [1,2,3];
  3. a.push( 4 );
  4. console.log( a ); // [1,2,3,4]
  5. a = 42; // TypeError!
  6. }

变量a实际上没有持有一个恒定的数组;而是持有一个指向数组的恒定的引用。数组本身可以自由变化。

警告: 将一个对象或数组作为常量赋值意味着这个值在常量的词法作用域消失以前是不能够被垃圾回收的,因为指向这个值的引用是永远不能解除的。这可能是你期望的,但如果不是你就要小心!

实质上,const声明强制实行了我们许多年来在代码中用文体来表明的东西:我们声明一个名称全由大写字母组成的变量并赋予它某些字面值,我们小心照看它以使它永不改变。var赋值没有强制性,但是现在const赋值上有了,它可以帮你发现不经意的改变。

const可以 被用于forfor..in,和for..of循环(参见“for..of循环”)的变量声明。然而,如果有任何重新赋值的企图,一个错误就会被抛出,例如在for循环中常见的i++子句。

const用还是不用

有些流传的猜测认为在特定的场景下,与letvar相比一个const可能会被JS引擎进行更多的优化。理论上,引擎可以更容易地知道变量的值/类型将永远不会改变,所以它可以免除一些可能的追踪工作。

无论const在这方面是否真的有帮助,还是这仅仅是我们的幻想和直觉,你要做的更重要的决定是你是否打算使用常量的行为。记住:源代码扮演的一个最重要的角色是为了明确地交流你的意图是什么,不仅是与你自己,而且还是与未来的你和其他的代码协作者。

一些开发者喜欢在一开始将每个变量都声明为一个const,然后当它的值在代码中有必要发生变化的时候将声明放松至一个let。这是一个有趣的角度,但是不清楚这是否真正能够改善代码的可读性或可推理性。

就像许多人认为的那样,它不是一种真正的 保护,因为任何后来的想要改变一个const值的开发者都可以盲目地将声明从const改为let。它至多是防止意外的改变。但是同样地,除了我们的直觉和感觉以外,似乎没有客观和明确的标准可以衡量什么构成了“意外”或预防措施。这与类型强制上的思维模式类似。

我的建议:为了避免潜在的令人糊涂的代码,仅将const用于那些你有意地并且明显地标识为不会改变的变量。换言之,不要为了代码行为而 依靠 const,而是在为了意图可以被清楚地表明时,将它作为一个表明意图的工具。

块儿作用域的函数

从ES6开始,发生在块儿内部的函数声明现在被明确规定属于那个块儿的作用域。在ES6之前,语言规范没有要求这一点,但是许多实现不管怎样都是这么做的。所以现在语言规范和现实吻合了。

考虑如下代码:

  1. {
  2. foo(); // 好用!
  3. function foo() {
  4. // ..
  5. }
  6. }
  7. foo(); // ReferenceError

函数foo()是在{ .. }块儿内部被声明的,由于ES6的原因它是属于那里的块儿作用域的。所以在那个块儿的外部是不可用的。但是还要注意它在块儿里面被“提升”了,这与早先提到的遭受TDZ错误陷阱的let声明是相反的。

如果你以前曾经写过这样的代码,并依赖于老旧的非块儿作用域行为的话,那么函数声明的块儿作用域可能是一个问题:

  1. if (something) {
  2. function foo() {
  3. console.log( "1" );
  4. }
  5. }
  6. else {
  7. function foo() {
  8. console.log( "2" );
  9. }
  10. }
  11. foo(); // ??

在前ES6环境下,无论something的值是什么foo()都将会打印"2",因为两个函数声明被提升到了块儿的顶端,而且总是第二个有效。

在ES6中,最后一行将抛出一个ReferenceError