第3章:函数与块儿作用域
原文:You-Dont-Know-JS
正如我们在第二章中探索的,作用域由一系列“气泡”组成,这些“气泡”的每一个就像一个容器或篮子,标识符(变量,函数)就在它里面被声明。这些气泡整齐地互相嵌套在一起,而且这种嵌套是在编写时定义的。
创造一个新的气泡方式:
- 函数
- with: 从对象中创建的作用域仅存在于这个
with
语句的生命周期中,而不在外围作用域中。 - try/catch: JavaScript 在 ES3 中明确指出在
try/catch
的catch
子句中声明的变量,是属于catch
块儿的块儿作用域的。
1 函数中的作用域
你可以通过将变量和函数围在一个函数的作用域中来“隐藏”它们:
function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多代码
var c = 3;
}
// foo() 外面访问不到 a,b,c
复制代码
考虑一个函数的传统方式是,你声明一个函数,并在它内部添加代码。但是相反的想法也同样强大和有用:拿你所编写的代码的任意一部分,在它周围包装一个函数声明,这实质上“隐藏”了这段代码。
为什么“隐藏”变量和函数是一种有用的技术?
有多种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为“最低权限原则”的软件设计原则引起的,有时也被称为“最低授权”或“最少曝光”。这个原则规定,在软件设计中,比如一个模块/对象的API,你应当只暴露所需要的最低限度的东西,而“隐藏”其他的一切。
这个原则可以扩展到用哪个作用域来包含变量和函数的选择。如果所有的变量和函数都在全局作用域中,它们将理所当然地对任何嵌套的作用域来说都是可访问的。但这回违背“最少……”原则,因为你(很可能)暴露了许多你本应当保持为私有的变量和函数,而这些代码的恰当用法是不鼓励访问这些变量/函数的。
例如:
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething(2); // 15
复制代码
在这个代码段中,变量 b
和函数 doSomethingElse(..)
很可能是 doSomething(..)
如何工作的“私有”细节。允许外围的作用域“访问” b
和 doSomethingElse(..)
不仅没必要而且可能是“危险的”,因为它们可能会以种种意外的方式,有意或无意地被使用,而这也许违背了 doSomething(..)
假设的前提条件。
修改:
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
doSomething(2); // 15
复制代码
将变量和函数“隐藏”在一个作用域内部的另一个好处是,避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。
例如:
function foo() {
function bar(a) {
i = 3; // 在外围的for循环的作用域中改变`i`
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2); // 噢,无限循环!
}
}
foo();
复制代码
bar(..)
内部的赋值 i = 3
意外地覆盖了在 foo(..)
的for循环中声明的 i
。在这个例子中,这将导致一个无限循环,因为 i
被设定为固定的值 3
,而它将永远 < 10
。
通过将变量和函数围在一个函数的作用域中“可以工作”,但它不一定非常理想。它引入了几个问题。首先是我们不得不声明一个命名函数 foo()
,这意味着这个标识符名称 foo
本身就“污染”了外围作用域(在这个例子中是全局)。我们要不得不通过名称(foo()
)明确地调用这个函数来使被包装的代码真正运行。
解决方法(立即调用函数表达式 IIFE ):
var a = 2;
(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
console.log(a); // 2
复制代码
得益于包装在一个 ()
中,我们有了一个作为表达式的函数,我们可以通过在末尾加入另一个 ()
来执行这个函数,就像 (function foo(){ .. })()
。第一个外围的 ( )
使这个函数变成表达式,而第二个 ()
执行这个函数。
另一种(匿名函数表达式):
setTimeout(function () {
console.log("I waited 1 second!");
}, 1000);
复制代码
有几个缺点需要考虑:
- 在栈轨迹上匿名函数没有有用的名称可以表示,这可能会使得调试更加困难。
- 没有名称的情况下,如果这个函数需要为了递归等目的引用它自己,那么就需要很不幸地使用 被废弃的
arguments.callee
引用。另一个需要自引用的例子是,当一个事件处理器函数在被触发后想要把自己解除绑定。 - 匿名函数省略的名称经常对提供更易读/易懂的代码很有帮助。一个描述性的名称可以帮助代码自解释。
2 块儿作为作用域
JavaScript 没有块儿作用域的能力。
考虑这个for循环:
for (var i = 0; i < 10; i++) {
console.log(i);
}
复制代码
这里使用 var
时,我们在何处声明变量是无关紧要的,因为它们将总是属于外围作用域。这个代码段实质上为了代码风格的原因“假冒”了块儿作用域,并依赖于我们要管好自己,不要在这个作用域的其他地方意外地使用 i
。比如上面无限循环的例子。
为什么要用仅将(或者至少是,仅 应当)在这个 for 循环中使用的变量 i
去污染一个函数的整个作用域呢?
幸运的是,ES6 改变了这种状态,并引入了一个新的关键字 let
,作为另一种声明变量的方式伴随着 var
。
let
关键字将变量声明附着在它所在的任何块儿(通常是一个 { .. }
)的作用域中。换句话说,let
为它的变量声明隐含地劫持了任意块儿的作用域。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something(bar);
console.log(bar);
}
console.log(bar); // ReferenceError
复制代码
我们可以在一个语句是合法文法的任何地方,通过简单地引入一个 { .. }
来为 let
创建一个任意的可以绑定的块儿。在这个例子中,我们在 if 语句内部制造了一个明确的块儿,在以后的重构中将整个块儿四处移动可能会更容易,而且不会影响外围的 if 语句的位置和语义。
注意:使用 let
做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。
{
console.log(bar); // ReferenceError!
let bar = 2;
}
复制代码
复习
区分函数声明与表达式的最简单的方法是,这个语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个东西,那么它就是一个函数声明。否则,它就是一个函数表达式。
在 JavaScript 中函数是最常见的作用域单位。在另一个函数内部声明的变量和函数,实质上对任何外围“作用域”都是“隐藏的”,这是优秀软件的一个有意的设计原则。
但是函数绝不是唯一的作用域单位。块儿作用域指的是这样一种想法:变量和函数可以属于任意代码块儿(一般来说,就是任意的 { .. }
),而不是仅属于外围的函数。
从 ES3 开始,try/catch
结构在 catch
子句上拥有块儿作用域。
在 ES6 中,引入了 let
关键字(var
关键字的表兄弟)允许在任意代码块中声明变量。if (..) { let a = 2; }
将会声明变量 a
,而它实质上劫持了 if
的 { .. }
块儿的作用域,并将自己附着在这里。
虽然有些人对此深信不疑,但是块儿作用域不应当被认为是 var
函数作用域的一个彻头彻尾的替代品。两种机能是共存的,而且开发者们可以并且应当同时使用函数作用域和块儿作用域技术 —— 在它们各自可以产生更好,更易读/易维护代码的地方。