JS基础(四)闭包

欢迎阅读,本篇主要介绍JS中的作用域闭包。

前言

JavaScript中闭包无处不在,你只需要能够识别并拥抱它。闭包是基于词法作用域书写代码时产生的自然结果,你甚至不需要为了利用它们而有意的创建闭包,闭包的创建和使用在你的代码中随处可见。

什么是闭包?

当函数可以记住并访问所在词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
例子:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;

function bar() {
console.log(a); // 2
}
bar();
}
foo();

这段代码看起来和嵌套作用域中的示例代码很相似。基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量a。
这是闭包吗?
技术上讲也许是,但根据前面的定义,确切的说并不是。最准确的用来解释bar()对a的引用的方法是词法作用域的查找规则,而这些规则只是闭包中的一部分(非常重要的一部分)。
从纯学术角度,在上述代码中,函数bar()具有一个涵盖foo()作用域的闭包,也可以认为bar()封闭在了foo()的作用域中,原因很简单,因为bar()嵌套在foo()内部。
但通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个片段中闭包是如何工作的。

闭包的例子

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 2;

function bar() {
console.log(a);
}
return bar;
}

var baz = foo();
baz(); // 2 朋友,这就是闭包的效果

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。
在函数foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同标识符引用调用了内部的函数bar()。bar()显然可以正常运行,但在这个例子中,它在自己定义的词法作用域以外的地方执行。
在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以自然会被考虑对其进行回收。
闭包的神奇之处正式可以阻止这件事情发生。事实上内部作用域依然存在,bar()本身仍在使用这个内部作用域。拜bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。
bar()依然持有对该作用域的引用,而这个引用就叫闭包。

函数在词法作用域以外的地方被调用,当函数在别处被调用时都可以观察到闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
var a = 2;

function baz() {
console.log(2); // 2
}
bar(baz);
}

function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包
}

foo();

把内部函数baz传递给bar,当调用这个内部函数时,它涵盖的foo()内部作用域的闭包就可以观察到了,因此它能访问a。
传递函数当然也可以是间接的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var fn;
function foo() {
var a = 2;

function baz() {
console.log(a);
}
fn = baz; // 将baz分配给全局变量
}

function bar() {
fn();
}

foo();
bar(); // 2

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

你写过的代码

前面的代码片段有点死板,并且为了解释如何使用闭包而人为地在结构上进行修饰。但你写过的代码中一定到处都是闭包的身影。

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

将一个内部名为timer的函数传递给setTimeout(…)。timer具有涵盖wait(…)作用域的闭包,因此还保有对变量message的引用。
wait(…)执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait(…)作用域的闭包。

在引擎内部,内置的工具函数setTimeout(…)持有对一个参数的引用,这个参数也许叫作fn或func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部timer函数,而词法作用域在这个过程中保持完整。
这就是闭包。
如果将(访问他们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

再看一个例子:

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

虽然这段代码可以正常运行,但严格来讲它并不是闭包。因为函数IIFE并不是在它本身的词法作用域以外执行的,它在定义时所在的作用域中执行,a是通过普通的词法作用域查找而非闭包被发现的。

循环和闭包

要说明闭包,for循环是最常见的例子。

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

正常情况下,我们对这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。但实际上,它会以每秒一次的频率输出五次6。
为啥呢?
我们很容易知道6是从哪来的。这个循环的终止条件是i不再<=5,条件首次成立时i的值是6,因此,输出显示的是循环结束时i的最终值。
仔细思考,延迟函数的回调会在循环结束时才执行。事实上,定时器运行时即使每个迭代中执行的是setTimeout(…, 0),所有回调函数依然是在循环结束后才会被执行,因此会每次输出一个6出来。
代码中到底什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷就是我们试图假设循环中的每个迭代在运行时都会自己“捕获”一个i的副本。但根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各自迭代中分别被定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上是一个i
怎么弥补“缺陷”呢?我们需要更多的闭包作用域,特别是循环的过程中每个迭代都需要一个闭包作用域。
下面的例子能行吗?

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

遗憾的是,这样并不行,为什么?不是已经拥有更多的词法作用域了吗?的确每个延迟函数都会将IIFE在每次迭代中创建的作用域封闭起来。但如果作用域是空的,那么仅仅将它们封闭起来是不够的
它需要有自己的变量,用来在每个迭代中存储i的值:

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

对这段代码进行改进:

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

在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

let的用法
前面我们使用IIFE在每次迭代时都创建一个新的作用域,换句话说,每次迭代我们都需要一个块作用域,我们前面介绍的let声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。

1
2
3
4
5
6
for(var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域
setTimeout( function timer() {
console.log(j);
}, j*1000);
}

但是,这还不是全部!,for循环头部的let声明还会有一个特殊行为,这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

是不是就变成了你常写的形式。

模块

还有其他代码模式利用闭包的强大威力,但从表面看,它们似乎与回调无关,下面我们来研究其中最强大的一个:模块

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
20
21
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。
模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块示例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
    一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

模块机制

ES6中为模块增加了一级语法支持。在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。
import可以将一个模块中的一个或多个API导入到当前作用域中,分别绑定在一个变量上。module会将整个模块的API导入并绑定到一个变量上。export会将当前模块的一个标识符(变量、函数)导出为公共API。
模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。