八、JavaScript之闭包

定义

闭包在 MDN 中的定义:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

这里只有一类函数除外,那就是通过 Function 构造器创建的函数,因为其[[Scope]]只包含全局对象。

为了更好的澄清该问题,我们对 ECMAScript 中的闭包给出 2 个正确的版本定义:

ECMAScript 中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
  3. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  4. 在代码中引用了自由变量

下面我们主要讨论实践上的闭包。

分析

这里我们需要注意的是:在 ECMAScript 中,同一个父上下文中创建的闭包是共用一个[[Scope]]属性的。也就是说,某个闭包对其中[[Scope]]的变量做修改会影响到其他闭包对其变量的读取:

这就是说:所有的内部函数都共享同一个父作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var firstClosure;
var secondClosure;

function foo() {
var x = 1;

firstClosure = function () {
return ++x;
};
secondClosure = function () {
return --x;
};

x = 2; // 影响 AO["x"], 在2个闭包公有的[[Scope]]中

console.log(firstClosure()); // 3, 通过第一个闭包的[[Scope]]
}

foo();

console.log(firstClosure()); // 4
console.log(secondClosure()); // 3

因为我们经常会遇到这样一个面试题:

1
2
3
4
5
6
7
8
9
10
11
var data = [];

for (var k = 0; k < 3; k++) {
data[k] = function () {
console.log(k);
};
}

data[0](); // 3, 而不是0
data[1](); // 3, 而不是1
data[2](); // 3, 而不是2

上述当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

1
2
3
4
5
6
globalContext = {
VO: {
data: [...],
k: 3
}
}

当执行 data[0] 函数的时候,data[0] 函数的作用域链为:

1
2
3
data[0]Context = {
Scope: [AO, globalContext.VO]
}

data[0]Context 的 AO 并没有 k 值,所以会从 globalContext.VO 中查找,k 为 3,所以打印的结果就是 3。data[1] 和 data[2] 是一样的道理。
如下所示,创建一个闭包就可以解决这个问题了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var data = [];

for (var k = 0; k < 3; k++) {
data[k] = (function _helper(x) {
return function () {
console.log(x);
};
})(k); // 传入"k"值
}

// 现在结果是正确的了
data[0](); // 0
data[1](); // 1
data[2](); // 2

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

1
2
3
4
5
6
globalContext = {
VO: {
data: [...],
k: 3
}
}

跟之前一样,但是 data[0]的作用域链发生了变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data[0]Context = {
Scope: [AO,_helperContext.AO, globalContext.VO]
}
// 匿名函数执行上下文的 AO 为:
_helperContext = {
VO: {
arguments: {
0: 0,
length: 1
},
k: 0
},
Scope: [AO, globalContext.VO]
}

data[0]Context 的 AO 并没有 k 值,所以会沿着作用域链从_helperContenx.AO 中查找,这时候找到 k 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 K 的值(值为 3),所以打印的结果就是 0。同理 data[1]、data[2]的值就是 1 和 2。

闭包用法实战

实际使用的时候,闭包可以创建出非常优雅的设计,允许对funarg上定义的多种计算方式进行定制。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 数组排序,它接受一个排序条件函数作为参数
[1, 2, 3].sort(function (a, b) {
... // 排序条件
});

// 数组的map方法是根据函数中定义的条件将原数组映射到一个新的数组中:
[1, 2, 3].map(function (element) {
return element * 2;
}); // [2, 4, 6]

// 数组的find方法实现一个搜索功能,并且可以支持无限制的搜索条件:
someCollection.find(function (element) {
return element.someProperty == 'searchCondition';
});

// 还有应用函数,比如常见的forEach方法,将函数应用到每个数组元素:
[1, 2, 3].forEach(function (element) {
if (element % 2 != 0) {
console.log(element);
}
}); // 1, 3

// 顺便提下,函数对象的 apply 和 call方法,在函数式编程中也可以用作应用函数。 apply和call已经在讨论“this”的时候介绍过了;这里,我们将它们看作是应用函数 —— 应用到参数中的函数(在apply中是参数列表,在call中是独立的参数):
(function () {
alert([].join.call(arguments, ';')); // 1;2;3
}).apply(this, [1, 2, 3]);

// 闭包还有另外一个非常重要的应用 —— 延迟调用:
var a = 10;
setTimeout(function () {
alert(a); // 10, after one second
}, 1000);

// 还有回调函数
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
// 当数据就绪的时候,才会调用;
// 这里,不论是在哪个上下文中创建
// 此时变量“x”的值已经存在了
alert(x); // 10
};

// 还可以创建封装的作用域来隐藏辅助对象:
var foo = {};
(function (object) {

var x = 10;

object.getX = function _getX() {
return x;
};

})(foo);

alert(foo.getX()); // 获得闭包 "x" – 10

参考文献

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures
https://juejin.cn/post/6844903475998900237
http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/