对于前端的同学来说,闭包这个词一定在无数的面试过程中被问到过,小呆也不例外。早些年有些公司甚至会把理解闭包当成区分初级、中级甚至高级前端工程师的一个方式。在被问到什么是闭包的时候,有些同学会回答:“闭包就是函数内部嵌套并返回一个函数”,果真如此吗?一起来复习一下闭包的知识吧。

知识点

  • 理解闭包
  • 闭包的应用

理解闭包

闭包的定义

关于对闭包的定义,一千个读者有一千个哈姆雷特,MDN Doc文档、《你不知道的JavaScript》、《JavaScript高级程序设计第3版》各自的定义都大不相同:

闭包是一个函数以及其捆绑的周边环境状态(lexical environment, 词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在JavaScript中,闭包会随着函数的创建而被同时创建。—— MDN Doc文档

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 —— 《你不知道的JavaScript》

闭包是指有权访问另一个函数作用域中的变量的函数。—— 《JavaScript高级程序设计第3版》

初看这些定义,一定有人会懵,哪个才是权威的解释呢?我们无需纠结到底哪个定义才是最合理的解释,不妨从中找出一些关键词:函数环境作用域

关键词找着了,在接着学习闭包之前,需要了解一些跟关键词相关的知识:

  1. 了解执行上下文的生命周期
  2. 了解作用域、作用域链、词法作用域的区别

我们知道,函数的执行上下文有两个阶段:创建和执行。创建阶段会形成作用域链,通过作用域链我们可以访问父级的作用域里声明的变量。而当函数执行完毕之后,该函数的执行上下文就会出栈,失去引用。其占用的内存空间很快就会被垃圾回收器释放。但是,有一种情况会阻止这一过程,它就是闭包。

先给出结论,小呆对闭包的理解是:一个执行上下文A,以及在该执行上下文中创建的函数B,不论B在哪里被调用,只要B执行的时候,访问了执行上下文A中的词法环境所记录的值,就会产生闭包。换言之,闭包并不是特指某个函数或者某个变量,而是一种现象。

如何创建闭包

说了这么多,我们先来看看闭包长什么样:

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的结尾,函数bar被当成函数foo的返回值return出去。当foo()执行后,函数bar的引用被变量baz所持有,正常来讲,函数foo执行完成后,生命周期结束,其内部的词法环境都会被销毁,这样才能被垃圾回收器用来释放,但似乎结果并不是这样。

由于函数bar声明在函数foo内部,所以根据词法作用域模型,它能够访问到函数foo内部的变量a。很不巧,在函数bar的内部对a变量进行了引用console.log(a),这使得函数foo的作用域能够一直存活,能够方便bar()在任何时间进行引用。所以即使foo()执行完成,其作用域也不会被回收释放。

上面的话似乎有一点绕,不太好理解,小呆举个例子,尽量用俗话的话把闭包讲清楚:

  1. 小呆在银行(函数foo)开了一个账户(函数bar)[函数bar声明在函数foo内部]
  2. 然后小呆通过这个账户,能与银行系统相连接(作用域链),访问系统内部的数据(a)[console.log(a),形成了对foo作用域的引用]
  3. 银行有一个规定,就是这个账户只能在银行内部,才能访问到数据[作用域链规定只有内部访问外部,反之则不行]
  4. 小呆偷偷的把账户的使用权给了在银行外面的小萌(内部函数bar的引用被赋值给了当前父级作用域以外的变量baz)
  5. 银行还有一个规定,只要还有客户在访问系统内部的数据(函数bar引用着foo作用域内的数据),哪怕银行倒闭了(即使foo执行完成,生命周期结束),系统也得开着(foo的作用域会一直保留,不被回收)
  6. 小萌利用第5条的规定,在银行外面(foo作用域以外的地方)访问到银行内部数据(foo作用域内的变量a)的这种现象,就叫做闭包

经过这么一个小场景,是不是一下子就好理解多了呢?

闭包真的跟return有关吗?

在小呆初学闭包时,由于对作用域和执行上下文并不了解,看了网上很多文章,最后就记住了一句话:“闭包就是函数内部嵌套并返回一个函数”,所以早些年有很多面试中,我回答了这句话后,面试官就不再问了,因为他知道你真的没理解闭包。哪怕到后来小呆当面试官去问什么是闭包的时候,也依然有很多候选人回答“闭包就是函数内部return一个函数”。闭包真的跟return有关系吗?我们不妨看一个《你不知道的Javascript》书中的例子:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 2
function baz() {
console.log(a) // 2
}
bar(baz)
}
function bar(fn) {
fn() // 妈妈快看呀,这就是闭包!
}
foo()

上面的代码中,我们把内部函数baz传递给了bar,当调用这个内部函数时(现在它被函数bar的arguments对象里fn属性所引用),即使它是在函数bar的作用域内调用,但是它依然能够访问到函数foo作用域内的变量a,所以也形成了闭包。但是需要注意的是:这段代码我们没有用到任何return

通过VS Code的调试插件,我们调用了Chrome的调式工具,并通过断点的方式观察到了闭包,如下图。注意这里有个区别:还记得上面小呆的理解吗?一个执行上下文A,和这个执行上下文中创建的函数B。大部分的书籍和文章会以函数B的名字代指生成的闭包,而在Chrome中,则以执行上下文A的函数名代指闭包。(注意看上面的代码fn()后面的注释,和下图左侧的Closure(foo)

闭包跟return没关系

除了上面将函数当做参数来传递以外,我们还可以通过间接的方式进行函数传递。来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fn = null
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
7
8
9
10
11
12
13
14
15
var fn = null
var b = 5
function foo() {
var a = 2
function baz(a) {
console.log(a)
}
fn = baz // 将baz分配给全局变量
}

function bar() {
fn(b)
}
foo()
bar() // 5

闭包的存在需要两个必要条件

我们在调用fn(b)时,给他传入了一个全局变量b,然后再通过调式工具去观察,发现Closure没了。因为函数baz内没有再引用函数foo内的变量。自然而然,也就不会产生闭包。

所以我们可以得出一个结论:一个闭包所产生的必要条件,必须由A(一个执行上下文)和B(在该执行上下文中创建的函数)共同组成

如何通过Chrome调试闭包

  1. 打开Chrome调试工具。
  2. 找到Sources -> 进行断点调试
  3. 通过右侧的Scope进行观察

再来看另一个代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
var num = 0
function bar() {
++num
console.log(num)
}
return bar
}
var baz1 = foo()
baz1() // 1
baz1() // 2
var baz2 = foo()
baz2() // 1
baz2() // 2

观察上面的代码,我们生成了两个闭包,但是我们却发现这两个闭包之间互不影响。这是为什么呢?回想执行上下文的创建过程,每次创建执行上下文都会重新走一遍流程,相当于重新创建了一份词法环境记录和作用域链。所以即使用相同的代码,产生的闭包也是相互独立的。

闭包的应用

常见的闭包有哪些

很多候选人在面试中被问到常见的闭包有哪些,都会支支吾吾或者哑口无言。殊不知其实在日常的开发过程中到处都能够看到闭包的身影。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function wait(message) {
setTimeout(function timer() {
console.log(message)
}, 1000)
}
wait('Hello, closure!')

// 上面的代码可以看成这样
function wait(message) {
function timer() {
console.log(message)
}
setTimeout(timer, 1000)
}
wait('Hello, closure!')

将一个内部函数timer,传递给setTimeout,函数timer引用了创建它的执行上下文函数wait的参数,这就形成了一个闭包。wait执行完毕后,它的内部作用域并不会消失。再来看另一个常见的闭包:

1
2
3
4
5
6
fucntion btnClick(id) {
document.getElementById(id).addEventListener('click', function () {
console.log(id)
})
}
btnClick('btn')

你看,在我们的日常开发中,其实闭包无处不在。本质上无论何时何地,如果将(访问了它们各种词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。比如:定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者其他任务中,只要使用了回调函数,就很容易产生闭包。

闭包有什么作用

其实闭包的作用最重要的就是用于模块化,一定程度上保护函数内的变量的安全性,基本可以解决函数污染变量随意被修改的问题!比如说Java、PHP等语言支持将方法声明为私有,它们只能被同一个类中的其他方法所调用。私有方法不仅仅有利于限制对代码的访问权限,还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var Counter = (function () {
var privateCounter = 0
function changeVal(val) {
privateCounter += val
}
return {
increment: function() {
changeVal(1)
},
decrement: function() {
changeVal(-1)
},
value: function() {
return privateCounter
}
}
})();
console.log(Counter.value()) // 0
Counter.increment()
Counter.increment()
console.log(Counter.value()) // 2
Counter.decrement()
console.log(Counter.value()) // 1

上面的代码表现了如何使用闭包来定义公共函数,并让它可以访问私有函数和变量。而且只能通过Counter暴露的特定方法才能访问到内部的变量和函数。这就是闭包的艺术。

闭包会造成内存泄露?

有些文章会说闭包会造成内存泄露,这其实是错误的。

在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。

内存泄漏的简单理解:无用的内存还在占用,得不到释放和归还。比较严重时,无用的内存会持续递增,从而导致整个系统的卡顿,甚至崩溃。

我们注意一点:内存泄露指的是那些用不到(访问不到)的变量,依然占据着内存空间,不能被再次利用。而闭包里的变量,我们是有在使用的,所以不叫内存泄露

产生该说法的原因:该死的IE(幸好现在没有IE了,遥想当年兼容各种IE版本,各种泪)。IE有个bug,会在我们使用完闭包之后,依然回收不了闭包里面引用的变量。

小结

通过这篇文章,希望能够帮助大家正确的认识并掌握闭包的概念。并学会如何通过调试工具观察闭包,以及场景的闭包场景和闭包的作用。闭包是JavaScript中比较难理解的一个知识点,但是掌握之后,你会发现闭包还是挺有趣的!

引用

本文内容参考了以下书籍及内容,感兴趣的同学可以购买正版图书进行阅读。

《你不知道的JavaScript——上卷》——作者:KYLE SIMPSON

《JavaScript 高级程序设计 第3版》——作者:Nicholas C.Zakas

前端基础进阶(五):闭包——作者:这波能反杀

JS闭包的测试——作者:司徒正美