第 16 章. 变量:作用域、环境和闭包
目录
购买本书
(广告,请不要屏蔽。)

第 16 章. 变量:作用域、环境和闭包

本章首先解释如何使用变量,然后详细介绍它们的工作原理(环境、闭包等)。

声明变量

在 JavaScript 中,您需要在使用变量之前通过 var 语句声明它:

var foo;
foo = 3; // OK, has been declared
bar = 5; // not OK, an undeclared variable

您还可以将声明与赋值结合起来,立即初始化变量

var foo = 3;

未初始化变量的值为 undefined

> var x;
> x
undefined

背景:静态与动态

您可以从两个角度检查程序的工作方式:

静态(或词法)

您在不运行程序的情况下检查源代码中的程序。给定以下代码,我们可以进行静态断言,即函数 g 嵌套在函数 f

function f() {
    function g() {
    }
}

形容词 词法静态 同义,因为两者都与程序的 词法(单词、源代码)有关。

动态

您检查在执行程序时发生的情况(“在运行时”)。给定以下代码

function g() {
}
function f() {
    g();
}

当我们调用 f() 时,它会调用 g()。在运行时,gf 调用表示一种动态关系。

背景:变量的作用域

在本章的其余部分,您应该了解以下概念:

变量的作用域

变量的作用域是它可以访问的位置。例如

function foo() {
    var x;
}

在这里,x直接作用域 是函数 foo()

词法作用域
JavaScript 中的变量是 词法作用域 的,因此程序的静态结构决定了变量的作用域(它不受例如函数调用位置的影响)。
嵌套作用域

如果作用域嵌套在变量的直接作用域内,则该变量在所有这些作用域中都可访问

function foo(arg) {
    function bar() {
        console.log('arg: '+arg);
    }
    bar();
}
console.log(foo('hello')); // arg: hello

arg 的直接作用域是 foo(),但它也可以在嵌套作用域 bar() 中访问。关于嵌套,foo()外部作用域,而 bar()内部作用域

遮蔽

如果一个作用域声明了一个与周围作用域中同名的变量,则在内部作用域及其嵌套的所有作用域中,对外部变量的访问将被阻止。对内部变量的更改不会影响外部变量,在离开内部作用域后可以再次访问外部变量:

var x = "global";
function f() {
    var x = "local";
    console.log(x); // local
}
f();
console.log(x); // global

在函数 f() 内部,全局变量 x 被局部变量 x 遮蔽。

变量是函数作用域的

大多数主流语言都是 块级作用域 的:变量“存在于”最里面的代码块中。以下是 Java 中的一个示例:

public static void main(String[] args) {
    { // block starts
        int foo = 4;
    } // block ends
    System.out.println(foo); // Error: cannot find symbol
}

在前面的代码中,变量 foo 只能在其直接包围的块内访问。如果我们尝试在块结束后访问它,则会收到编译错误。

相比之下,JavaScript 的变量是 函数作用域 的:只有函数会引入新的作用域;在作用域方面,块会被忽略。例如:

function main() {
    { // block starts
        var foo = 4;
    } // block ends
    console.log(foo); // 4
}

换句话说,foomain() 的所有地方都可以访问,而不仅仅是在块内。

变量声明会被提升

JavaScript 会 提升 所有变量声明,将它们移动到其直接作用域的开头。这清楚地说明了如果在声明变量之前访问它会发生什么:

function f() {
    console.log(bar);  // undefined
    var bar = 'abc';
    console.log(bar);  // abc
}

我们可以看到变量 bar 已经存在于 f() 的第一行,但它还没有值;也就是说,声明已被提升,但赋值没有。JavaScript 执行 f(),就好像它的代码是

function f() {
    var bar;
    console.log(bar);  // undefined
    bar = 'abc';
    console.log(bar);  // abc
}

如果您声明一个已经声明的变量,则不会发生任何事情(变量的值保持不变)

> var x = 123;
> var x;
> x
123

每个函数声明也会被提升,但方式略有不同。完整的函数会被提升,而不仅仅是存储它的变量的创建(请参阅提升)。

最佳实践:了解提升,但不要害怕它

一些 JavaScript 样式指南建议您只将变量声明放在函数的开头,以避免被提升所迷惑。如果您的函数相对较小(无论如何都应该是这样),那么您可以稍微放宽该规则,并在变量使用位置附近声明它们(例如,在 for 循环内)。这样可以更好地封装代码段。显然,您应该意识到这种封装只是概念上的,因为函数范围的提升仍然会发生。

通过 IIFE 引入新的作用域

您通常会引入一个新的作用域来限制变量的生命周期。您可能希望这样做的一个例子是 if 语句的“then”部分:它仅在条件成立时执行;如果它只使用辅助变量,我们不希望它们“泄漏”到周围的作用域中:

function f() {
    if (condition) {
        var tmp = ...;
        ...
    }
    // tmp still exists here
    // => not what we want
}

如果要为 then 块引入新的作用域,可以定义一个函数并立即调用它。这是一种解决方法,是对块级作用域的模拟:

function f() {
    if (condition) {
        (function () {  // open block
            var tmp = ...;
            ...
        }());  // close block
    }
}

这是 JavaScript 中的一种常见模式。Ben Alman 建议将其称为立即调用函数表达式(IIFE,发音为“iffy”)。通常,IIFE 如下所示

(function () { // open IIFE
    // inside IIFE
}()); // close IIFE

以下是关于 IIFE 的一些注意事项

它会被立即调用
函数右括号后面的括号会立即调用它。这意味着它的主体会被立即执行。
它必须是一个表达式
如果语句以关键字 function 开头,则解析器会将其视为函数声明(请参阅表达式与语句)。但是函数声明不能立即调用。因此,我们通过以左括号开始语句来告诉解析器关键字 function 是函数表达式的开头。在括号内,只能有表达式。
需要尾随分号

如果您在两个 IIFE 之间忘记了它,那么您的代码将无法再工作:

(function () {
    ...
}()) // no semicolon
(function () {
    ...
}());

前面的代码被解释为函数调用——第一个 IIFE(包括括号)是要调用的函数,第二个 IIFE 是参数。

注意

IIFE 会产生成本(认知和性能方面),因此在 if 语句中使用它很少有意义。选择前面的示例是为了便于说明。

IIFE 变体:前缀运算符

您还可以通过前缀运算符强制执行表达式上下文。例如,您可以通过逻辑非运算符来实现:

!function () { // open IIFE
    // inside IIFE
}(); // close IIFE

或者通过 void 运算符(请参阅void 运算符

void function () { // open IIFE
    // inside IIFE
}(); // close IIFE

使用前缀运算符的优点是忘记终止分号不会导致问题。

IIFE 变体:已经在表达式上下文中

请注意,如果您已经在表达式上下文中,则无需为 IIFE 强制执行表达式上下文。那么您不需要括号或前缀运算符。例如:

var File = function () { // open IIFE
    var UNTITLED = 'Untitled';
    function File(name) {
        this.name = name || UNTITLED;
    }
    return File;
}(); // close IIFE

在前面的示例中,有两个不同的变量都名为 File。一方面,该函数只能在 IIFE 内部直接访问。另一方面,该变量在第一行声明。它被赋予 IIFE 中返回的值。

IIFE 变体:带参数的 IIFE

您可以使用参数为IIFE 内部定义变量:

var x = 23;
(function (twice) {
    console.log(twice);
}(x * 2));

这类似于

var x = 23;
(function () {
    var twice = x * 2;
    console.log(twice);
}());

IIFE 应用

IIFE 使您能够将私有数据附加到函数。然后您不必声明全局变量,并且可以将函数与其状态紧密打包。您可以避免污染全局命名空间:

var setValue = function () {
    var prevValue;
    return function (value) { // define setValue
        if (value !== prevValue) {
            console.log('Changed: ' + value);
            prevValue = value;
        }
    };
}();

IIFE 的其他应用在本的其他地方也有提及

全局变量

包含程序所有内容的作用域称为 全局作用域程序作用域这是您在输入脚本(无论是网页中的 <script> 标签还是 .js 文件)时所处的作用域。在全局作用域内,您可以通过定义函数来创建嵌套作用域。在这样的函数内部,您可以再次嵌套作用域。每个作用域都可以访问自己的变量以及周围作用域中的变量。由于全局作用域包含所有其他作用域,因此它的变量可以在任何地方访问:

// here we are in global scope
var globalVariable = 'xyz';
function f() {
    var localVariable = true;
    function g() {
        var anotherLocalVariable = 123;

        // All variables of surround scopes are accessible
        localVariable = false;
        globalVariable = 'abc';
    }
}
// here we are again in global scope

最佳实践:避免创建全局变量

全局变量有两个缺点。首先,依赖全局变量的软件容易受到副作用的影响;它们的健壮性较差,行为可预测性较低,并且可重用性较差。

其次,网页上的所有 JavaScript 代码都共享相同的全局变量:您的代码、内置代码、分析代码、社交媒体按钮等等。这意味着名称冲突可能会成为一个问题。这就是为什么最好尽可能多地隐藏全局作用域中的变量。例如,不要这样做

<!-- Don’t do this -->
<script>
    // Global scope
    var tmp = generateData();
    processData(tmp);
    persistData(tmp);
</script>

变量 tmp 变为全局变量,因为它的声明是在全局作用域中执行的。但它只在本地使用。因此,我们可以使用 IIFE(请参阅通过 IIFE 引入新的作用域)将其隐藏在嵌套作用域中

<script>
    (function () {  // open IIFE
        // Local scope
        var tmp = generateData();
        processData(tmp);
        persistData(tmp);
    }());  // close IIFE
</script>

模块系统导致更少的全局变量

值得庆幸的是,模块系统(请参阅模块系统)在很大程度上消除了全局变量的问题,因为模块不通过全局作用域进行交互,并且每个模块都有自己的模块全局变量作用域。

全局对象

ECMAScript 规范使用内部数据结构环境来存储变量(请参阅环境:管理变量)。该语言有一个不同寻常的特性,即可以通过一个对象(即所谓的全局对象)访问全局变量的环境。全局对象可用于创建、读取和更改全局变量。在全局作用域中,this 指向它:

> var foo = 'hello';
> this.foo  // read global variable
'hello'

> this.bar = 'world';  // create global variable
> bar
'world'

请注意,全局对象具有原型。如果要列出其所有(自身和继承的)属性,则需要使用列出所有属性键中的getAllPropertyNames()等函数。

> getAllPropertyNames(window).sort().slice(0, 5)
[ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ]

JavaScript 创建者 Brendan Eich 认为全局对象是他“最后悔的事情之一”。它会对性能产生负面影响,使变量作用域的实现更加复杂,并导致代码模块化程度降低。

跨平台注意事项

浏览器和 Node.js 具有用于引用全局对象的全局变量。不幸的是,它们是不同的:

在这两个平台上,this 都引用全局对象,但前提是您位于全局作用域中。在 Node.js 上,这几乎从未发生过。如果要以跨平台的方式访问全局对象,可以使用以下模式:

(function (glob) {
    // glob points to global object
}(typeof window !== 'undefined' ? window : global));

从现在开始,我将使用window 来引用全局对象,但在跨平台代码中,您应该使用前面的模式和glob

window 的用例

本节介绍通过window 访问全局变量的用例。但一般规则是:尽可能避免这样做。

用例:标记全局变量

前缀window 是一个视觉提示,表明代码引用的是全局变量,而不是局部变量:

var foo = 123;
(function () {
    console.log(window.foo);  // 123
}());

但是,这会使您的代码变得脆弱。一旦您将foo 从全局作用域移至另一个周围作用域,它就会停止工作。

(function () {
    var foo = 123;
    console.log(window.foo);  // undefined
}());

因此,最好将foo 引用为变量,而不是window 的属性。如果要明确表示foo 是全局变量或类似全局变量,可以添加名称前缀,例如g_

var g_foo = 123;
(function () {
    console.log(g_foo);
}());

用例:内置函数

我更喜欢不通过window 引用内置全局变量。它们是众所周知的名称,因此您无法从指示它们是全局变量中获得多少好处。并且带前缀的window 会增加混乱:

window.isNaN(...)  // no
isNaN(...)  // yes

用例:代码风格检查器

当您使用 JSLint 和 JSHint 等代码风格检查工具时,使用window 意味着在引用当前文件中未声明的全局变量时不会收到错误。但是,这两种工具都提供了告知此类变量并防止此类错误的方法(在其文档中搜索“全局变量”)。

用例:检查全局变量是否存在

这不是一个常见的用例,但尤其是垫片和 polyfill(请参阅垫片与 Polyfill)需要检查全局变量someVariable 是否存在。在这种情况下,window 很有用:

if (window.someVariable) { ... }

这是一种执行此检查的安全方法。如果未声明someVariable,则以下语句将引发异常:

// Don’t do this
if (someVariable) { ... }

您可以通过window 进行检查的另外两种方法大致相同,但更明确一些:

if (window.someVariable !== undefined) { ... }
if ('someVariable' in window) { ... }

检查变量是否存在(并具有值)的通用方法是通过typeof(请参阅typeof:对基元进行分类

if (typeof someVariable !== 'undefined') { ... }

用例:在全局作用域中创建事物

window 允许您将事物添加到全局作用域(即使您位于嵌套作用域中),并且允许您有条件地执行此操作:

if (!window.someApiFunction) {
    window.someApiFunction = ...;
}

通常最好在全局作用域中使用var 将事物添加到全局作用域。但是,window 提供了一种有条件地进行添加的简洁方法。

环境:管理变量

当程序执行进入其作用域时,变量就会出现。然后它们需要存储空间。在 JavaScript 中,提供该存储空间的数据结构称为环境。它将变量名映射到值。其结构与 JavaScript 对象的结构非常相似。环境有时会在您离开其作用域后继续存在。因此,它们存储在堆上,而不是堆栈上。

变量以两种方式传递。如果您愿意,它们有两个维度:

这两个维度处理如下:

让我们看一个例子:

function myFunction(myParam) {
    var myVar = 123;
    return myFloat;
}
var myFloat = 1.3;
// Step 1
myFunction('abc');  // Step 2

图 16-1 说明了执行前面的代码时会发生什么:

  1. myFunctionmyFloat 已存储在全局环境 (#0) 中。请注意,myFunction 引用的function 对象通过内部属性[[Scope]] 指向其作用域(全局作用域)。
  2. 为了执行myFunction('abc'),会创建一个新的环境 (#1),用于保存参数和局部变量。它通过outer(从myFunction.[[Scope]] 初始化)引用其外部环境。借助外部环境,myFunction 可以访问myFloat

闭包:函数与其诞生作用域保持连接

如果函数离开创建它的作用域,它会保持与该作用域(以及周围作用域)的变量的连接。例如:

function createInc(startValue) {
    return function (step) {
        startValue += step;
        return startValue;
    };
}

createInc() 返回的函数不会丢失与其startValue 的连接,该变量为函数提供了在函数调用之间持久化的状态。

> var inc = createInc(5);
> inc(1)
6
> inc(2)
8

闭包是一个函数加上与其创建时作用域的连接。该名称源于闭包“封闭”了函数的自由变量。如果变量未在函数中声明(即,如果它来自“外部”),则该变量是自由的。

通过环境处理闭包

提示

这是一个高级部分,将更深入地介绍闭包的工作原理。您应该熟悉环境(请回顾环境:管理变量)。

闭包是执行离开其作用域后环境仍然存在的示例。为了说明闭包的工作原理,让我们检查之前与createInc() 的交互,并将其分为四个步骤(在每个步骤中,活动执行上下文及其环境都突出显示;如果函数处于活动状态,则也会突出显示):

  1. 此步骤发生在交互之前,以及在评估createInc 的函数声明之后。已将createInc 的条目添加到全局环境 (#0) 中,并指向一个函数对象。

    image with no caption
  2. 此步骤发生在函数调用createInc(5) 执行期间。为createInc 创建了一个新的环境 (#1),并将其推送到堆栈上。其外部环境是全局环境(与createInc.[[Scope]] 相同)。该环境保存参数startValue

    image with no caption
  3. 这一步发生在赋值给 inc 之后。在我们从 createInc 返回后,指向其环境的执行上下文将从堆栈中移除,但环境仍然存在于堆中,因为 inc.[[Scope]] 引用了它。inc 是一个闭包(函数加上诞生环境)。

    image with no caption
  4. 这一步发生在执行 inc(1) 期间。一个新的环境(#1)已经被创建,并且一个指向它的执行上下文已经被推送到堆栈上。它的外部环境是 inc[[Scope]]。外部环境使 inc 可以访问 startValue

    image with no caption
  5. 这一步发生在执行 inc(1) 之后。没有引用(执行上下文、outer 字段或 [[Scope]])指向 inc 的环境。因此,它不再需要,可以从堆中移除。

    image with no caption

陷阱:无意中共享环境

有时,您创建的函数的行为会受到当前作用域中变量的影响。在 JavaScript 中,这可能会有问题,因为每个函数都应该使用该变量在函数创建时的值。但是,由于函数是闭包,因此该函数将始终使用该变量的当前值。for 循环中,这可能会阻止事情正常工作。一个例子将使事情更清楚:

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        var func = function () {
            return i;
        };
        result.push(func);
    }
    return result;
}
console.log(f()[1]());  // 3

f 返回一个包含三个函数的数组。所有这些函数仍然可以访问 f 的环境,因此也可以访问 i。实际上,它们共享相同的环境。唉,在循环结束后,i 在该环境中的值为 3。因此,所有函数都返回 3

这不是我们想要的。为了解决这个问题,我们需要在创建一个使用索引 i 的函数之前对其进行快照。换句话说,我们希望将每个函数与函数创建时 i 的值打包在一起。因此,我们采取以下步骤

只有函数才能创建环境,因此我们使用 IIFE(参见 通过 IIFE 引入新的作用域)来完成步骤 1

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        (function () { // step 1: IIFE
            var pos = i; // step 2: copy
            var func = function () {
                return pos;
            };
            result.push(func);
        }());
    }
    return result;
}
console.log(f()[1]());  // 1

请注意,该示例与实际情况相关,因为当您通过循环将事件处理程序添加到 DOM 元素时,会出现类似情况。

下一页:17. 对象和继承