深入学习 JavaScript 系列(二):闭包
小举 筑基

闭包是 JavaScript 这门语言中一个非常重要但又难以掌握,近乎神话的概念。关于闭包的深入理解,我们借用一下 Crockford 的话:“魔术师的幕后藏着一个人,我们将要揭开它的伪装。”

首先请牢记一个秘诀:JavaScript 中闭包无处不在,你只需要能够识别并拥抱它。闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它而有意的创建闭包。闭包的创建和使用在你的日常代码中随处可见,你缺少的只是根据你自己的意愿来识别、拥抱和影响闭包的思维环境

1. 从问题和疑惑开始

首先来直接了当的贴出闭包的定义:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。来看下面的代码:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log(a);
}
bar();
}

foo(); // 2

基于词法作用域的查找规则,函数bar()内部可以访问外部作用域(也就是 foo 函数的作用域)中的变量a(这个例子中的是一个 RHS 的引用查询)。

请问这个是闭包吗?

技术上来讲,也许是。但是根据刚才的定义,确切地说并不是。我认为最准确的用来解释bar()内部对a的引用的方法是词法作用域的查找规则,而这只是闭包的一部分。

分析一下上面的代码片段,函数bar()具有一个涵盖foo()作用域的闭包(事实上不光是 foo,它涵盖了所能访问的所有作用域,比如全局作用域)。也可以认为bar()封闭在了foo()的作用域中(很简单,因为bar()嵌套在foo()内部)。但是通过这种方式定义的闭包并不能直接进行观察。再来看下面的代码是如何清晰的展示闭包的:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2 -- 这就是闭包的效果

按照词法作用域查找规则,全剧作用域是看不到且访问不到foo()作用域中的变量 a 的,但是现在我们却打印出了a的值,这是怎么回事呢?

函数bar()的词法作用域能够访问foo()的内部作用域,然后我们将bar()函数本身当作一个值类型进行传递,我们将 bar 所引用的函数对象本身当作值返回。

foo()执行后,其返回值(也就是内部的函数bar)赋值给变量baz并调用baz(),实际上只是通过不通的标识符引用调用了内部函数bar()。(表示符barbaz指向的是同一个函数对象)

bar()显然可以被正常执行,但是在这个例子中,它是在定义自己的词法作用域以外的地方被执行的(bar函数是在foo函数的内部被定义的,但是确是在全局作用域被调用执行的)。

foo()执行之后,通常会期待foo()的整个内部作用域都会被销毁,因为我们知道引擎有垃圾回收机制来释放不再使用的内存空间,由于看上去foo()内部的内容不会再被使用,因此很自然的考虑对其进行回收。而闭包的“神奇之处”就在于可以组织这件事情发生,事实上foo()内部作用域依然存在而没有被回收,那是谁还在使用这个内部作用域呢?或者说谁还在引用这个内部作用域中的变量呢?答案是bar()函数在使用,还记得bar()函数的内部仍然在访问foo()函数内部作用域中的变量a吗?

💡 拜bar()函数声明的位置所赐,它拥有涵盖整个foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对foo()内部作用域的引用,而这个引用本身就叫做闭包。尽管这个函数在定义它的词法作用域以外的地方被调用,但闭包使得该函数可以继续访问定义它时的那个词法作用域。

无论你使用何种方式对函数类型的值进行传递,当函数在别处进行调用时都可以观察到闭包,来看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;
function bar() {
console.log(a);
}
baz(bar);
}

function baz(fn) {
fn(); // 这个例子中的 fn 指向的就是 bar 函数
}

foo(); // 2

把内部函数bar当作参数传递给baz函数,baz函数内部直接调用这个函数(在baz内部叫做 fn),这个fn()函数携带着涵盖foo()内部作用域的闭包,因此可以访问到变量a

传递函数也可以是间接的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fn;

function foo() {
var a = 2;
function bar() {
console.log(a);
}
fn = bar;
}

function baz() {
fn(); // 这也是闭包
}

foo();

baz(); // 2

无论通过何种手段将内部函数传递定义它时的那个词法作用域之外的地方,它都会持有对原始定义它的那个作用域的引用,无论在何处执行这个函数都会使用闭包。

2. 闭包无处不在

其实我们平时写过的代码中到处都是闭包的身影:

1
2
3
4
5
6
7
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}

wait('Hello, closure!'); // 1 秒后输出 "Hello, closure!"

在这代码片段中,我们将一个内部函数timer作为参数传递给setTimeout函数,time()函数具有涵盖wait()函数内部词法作用域的闭包,因此还保有对变量message的引用。wait()函数执行 1000 毫秒以后,它的内部作用域并不会消失,timer 函数依然保有wait()作用域的闭包。

💡 本质上无论何时何地,如果将函数(内部会访问它所在的词法作用域的变量)作为第一级的值类型并到处传递,我们就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或同步任务)中,只要使用了回调函数,实际上就是在使用闭包!

对了,之前介绍的 IIFE 模式算是闭包吧?

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

严格上来讲它并不算是闭包,为什么呢?因为函数 IIFE 并不是在定义它的词法作用域之外被执行的。它是在定义时所在的词法作用域中执行的,变量 a 是通过普通的词法作用域查找规则而非闭包被发现的。

尽管 IIFE 本身并不是观察闭包的恰当例子,但时它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具。

3. 循环和闭包

要说明闭包,循环是一个很好的例子:

1
2
3
4
5
for(var i = 1; i <= 5; i++>) {
setTimeout(function timer(){
console.log(i);
}, i * 1000)
}

正常情况我们对这段代码的运行期望是分别输出数字 1、2、3、4、5,每秒一次,每次一个数字。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。这是为什么?

首先,6 是从哪里来的呢?这个循环的终止条件是 i 不再 <= 5,条件首次成立时 i 的值为 6,因此输出显示的结果是循环结束时 i 的最终值。

我们知道,传递给setTimeout 的回调函数会在指定的时间之后被放入循环队列等待执行,最起码也要等到主线程的同步代码执行完之后才能执行,因此这些函数一定是会在循环结束后才执行的。事实上,即使每个迭代传递给setTimeout的延迟时间是 0,所有的回调函数也依然会在循环结束后才会被执行。

那么代码中是什么“缺陷”导致了它的行为和语义所描述的不一致呢?

这段代码里我们试图”假设“循环中的每一次迭代在运行时都会给自己捕获一个 i 的副本。但是根据作用域的原理,实际情况是:尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在同一个共享的全局作用域中,而 i 也是在那个共享的作用域中定义的,因此这些函数访问的是同一个 i, 实际上也只有这一个 i。 循环让我们误以为背后有更复杂的机制在起作用,实际上并没有。

问题清楚了,那么如何解决呢?

其实我们需要的是闭包作用域,特别是在循环过程中的每一次迭代都需要一个闭包作用域。这样每次迭代中定义的函数 timer 都可以访问自己携带的闭包作用域中的互相独立的 i。

3.1 解决方案一:IIFE

那如何创建闭包作用域呢?还记得前面提到的 IIFE 吗?我们说 IIFE 是最常用来创建可以被封闭起来的闭包的工具。那么我们用 IIFE 来试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (var i = 1; i <= 5; i++) {
(function IIFE() {
var j = i; // 在每一次迭代中我们都在这个封闭作用域中定义一个属于自己的 j,它的值等于当前迭代的 i
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})();
}
// 再来改进一下
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i); // 直接把每次迭代的 i 作为参数传递给 IIFE,其实等价于 var j = i;
}

在迭代内部使用 IIFE 会为每个迭代都生成一个全新的封闭作用域,使得延迟的回调函数 timer 可以将新的作用域封闭在每次迭代的内部。这样,每个迭代都可以访问自己的 j 变量,而不会影响到其他迭代的 j 变量。

3.2 解决方案二:块作用域

仔细思考前面 IIFE 的解决方案,我们使用 IIFE 在每次迭代时都创建了一个新的封闭作用域,换句话说,我们每次迭代都需要一个块作用域。那除了使用 IIFE 来直接创建一个封闭的作用域外,还有其他方法吗?当然!还记得前面介绍的 let 吗? let 可以用来“劫持”块作用域。

使用 let 本质上是将一个块转换成一个可以被关闭的作用域。因此,下面的代码就可以正常运行了:

1
2
3
4
5
6
for (var i = 1; i <= 5; i++) {
let j = i; // 这里使用 let 而不是 var 来定义 j
setTimeout(function timer() {
console.log(j);
}, j * 1000);
}

这段代码还可以进一步改进,我们知道,for 循环头部的 let 声明还会有一个特殊的行为,这个行为指出:变量在循环迭代过程中不止被声明一次,而是每次迭代都会被重新声明。随后的每一个迭代都会使用上一个迭代结束时的初始值来初始化这个变量。

1
2
3
4
5
6
// 注意这里直接使用 let 声明 i 变量
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

4. 模块

闭包的强大威力还体现在其他代码模式中,接下来我们来研究一下其中最强大的一个:模块。

先看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
var something = 'cool';
var another = [1, 2, 3];

function doSomething() {
console.log(something);
}

function doAnother() {
console.log(another.join('!'));
}
}

这段代码里并没有明显的闭包,只有两个私有数据变量 something 和 another,以及两个内部函数 doSomething 和 doAnother。它们的词法作用域就是 foo 函数的内部作用域(而这个就是闭包)。

再看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
return {
doSomething: doSomething,
doAnother: doAnother,
};
}

var foo = CoolModule();

foo.doSomething(); // "cool"
foo.doAnother(); // "1!2!3"

这个模式在 JavaScript 中被称为模块

我们仔细来分析一下这段代码。

首先 CoolModule 只是一个普通的函数,必须要调用它来创建一个模块实力。如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,CoolModule()返回一个用对象字面量语法{key: value}来表示的对象,这个对象内部含有对内部函数的引用而不是内部私有数据变量的引用,这样我们就保证了内部数据变量时隐藏且私有的状态,我们可以将这个对象类型的返回值看作是模块暴露的公共 API

然后这个对象类型的返回值被赋值给外部变量 foo,然后就可以通过它来访问 API 中的属性和方法了,比如foo.doSomething()foo.doAnother()doSomething()doAnother()函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实现)。当通过返回一个含有属性引用的对象的方式来将内部函数传递到词法作用域外部时,我们就创建了可以观察和实践闭包的条件。

简单的总结一下,模块模式需要两个必要条件:

  1. 必须有外部的封闭函数,该函数必须被至少调用一次(每一次调用都会创建一个新的模块实例)。
  2. 封闭函数必须至少返回一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

4.1 单例模式

刚才的例子中的CoolModule()可以称之为一个模块创建器,它可以被调用任意多次,每次调用都会创建一个新的模块实例。但是当我们只需要一个实例时(单例模式),可以对这个模式进行简单的改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var foo = (function CoolModule() {
var something = 'cool';
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join('!'));
}
return {
doSomething: doSomething,
doAnother: doAnother,
};
})();
foo.doSomething(); // "cool"
foo.doAnother(); // "1!2!3"

我们将模块函数变成了 IIFE,立即调用这个函数并将返回值直接赋值给单例的模块标识符 foo。

模块也是普通的函数,也可以接受参数,比如下边的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function CoolModule(id) {
function identify() {
console.log(id);
}
return {
identify: identify,
};
}

var foo1 = CoolModule('foo1');
var foo2 = CoolModule('foo2');

foo1.identify(); // "foo1"
foo2.identify(); // "foo2"

4.2 动态命名模块公共 API

模块模式的另一个简单而又强大的用法是命名将要作为公共 API 返回的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var foo = (function (id) {
function change() {
publicAPI.identify = identify2;
}
function identify1() {
console.log('identify1');
}
function identify2() {
console.log('identify2');
}
var publicAPI = {
change: change,
identify: identify1,
};
})('foo module');
foo.identify(); // "identify1"
foo.change();
foo.identify(); // "identify2"

上面的代码片段通过在模块实例内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或者删除方法和属性,以及修改它们的值。

4.3 现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装在一个友好的 API。先来看一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var MyModules = (function (modules) {
function define(name, deps, implementation) {
for (var i = 0; i < deps.length; i++) {
deps[i] = modules[deps[i]]; // 这一步是将依赖数组中的模块标识符(字符串)变为实际对应的模块实例引用
}
modules[name] = implementation.apply(implementation, deps);
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get,
};
})();

这段代码的核心是modules[name] = implementation.apply(implementation, deps)为模块的定义引入了包装函数(可以传入任何依赖),并且将返回值也就是模块的公共 API,存储在一个根据名字来管理的模块列表对象中。

这样,我们就可以像下面这样定义模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MyModules.define('bar', [], function () {
function hello(who) {
return `let me introduce: ${who}`;
}
return {
hello: hello,
};
});

MyModules.define('foo', ['bar'], function (bar) {
var hungry = 'hippo';
function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}
return {
awesome: awesome,
};
});

可以看到,define()函数的第三个参数是一个函数,这个函数就是模块定义函数,正如前面所说的那样,调用这个函数将会返回一个模块实例对象。我们实现的这个模块管理器甚至允许模块定义函数接受其他模块的实例作为参数,这样在模块定义函数的内部就可以用其他模块实例的 API 了。

4.4 ES6 中的模块机制

ES6 中为模块增加了一级语法支持,在通过模块系统进行加载时,ES6 会将文件当作独立的模块来处理。ES6 的模块没有“行内”格式,必须被定义在一个独立的文件中(一个文件一个模块)。浏览器或者引擎有一个默认的“模块加载器”,可以在倒入模块的同时同步地加载模块文件。

基于函数的模块(例如我们前面提到的那些例子)并不是一个能被静态识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来,因此我们可以在运行时修改一个模块暴露出来的 API。相比之下, ES6 中的模块 API 是静态的(API 不会在运行时改变)。由于编辑器知道这一点,因此可以在编译器检查期间对导入模块的 API 成员引用是否存在进行检查,如果并不存在,编译器会在编译时就抛出“早期”错误,而不会等到运行时再动态解析并报错。

5. 总结

闭包看起来神秘且难以理解,但实际上它只是一个非常普通且明显的事实:那就是我们在词法作用域的环境下写代码,而函数也是值,可以被随意的传递来传递去。

当函数可以记住并且访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时便产生了闭包

闭包是一个非常强大的工具,可以利用它来实现模块等模式,模块主要有两个特征:

  1. 为了创建一个内部作用域而调用了一个包装函数;
  2. 包装函数的返回值必须至少包含一个对内部函数的引用,这样就会创建一个涵盖整个包装函数内部作用域的闭包