JavaScript系列之闭包

相信很多初学者在学习JavaScript 的时候,一直对闭包(closure) 有所疑惑。因为从字面上来看,完全看不出它所代表的东西。那么今天,我想通过这篇文章,尽量用简单易懂的话来与各位介绍「闭包」到底是什么。

在具体介绍闭包之前,为了更好的理解本文要介绍的内容,建议先去阅读前面的文章《JavaScript系列之变量对象》《JavaScript系列之作用域和作用域链》,因为它们相互之间都是有关联的。

闭包是什么?

首先,先来看看MDN 对闭包的定义:

闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?

自由变量是一个既不是函数的形参,也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

好,如果上面三行就看得懂的话那么就不用再往下看了,Congratulations!

…… 不过如果你是初学者的话,我想应该不会,如果仅用三言两语就把闭包讲通,那还能称为Javascript 语言的一个难点吗?

先来举个例子:

1
2
3
4
5
6
7
var n = 1;

function f1() {
console.log(n); // 1
}

f1()

f1 函数可以访问变量 n,但是 n 既不是 f1 函数的形参,也不是 f1 函数的局部变量,所以这种情况下的 n 就是自由变量。其实上面代码中就存在闭包了,即函数 f1 + f1 函数访问的自由变量 n 就构成了一个闭包

上面代码中,函数 f1 可以读取全局自由变量 n。但是,函数外部无法读取函数内部声明的变量:

1
2
3
4
5
function f1() {
var n = 1;
}

console.log(n) // Uncaught ReferenceError: n is not defined

如果有时需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过改变形式才能实现。那就是在函数的内部,再定义一个函数。

1
2
3
4
5
6
7
8
9
10
function f1() {
var n = 1;
function f2() {
console.log(n); // 1
}
return f2;
}

var a = f1();
a();

上面代码中,函数f2就在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。既然f2可以读取f1的局部变量,那么只要把f2作为返回值,我们就可以在f1外部读取它的内部变量了。

所以闭包是一个可以从另一个函数的作用域访问变量的函数。这是通过在函数内创建函数来实现的。当然,外部函数无法访问内部范围

在我们深入研究闭包之前,有必要先从不使用闭包的情况切入,了解为什么要用闭包。

不使用闭包的情况

在JavaScript 中,全局变量的错用可能会使得我们的代码出现不可预期的错误。

假设我们现在要做一个计数的函数,一开始我们想要先写一个给狗的计数函数:

1
2
3
4
5
6
7
8
9
10
11
// 狗的计数函数
var count = 0

function countDogs () {
count += 1
console.log(count + ' dog(s)')
}

countDogs() // 1 dog(s)
countDogs() // 2 dog(s)
countDogs() // 3 dog(s)

接着继续写代码的其他部分,当写到后面时,我发现我也需要写猫的计数函数,于是我又开始写了猫的计数函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 狗的计数函数
var count = 0

function countDogs () {
count += 1
console.log(count + ' dog(s)')
}


// 中间的其它代码...

// 猫的计数函数
var count = 0

function countCats () {
count += 1
console.log(count + ' cat(s)')
}

countCats() // 1 cat(s)
countCats() // 2 cat(s)
countCats() // 3 cat(s)

乍看之下好像没啥问题,当我执行countDogs()countCats(),都会让count增加,然而问题在于当我在不注意的情况下把count这个变量建立在了全局作用域底下时,不论是执行countDogs()或是countCats()时,都是用到了全局的count变量,这使得当我执行下面的代码时,它没有办法分辨现在到底是在对狗计数还是对猫计数,进而导致把猫的数量和狗的数量交错计算的错误情况:

1
2
3
4
5
6
7
8
countCats()    // 1 cat(s)
countCats() // 2 cat(s)
countCats() // 3 cat(s)

countDogs() // 4 dog(s),我希望是 1 dog(s)
countDogs() // 5 dog(s),我希望是 2 dog(s)

countCats() // 6 cat(s),我希望是 4 cat(s)

闭包让函数有私有变量

从上面的例子我们知道,如果错误的使用全局变量,很容易会出现一些莫名其妙的bug ,这时候我们就可以利用闭包(closure)的写法,让函数有自己私有变量,简单来说就是countDogs里面能有一个计算dogscount变数;而countCats里面也能有一个计算catscount变量,两者是不会互相干扰的。

为了达到这样的效果,我们就要利用闭包,让变量保留在该函数中而不会被外在环境干扰。

改成闭包的写法会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
function dogHouse () {
var count = 0
function countDogs () {
count += 1
console.log(count + ' dogs')
}
return countDogs
}

const countDogs = dogHouse()
countDogs() // "1 dogs"
countDogs() // "2 dogs"
countDogs() // "3 dogs"

这样我们就将专门计算狗的变量count闭包在dogHouse这个函数中,在dogHouse这个函数中里面的countDogs()才是我们真正执行计数的函数,而在dogHouse这个函数中存在count这个变量,由于JavaScript变量会被缩限在函数的执行上下文中,因此这个count的值只有在dogHouse里面才能被取用,在dogHouse函数外是取用不到这个值的。

接着因为我们要能够执行在dogHouse中真正核心countDogs()这个函数,因此我们会在最后把这个函数给return出来,好让我们可以在外面去调用到dogHouse里面的这个countDogs()函数。

最后当我们在使用闭包时,我们先把存在dogHouse里面的countDogs拿出来用,并一样命名为countDogs(这里变量名称可以自己取),因此当我执行全局中的countDogs时,实际上会执行的是dogHouse里面的countDogs函数。

上面这是闭包的基本写法:一个函数里面包了另一个函数,同时会 return 里面的函数让我们可以在外面使用到它

我们可以把我们最一开始的代码都改成使用闭包的写法:

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
function dogHouse () {
var count = 0
function countDogs () {
count += 1
console.log(count + ' dogs')
}
return countDogs
}

function catHouse () {
var count = 0
function countCats () {
count += 1
console.log(count + ' cats')
}
return countCats
}

const countDogs = dogHouse()
const countCats = catHouse()

countDogs() // "1 dogs"
countDogs() // "2 dogs"
countDogs() // "3 dogs"

countCats() // "1 cats"
countCats() // "2 cats"

countDogs() // "4 dogs"

当我们正确地使用闭包时,虽然一样都是使用count来计数,但是是在不同执行环境内的count因此也不会相互干扰。

进一步了解和使用闭包

另外,甚至在运用的是同一个dogHouse 时,变量间也都是独立的执行环境不会干扰,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function dogHouse () {
var count = 0
function countDogs () {
count += 1
console.log(count + ' dogs')
}
return countDogs
}

// 虽然都是使用 dogHouse ,但是各是不同的执行环境
// 因此彼此的变量不会互相干扰

var countGolden = dogHouse()
var countPug = dogHouse()
var countPuppy = dogHouse()

countGolden() // 1 dogs
countGolden() // 2 dogs

countPug() // 1 dogs
countPuppy() // 1 dogs

countGolden() // 3 dogs
countPug() // 2 dogs

将参数代入闭包中

但是这么做的话你可能觉得还不够清楚,因为都是叫做dogs,这时候我们一样可以把外面的变量通过函数的参数代入闭包中,像是下面这样,返回的结果就清楚多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 通过函数的参数将值代入闭包中
function dogHouse (name) {
var count = 0
function countDogs () {
count += 1
console.log(count + ' ' + name)
}
return countDogs
}

// 同样是使用 dogHouse 但是使用不同的参数
var countGolden = dogHouse('Golden')
var countPug = dogHouse('Pug')
var countPuppy = dogHouse('Puppy')

// 结果看起来更清楚了
countGolden() // 1 Golden
countGolden() // 2 Golden

countPug() // 1 Pug
countPuppy() // 1 Puppy

countGolden() // 3 Golden
countPug() // 2 Pug

为了进一步简化代码,我们可以在闭包中直接return一个函数出来,我们就可以不必为里面的函数命名了,而是用匿名函数的方式直接把它返回出来。

因此写法可以简化成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function dogHouse () {
var count = 0
// 把原本 countDogs 函数改成匿名函数直接放进来
return function () {
count += 1
console.log(count + ' dogs')
}
}

function catHouse () {
var count = 0
// 把原本 countCats 函数改成匿名函数直接放进来
return function () {
count += 1
console.log(count + ' cats')
}
}

然后我们刚刚有提到,可以透过函数参数的方式把值代入闭包当中,因此实际上我们只需要一个counter ,在不同的时间点给它参数区分就好。这样子不管你是要记录哪一种动物都很方便了,而且代码也相当简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function createCounter (name) {
var count = 0
return function () {
count++
console.log(count + ' ' + name)
}
}

const dogCounter = createCounter('dogs')
const catCounter = createCounter('cats')
const pigCounter = createCounter('pigs')

dogCounter() // 1 dogs
dogCounter() // 2 dogs
catCounter() // 1 cats
catCounter() // 2 cats
pigCounter() // 1 pigs
dogCounter() // 3 dogs
catCounter() // 3 cats

闭包的实际应用

我们要实现这样的一个需求:点击某个按钮,提示点击的是”第n个”按钮,此处我们先不用事件代理:

1
2
3
4
5
6
7
8
9
10
11
12
.....
<button>测试1</button>
<button>测试2</button>
<button>测试3</button>
<script type="text/javascript">
var buttons = document.getElementsByTagName('button')
for (var i = 0; i < buttons.length; i++) {
buttons[i].onclick = function () {
console.log('第' + (i + 1) + '个')
}
}
</script>

这时候可能会预期点选不同的按钮时,会根据每个button 点击顺序的不同而得到不同的结果。但是实际执行后,你会发现返回的结果都是“第四个”。这是因为i是全局变量,执行到点击事件时,此时i的值为3。

如果要强制返回预期的结果,那该如何修改呢?最简单的是用let声明i

1
2
3
4
5
for (let i = 0; i < buttons.length; i++) {
buttons[i].onclick = function () {
console.log('第' + (i + 1) + '个')
}
}

简单来说,通过let可以帮我们把所定义的变量缩限在块级作用域中,也就是变量的作用域只有在{ }内,来避免 i 这个变量跑到全局变量被重复覆盖。

另外我们可以通过闭包的方式来修改:

1
2
3
4
5
6
7
for (var i = 0; i < buttons.length; i++) {
(function (j) {
buttons[j].onclick = function () {
console.log('第' + (j + 1) + '个')
}
})(i)
}

这其实也是「立即执行函数表达式 (Immediately Invoked Function Expression, IIFE)」的概念,后续会有专门的文章进行介绍。

希望看完这篇文章后,你能对于闭包有更清楚的认识。

如果觉得文章对你有些许帮助,欢迎在我的GitHub博客点赞和关注,感激不尽!