虽然函数是最常见的作用域单位,而且当然也是在世面上流通的绝大多数 JS 中最为广泛传播的设计方式,但是其他的作用域单位也是可能的,而且使用这些作用域单位可以导致更好、对于维护来说更干净的代码。

JavaScript 之外的许多其他语言都支持块儿作用域,所以有这些语言背景的开发者习惯于这种思维模式,然而那些主要在 JavaScript 中工作的开发者可能会发现这个概念有些陌生。

但即使你从没用块儿作用域的方式写过一行代码,你可能依然对 JavaScript 中这种极其常见的惯用法很熟悉:

  1. for (var i=0; i<10; i++) {
  2. console.log( i );
  3. }

我们在 for 循环头的内部直接声明了变量 i,因为我们意图很可能是仅在这个 for 循环内部的上下文环境中使用 i,而实质上忽略了这个变量实际上将自己划入了外围作用域中(函数或全局)的事实。

这就是有关块儿作用域的一切。尽可能靠近地,尽可能局部地,在变量将被使用的位置声明它。另一个例子是:

  1. var foo = true;
  2. if (foo) {
  3. var bar = foo * 2;
  4. bar = something( bar );
  5. console.log( bar );
  6. }

我们仅在 if 语句的上下文环境中使用变量 bar,所以我们将它声明在 if 块儿的内部是有些道理的。然而,当使用 var 时,我们在何处声明变量是无关紧要的,因为它们将总是属于外围作用域。这个代码段实质上为了代码风格的原因“假冒”了块儿作用域,并依赖于我们要管好自己,不要在这个作用域的其他地方意外地使用 bar

从将信息隐藏在函数中,到将信息隐藏在我们代码的块儿中,块儿作用域是一种扩展了早先的“最低 权限 暴露原则”[^note-leastprivilege]的工具。

再次考虑这个for循环的例子:

  1. for (var i=0; i<10; i++) {
  2. console.log( i );
  3. }

为什么要用仅将(或者至少是,仅 应当)在这个 for 循环中使用的变量 i 去污染一个函数的整个作用域呢?

但更重要的是,开发者们也许偏好于 检查 他们自己来防止在变量预期的目的之外意外地(重)使用它们,例如如果你试着在错误的地方使用变量会导致一个未知变量的错误。对于变量 i 的块儿作用域(如果它是可能的话)将使 i 仅在 for 循环内部可用,使得如果在函数的其他地方访问 i 将导致一个错误。这有助于保证变量不会被糊涂地重用或者难于维护。

但是,悲惨的现实是,表面上看来,JavaScript 没有块儿作用域的能力。

更确切地说,直到你再深入一些才有。

with

我们在第二章中学习了 with。虽然它是一个使人皱眉头的结构,但它确实是一个(一种形式的)块儿作用域的例子,它从对象中创建的作用域仅存在于这个 with 语句的生命周期中,而不在外围作用域中。

try/catch

一个鲜为人知的事实是,JavaScript 在 ES3 中明确指出在 try/catchcatch 子句中声明的变量,是属于 catch 块儿的块儿作用域的。

例如:

  1. try {
  2. undefined(); //用非法的操作强制产生一个异常!
  3. }
  4. catch (err) {
  5. console.log( err ); // 好用!
  6. }
  7. console.log( err ); // ReferenceError: `err` not found

如你所见,err 仅存在于 catch 子句中,并且在你试着从其他地方引用它时抛出一个错误。

注意: 虽然这种行为已经被明确规定,而且对于几乎所有的标准JS环境(也许除了老IE)来说都是成立的,但是如果你在同一个作用域中有两个或多个 catch 子句,而它们又各自用相同的标识符名称声明了它们表示错误的变量时,许多 linter 依然会报警。实际上这不是重定义,因为这些变量都安全地位于块儿作用域中,但是 linter 看起来依然会恼人地抱怨这个事实。

为了避免这些不必要的警告,一些开发者将他们的 catch 变量命名为 err1err2,等等。另一些开发者干脆关闭 linter 对重复变量名的检查。

catch 的块儿作用域性质看起来像是一个没用的,只有学院派意义的事实,但是参看附录B来了解更多它如何有用的信息。

let

至此,我们看到 JavaScript 仅仅有一些奇怪的小众行为暴露了块儿作用域功能。如果这就是我们拥有的一切,而且许多许多年以来这 确实就是 我们拥有的一切,那么块作用域对 JavaScript 开发者来说就不是非常有用。

幸运的是,ES6 改变了这种状态,并引入了一个新的关键字 let,作为另一种声明变量的方式伴随着 var

let 关键字将变量声明附着在它所在的任何块儿(通常是一个 { .. })的作用域中。换句话说,let 为它的变量声明隐含地劫持了任意块儿的作用域。

  1. var foo = true;
  2. if (foo) {
  3. let bar = foo * 2;
  4. bar = something( bar );
  5. console.log( bar );
  6. }
  7. console.log( bar ); // ReferenceError

使用 let 将一个变量附着在一个现存的块儿上有些隐晦。它可能会使人困惑 —— 在你开发和设计代码时,如果你不仔细注意哪些块儿的作用域包含了变量,并且习惯于将块儿四处移动,将它们包进其他的块儿中,等等。

为块儿作用域创建明确的块儿可以解决这些问题中的一些,使变量附着在何处更加明显。通常来说,明确的代码要比隐晦或微妙的代码好。这种明确的块儿作用域风格很容易达成,而且它与块儿作用域在其他语言中的工作方式匹配得更自然:

  1. var foo = true;
  2. if (foo) {
  3. { // <-- 明确的块儿
  4. let bar = foo * 2;
  5. bar = something( bar );
  6. console.log( bar );
  7. }
  8. }
  9. console.log( bar ); // ReferenceError

我们可以在一个语句是合法文法的任何地方,通过简单地引入一个 { .. } 来为 let 创建一个任意的可以绑定的块儿。在这个例子中,我们在 if 语句内部制造了一个明确的块儿,在以后的重构中将整个块儿四处移动可能会更容易,而且不会影响外围的 if 语句的位置和语义。

注意: 另一个明确表达块儿作用域的方法,参见附录B。

在第四章中,我们将讲解提升(hoisting),它讲述关于声明在它们所出现的整个作用域中都被认为是存在的。

然而,使用 let 做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。如此,直到声明语句为止,声明将不会“存在”于块儿中。

  1. {
  2. console.log( bar ); // ReferenceError!
  3. let bar = 2;
  4. }

垃圾回收

块儿作用域的另一个有用之处是关于闭包和释放内存的垃圾回收。我们将简单地在这里展示一下,但是闭包机制将在第五章中详细讲解。

考虑这段代码:

  1. function process(data) {
  2. // 做些有趣的事
  3. }
  4. var someReallyBigData = { .. };
  5. process( someReallyBigData );
  6. var btn = document.getElementById( "my_button" );
  7. btn.addEventListener( "click", function click(evt){
  8. console.log("button clicked");
  9. }, /*capturingPhase=*/false );

点击事件的处理器回调函数 click 根本不 需要 someReallyBigData 变量。这意味着从理论上讲,在 process(..) 运行之后,这个消耗巨大内存的数据结构可以被作为垃圾回收。然而,JS引擎很可能(虽然这要看具体实现)仍会将这个结构保持一段时间,因为click函数在整个作用域上拥有一个闭包。

块儿作用域可以解决这个问题,使引擎清楚地知道它不必再保持 someReallyBigData 了:

  1. function process(data) {
  2. // 做些有趣的事
  3. }
  4. // 运行过后,任何定义在这个块中的东西都可以消失了
  5. {
  6. let someReallyBigData = { .. };
  7. process( someReallyBigData );
  8. }
  9. var btn = document.getElementById( "my_button" );
  10. btn.addEventListener( "click", function click(evt){
  11. console.log("button clicked");
  12. }, /*capturingPhase=*/false );

声明可以将变量绑定在本地的明确的块儿是一种强大的工具,你可以把它加入你的工具箱。

let 循环

一个使 let 闪光的特殊例子是我们先前讨论的 for 循环。

  1. for (let i=0; i<10; i++) {
  2. console.log( i );
  3. }
  4. console.log( i ); // ReferenceError

在 for 循环头部的 let 不仅将 i 绑定在 for 循环体中,而且实际上,它会对每一次循环的 迭代 重新绑定 i,确保它被赋予来自上一次循环迭代末尾的值。

这是描绘这种为每次迭代进行绑定的行为的另一种方式:

  1. {
  2. let j;
  3. for (j=0; j<10; j++) {
  4. let i = j; // 每次迭代都重新绑定
  5. console.log( i );
  6. }
  7. }

这种为每次迭代进行的绑定有趣的原因将在第五章中我们讨论闭包时变得明朗。

因为 let 声明附着于任意的块儿,而不是外围的函数作用域(或全局),所以在重构代码时可能会有一些坑需要额外小心:现存的代码拥有对函数作用域的 var 声明有隐藏的依赖,但你想要用 let 来取代 var

考虑如下代码:

  1. var foo = true, baz = 10;
  2. if (foo) {
  3. var bar = 3;
  4. if (baz > bar) {
  5. console.log( baz );
  6. }
  7. // ...
  8. }

这段代码可以相当容易地重构为:

  1. var foo = true, baz = 10;
  2. if (foo) {
  3. var bar = 3;
  4. // ...
  5. }
  6. if (baz > bar) {
  7. console.log( baz );
  8. }

但是,当使用块儿作用域变量时要小心这样的变化:

  1. var foo = true, baz = 10;
  2. if (foo) {
  3. let bar = 3;
  4. if (baz > bar) { // <-- 移动时不要忘了`bar`
  5. console.log( baz );
  6. }
  7. }

附录B介绍了一种块作用域的(更加明确的)替代形式,它可能会在这些场景下提供更易于维护/重构的更健壮的代码。

const

除了 let 之外,ES6 还引入了 const,它也创建一个块儿作用域变量,但是它的值是固定的(常量)。任何稍后改变它的企图都将导致错误。

  1. var foo = true;
  2. if (foo) {
  3. var a = 2;
  4. const b = 3; // 存在于包含它的`if`作用域中
  5. a = 3; // 没问题!
  6. b = 4; // 错误!
  7. }
  8. console.log( a ); // 3
  9. console.log( b ); // ReferenceError!