网上关于 js 闭包的文章多如牛毛,这里之所以再写一篇,主要是因为网上的那些文章要么对初学者不够友好,要么根本就没有谈到重点。在读过那些文章后的很长一段时间里,我对闭包都是似懂非懂。直到在学 react 的过程中逐渐接触函数式编程,才开始真正理解闭包。

Trick

请先思考一下下面两段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createFunction() {
let arr = [];

for (var i = 0; i < 5; i++) {
arr[i] = function () {
return i;
};
}

return arr;
}

const result = createFunction().map(e => e());

console.log(result); // [5, 5, 5, 5, 5]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function counter() {
let n = 0;
return {
increase() { ++n; },
get() { return n; },
};
}

const cnt1 = counter();
const cnt2 = counter();

cnt1.increase();

console.log(cnt1.get()); // 1
console.log(cnt2.get()); // 0

在往下阅读之前,请再次确保你有花时间理解上面两段代码。虽然它们跟下文内容并没有一毛钱关系,不过反正没事烧烧脑也没什么坏处。。。
这里我想表达的只是,网上大量关于闭包的文章大抵都遵循这个模式,先制造一堆跟上面例子类似的函数,之后让读者尝试给出运行结果,最后在配合上自己的一顿讲解,仿佛能理解这些代码就是懂了闭包。而事实却是,能看懂这些代码并不代表你就理解了闭包,理解闭包之后再看这些代码也不一定就都能立刻指出运行结果。

Definition

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为“闭包”。从技术的角度讲,所有的javascript函数都是闭包

闭包是指有权访问另一个函数作用域中的变量的函数

上面两段话分别摘自中译本的《javascript权威指南》和《javascript高级程序设计》,不知道你看懂了没,反正我是看不懂。(不过依然强烈推荐 js 初学者选择这两本书之一来入门 js)

正片

上面无论是代码还是文字,都不是我这种智商能够轻易理解的。我的大脑能够正常处理的代码应该长这样:

1
2
3
4
5
function add(x, y){
return x + y;
}

add(1, 2) // => 3

在学过一些函数式编程入门知识后,勉强可以接受这样:
1
2
3
4
5
6
7
function add(x){
return function addx(y){
return x + y;
}
}

add(1)(2) // => 3

以上将原本接受两个参数的函数转换为只接受单个参数的函数,这个过程又称为 柯里化。柯里化后的 add 函数返回值是另一个函数。一些程序员可能并不习惯这种用法,因为在某些编程语言中函数只能用来操作数据,不能操作函数。而在 js 中函数也是数据的一种,用面向对象程序员熟悉的话来说就是,函数是数据的子类型,它们之间满足里氏替换原则。也就是说在 js 中, 函数实现了数据拥有的所有行为,可以去任何数据能够去的地方。
现在不妨来思考一下,我们应该用什么样的数据结构来表达函数这种数据类型?最简单粗暴的方法莫过于直接使用函数的源码字符串,早期的 lisp 语言就是这么做的。但是这种做法有一个致命的缺陷,考虑上面调用add(1)后返回的这个函数:
1
2
3
function addx(y){
return x + y;
}

它的字符串表示形式是 "function addx(y){return x + y;}",当我们试图调用它时会发现没办法解释其中的自由变量 x,一个可能的解决方案是采取就近原则,在该函数的调用栈中去寻找 x 的值,于是产生了如下让人匪夷所思的执行结果:
1
2
3
add(1)(2)         // => NaN   因为执行环境没有定义x, 所以得到undefined + 2;
const x = 100;
add(1)(2) // => 102 在执行环境找到x,所以得到100 + 2

上面这种解释方案又被称为动态作用域(dynamic scoping),虽然它的确实现了将函数作为数据传递,但是也直接导致了函数的行为无法预测。因此,现代编程语言普遍采用的是另一种称为词法作用域(lexical scoping)的解释方案。这种方案强调的是,应该使用函数定义时的作用域来解释函数中的自由变量,这样才能让函数的行为与定义时预期的行为保持一致。为了实现这一点,很容易想到应该把用来保存函数的数据结构改成如下形式:
1
2
3
4
5
6
{
func: "function addx(y){return x + y;}",
scope: {
x: 1,
},
}

而这种既包含函数的代码逻辑,又包含函数定义时作用域的数据结构就是闭包。现在当执行函数遇到自由变量时,直接在闭包中查找定义时该变量的值就可以了。
1
2
3
4
const addx = add(1);
addx(2) // => 3
const x = 100;
addx(2) // => 3

至此,闭包的概念也就解释完了。

Function.prototype.bind

最后再提一个 js 新手可能不知道的小技巧,使用函数的 bind 方法,可以将函数的绑定变量转换为自由变量,同时将该变量加入闭包作用域。继续拿上面的例子来说,原函数是:

1
2
3
function add(x, y){
return x + y;
}

可以通过const addx = add.bind(null, 1)来得到一个跟上面调用add(1)返回的函数等价的函数。