你不懂JS:作用域与闭包

第三章:函数与块儿作用域

正如我们在第二章中探索的,作用域由一系列“气泡”组成,这些“气泡”的每一个就像一个容器或篮子,标识符(变量,函数)就在它里面被声明。这些气泡整齐地互相嵌套在一起,而且这种嵌套是在编写时定义的。

但是到底是什么才能制造一个新气泡?只能是函数吗?JavaScript 中的其他结构可以创建作用域的气泡吗?

函数中的作用域

对这些问题的最常见的回答是,JavaScript 拥有基于函数的作用域。也就是,你声明的每一个函数都为自己创建了一个气泡,而且没有其他的结构可以创建它们自己的作用域气泡。但是就像我们一会儿将会看到的,这不完全正确。

但首先,让我们探索一下函数作用域和它的含义。

考虑这段代码:

function foo(a) {
    var b = 2;

    // 一些代码

    function bar() {
        // ...
    }

    // 更多代码

    var c = 3;
}

在这个代码段中,foo(..) 的作用域气泡包含标识符 abcbar。一个声明出现在作用域 何处无关紧要的,不管怎样,变量和函数属于包含它们的作用域气泡。在下一章中我们将会探索这到底是如何工作的。

bar(..) 拥有它自己的作用域气泡。全局作用域也一样,它仅含有一个标识符:foo

因为 abc,和 bar 都属于 foo(..) 的作用域气泡,所以它们在 foo(..) 外部是不可访问的。也就是,接下来的代码都会得到 ReferenceError 错误,因为这些标识符在全局作用域中都不可用:

bar(); // 失败

console.log( a, b, c ); // 3个都失败

然而,所有这些标识符(abc,和 bar)在 foo(..) 内部 都是可以访问的,而且在 bar(..) 内部也都是可用的(假定在 bar(..) 内部没有遮蔽标识符的声明)。

函数作用域支持着这样的想法:所有变量都属于函数,而且贯穿整个函数始终都可以使用和重用(而且甚至可以在嵌套的作用域中访问)。这种设计方式可以十分有用,而且肯定可以完全利用 JavaScript 的“动态”性质 —— 变量可以根据需要接受不同种类型的值。

另一方面,如果你不小心提防,跨越整个作用域存在的变量可能会导致一些意料之外的陷阱。

隐藏于普通作用域

考虑一个函数的传统方式是,你声明一个函数,并在它内部添加代码。但是相反的想法也同样强大和有用:拿你所编写的代码的任意一部分,在它周围包装一个函数声明,这实质上“隐藏”了这段代码。

其实际结果是在这段代码周围创建了一个作用域气泡,这意味着现在在这段代码中的任何声明都将绑在这个新的包装函数的作用域上,而不是前一个包含它们的作用域。换句话说,你可以通过将变量和函数围在一个函数的作用域中来“隐藏”它们。

为什么“隐藏”变量和函数是一种有用的技术?

有多种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为“最低权限原则”的软件设计原则引起的[^note-leastprivilege],有时也被称为“最低授权”或“最少曝光”。这个原则规定,在软件设计中,比如一个模块/对象的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(..) 如何工作的“私有”细节。允许外围的作用域“访问” bdoSomethingElse(..) 不仅没必要而且可能是“危险的”,因为它们可能会以种种意外的方式,有意或无意地被使用,而这也许违背了 doSomething(..) 假设的前提条件。

一个更“恰当”的设计是讲这些私有细节隐藏在doSomething(..)的作用域内部,比如:

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }

    var b;

    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}

doSomething( 2 ); // 15

现在,bdoSomethingElse(..) 对任何外界影响都是不可访问的,而是仅仅由 doSomething(..) 控制。它的功能和最终结果不受影响,但是这种设计将私有细节保持为私有的,这通常被认为是好的软件。

避免冲突

将变量和函数“隐藏”在一个作用域内部的另一个好处是,避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。

例如:

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

bar(..) 内部的赋值需要声明一个本地变量来使用,不论选用什么样的标识符名称。var i = 3; 将修复这个问题(并将为 i 创建一个前面提到的“遮蔽变量”声明)。一个 另外的 选项,不是代替的选项,是完全选择另外一个标识符名称,比如 var j = 3;。但是你的软件设计也许会自然而然地使用相同的标识符名称,所以在这种情况下利用作用域来“隐藏”你的内部声明是你最好/唯一的选择。

全局“名称空间”

变量冲突(很可能)发生的一个特别强有力的例子是在全局作用域中。当多个库被加载到你的程序中时,如果它们没有适当地隐藏它们的内部/私有函数和变量,那么它们可以十分容易地互相冲突。

这样的库通常会在全局作用域中使用一个足够独特的名称来创建一个单独的变量声明,它经常是一个对象。然后这个对象被用作这个库的一个“名称空间”,所有要明确暴露出来的功能都被作为属性挂在这个对象(名称空间)上,而不是将它们自身作为顶层词法作用域的标识符。

例如:

var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
        // ...
    },
    doAnotherThing: function() {
        // ...
    }
};

模块管理

另一种回避冲突的选择是通过任意一种依赖管理器,使用更加现代的“模块”方式。使用这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。

应该可以看到,这些工具并不拥有可以豁免于词法作用域规则的“魔法”功能。它们简单地使用这里讲解的作用域规则,来强制标识符不会被注入任何共享的作用域,而是保持在私有的,不易冲突的作用域中,这防止了任何意外的作用域冲突。

因此,如果你选择这样做的话,你可以防御性地编码,并在实际上不使用依赖管理器的情况下,取得与使用它们相同的结果。关于模块模式的更多信息参见第五章。

函数作为作用域

我们已经看到,我们可以拿来一段代码并在它周围包装一个函数,而这实质上对外部作用域“隐藏”了这个函数内部作用域包含的任何变量或函数声明。

例如:

var a = 2;

function foo() { // <-- 插入这个

    var a = 3;
    console.log( a ); // 3

} // <-- 和这个
foo(); // <-- 还有这个

console.log( a ); // 2

虽然这种技术“可以工作”,但它不一定非常理想。它引入了几个问题。首先是我们不得不声明一个命名函数 foo(),这意味着这个标识符名称 foo 本身就“污染”了外围作用域(在这个例子中是全局)。我们要不得不通过名称(foo())明确地调用这个函数来使被包装的代码真正运行。

如果这个函数不需要名称(或者,这个名称不污染外围作用域),而且如果这个函数能自动地被执行就更理想了。

幸运的是,JavaScript 给这两个问题提供了一个解决方法。

var a = 2;

(function foo(){ // <-- 插入这个

    var a = 3;
    console.log( a ); // 3

})(); // <-- 和这个

console.log( a ); // 2

让我们分析一下这里发生了什么。

首先注意,与仅仅是 function... 相对,这个包装函数语句以 (function... 开头。虽然这看起来像是一个微小的细节,但实际上这是一个重大改变。与将这个函数视为一个标准的声明不同的是,这个函数被视为一个函数表达式。

注意: 区分声明与表达式的最简单的方法是,这个语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个东西,那么它就是一个函数声明。否则,它就是一个函数表达式。

这里我们可以观察到一个函数声明和一个函数表达式之间的关键不同是,它的名称作为一个标识符被绑定在何处。

比较这前两个代码段。在第一个代码段中,名称 foo 被绑定在外围作用域中,我们用 foo() 直接调用它。在第二个代码段中,名称 foo 没有被绑定在外围作用域中,而是被绑定在它自己的函数内部。

换句话说,(function foo(){ .. }) 作为一个表达式意味着标识符 foo 仅能在 .. 代表的作用域中被找到,而不是在外部作用域中。将名称 foo 隐藏在它自己内部意味着它不会没必要地污染外围作用域。

匿名与命名

你可能对函数表达式作为回调参数再熟悉不过了,比如:

setTimeout( function(){
    console.log("I waited 1 second!");
}, 1000 );

这称为一个“匿名函数表达式”,因为 function()... 上没有名称标识符。函数表达式可以是匿名的,但是函数声明不能省略名称 —— 那将是不合法的JS程序。

匿名函数表达式可以快速和很容易地键入,而且许多库和工具往往鼓励使用这种代码惯用风格。然而,它们有几个缺点需要考虑:

  1. 在栈轨迹上匿名函数没有有用的名称可以表示,这可能会使得调试更加困难。

  2. 没有名称的情况下,如果这个函数需要为了递归等目的引用它自己,那么就需要很不幸地使用 被废弃的 arguments.callee 引用。另一个需要自引用的例子是,当一个事件处理器函数在被触发后想要把自己解除绑定。

  3. 匿名函数省略的名称经常对提供更易读/易懂的代码很有帮助。一个描述性的名称可以帮助代码自解释。

内联函数表达式 很强大且很有用 —— 匿名和命名的问题并不会贬损这一点。给你的函数表达式提供一个名称就可以十分有效地解决这些缺陷,而且没有实际的坏处。最佳的方法是总是命名你的函数表达式:

setTimeout( function timeoutHandler(){ // <-- 看,我有一个名字!
    console.log( "I waited 1 second!" );
}, 1000 );

立即调用函数表达式

var a = 2;

(function foo(){

    var a = 3;
    console.log( a ); // 3

})();

console.log( a ); // 2

得益于包装在一个 () 中,我们有了一个作为表达式的函数,我们可以通过在末尾加入另一个 () 来执行这个函数,就像 (function foo(){ .. })()。第一个外围的 ( ) 使这个函数变成表达式,而第二个 () 执行这个函数。

这个模式是如此常见,以至于几年前开发者社区一致同意给它一个术语:IIFE,它表示“立即被调用的函数表达式”(Immediately Invoked Function Expression)。

当然,IIFE不一定需要一个名称 —— IIFE的最常见形式是使用一个匿名函数表达式。虽然少见一些,但与匿名函数表达式相比,命名的IIFE拥有前述所有的好处,所以它是一个可以采用的好方式。

var a = 2;

(function IIFE(){

    var a = 3;
    console.log( a ); // 3

})();

console.log( a ); // 2

传统的IIFE有一种稍稍变化的形式,一些人偏好这样:(function(){ .. }())。仔细观察不同之处。在第一种形式中,函数表达式被包在 ( ) 中,然后用于调用的 () 出现在它的外侧。在第二种形式中,用于调用的 () 被移动到用于包装的 ( ) 内侧。

这两种形式在功能上完全相同。这纯粹是一个你偏好的风格的选择。

IIFE的另一种十分常见的变种是,利用它们实际上只是函数调用的事实,来传入参数值。

例如:

var a = 2;

(function IIFE( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

})( window );

console.log( a ); // 2

我们传入 window 对象引用,但是我们将参数命名为 global,这样我们对于全局和非全局引用就有了一个清晰的文体上的划分。当然,你可以从外围作用域传入任何你想要的东西,而且你可以将参数命名为任何适合你的名称。这几乎仅仅是文体上的选择。

这种模式的另一种应用解决了一个小问题:默认的 undefined 标识符的值也许会被不正确地覆盖掉,而导致意外的结果。通过将参数命名为undefined,同时不为它传递任何参数值,我们就可以保证在一个代码块中 undefined 标识符确实是是一个未定义的值。

undefined = true; // 给其他的代码埋地雷!别这么干!

(function IIFE( undefined ){

    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }

})();

IIFE 还有另一种变种,它将事情的顺序倒了过来,要被执行的函数在调用和传递给它的参数 之后 给出。这种模式被用于 UMD(Universal Module Definition —— 统一模块定义)项目。一些人发现它更干净和易懂一些,虽然有点儿繁冗。

var a = 2;

(function IIFE( def ){
    def( window );
})(function def( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

});

def 函数表达式在这个代码段的后半部分被定义,然后作为一个参数(也叫 def)被传递给在代码段前半部分定义的 IIFE 函数。最后,参数 def(函数)被调用,并将 window 作为 global 参数传入。

块儿作为作用域

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

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

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

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

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

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

var foo = true;

if (foo) {
    var bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

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

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

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

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

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

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

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

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

with

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

try/catch

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

例如:

try {
    undefined(); //用非法的操作强制产生一个异常!
}
catch (err) {
    console.log( err ); // 好用!
}

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 为它的变量声明隐含地劫持了任意块儿的作用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

console.log( bar ); // ReferenceError

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

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

var foo = true;

if (foo) {
    { // <-- 明确的块儿
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

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

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

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

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

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

垃圾回收

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

考虑这段代码:

function process(data) {
    // 做些有趣的事
}

var someReallyBigData = { .. };

process( someReallyBigData );

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

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

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

function process(data) {
    // 做些有趣的事
}

// 运行过后,任何定义在这个块中的东西都可以消失了
{
    let someReallyBigData = { .. };

    process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

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

let 循环

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

for (let i=0; i<10; i++) {
    console.log( i );
}

console.log( i ); // ReferenceError

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

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

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // 每次迭代都重新绑定
        console.log( i );
    }
}

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

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

考虑如下代码:

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    if (baz > bar) {
        console.log( baz );
    }

    // ...
}

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

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    // ...
}

if (baz > bar) {
    console.log( baz );
}

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

var foo = true, baz = 10;

if (foo) {
    let bar = 3;

    if (baz > bar) { // <-- 移动时不要忘了`bar`
        console.log( baz );
    }
}

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

const

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

var foo = true;

if (foo) {
    var a = 2;
    const b = 3; // 存在于包含它的`if`作用域中

    a = 3; // 没问题!
    b = 4; // 错误!
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

复习

在 JavaScript 中函数是最常见的作用域单位。在另一个函数内部声明的变量和函数,实质上对任何外围“作用域”都是“隐藏的”,这是优秀软件的一个有意的设计原则。

但是函数绝不是唯一的作用域单位。块儿作用域指的是这样一种想法:变量和函数可以属于任意代码块儿(一般来说,就是任意的 { .. }),而不是仅属于外围的函数。

从 ES3 开始,try/catch 结构在 catch 子句上拥有块儿作用域。

在 ES6 中,引入了 let 关键字(var 关键字的表兄弟)允许在任意代码块中声明变量。if (..) { let a = 2; } 将会声明变量 a,而它实质上劫持了 if{ .. } 块儿的作用域,并将自己附着在这里。

虽然有些人对此深信不疑,但是块儿作用域不应当被认为是 var 函数作用域的一个彻头彻尾的替代品。两种机能是共存的,而且开发者们可以并且应当同时使用函数作用域和块儿作用域技术 —— 在它们各自可以产生更好,更易读/易维护代码的地方。

[^note-leastprivilege]: Principle of Least Privilege

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8