JavaScript 关于闭包的理解
对于前端的同学来说,闭包这个词一定在无数的面试过程中被问到过,小呆也不例外。早些年有些公司甚至会把理解闭包当成区分初级、中级甚至高级前端工程师的一个方式。在被问到什么是闭包的时候,有些同学会回答:“闭包就是函数内部嵌套并返回一个函数”,果真如此吗?一起来复习一下闭包的知识吧。
知识点
- 理解闭包
- 闭包的应用
理解闭包
闭包的定义
关于对闭包的定义,一千个读者有一千个哈姆雷特,MDN Doc文档、《你不知道的JavaScript》、《JavaScript高级程序设计第3版》各自的定义都大不相同:
闭包是一个函数以及其捆绑的周边环境状态(lexical environment, 词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在JavaScript中,闭包会随着函数的创建而被同时创建。—— MDN Doc文档
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 —— 《你不知道的JavaScript》
闭包是指有权访问另一个函数作用域中的变量的函数。—— 《JavaScript高级程序设计第3版》
初看这些定义,一定有人会懵,哪个才是权威的解释呢?我们无需纠结到底哪个定义才是最合理的解释,不妨从中找出一些关键词:函数、环境、作用域。
关键词找着了,在接着学习闭包之前,需要了解一些跟关键词相关的知识:
- 了解执行上下文的生命周期
- 了解作用域、作用域链、词法作用域的区别
我们知道,函数的执行上下文有两个阶段:创建和执行。创建阶段会形成作用域链,通过作用域链我们可以访问父级的作用域里声明的变量。而当函数执行完毕之后,该函数的执行上下文就会出栈,失去引用。其占用的内存空间很快就会被垃圾回收器释放。但是,有一种情况会阻止这一过程,它就是闭包。
先给出结论,小呆对闭包的理解是:一个执行上下文A,以及在该执行上下文中创建的函数B,不论B在哪里被调用,只要B执行的时候,访问了执行上下文A中的词法环境所记录的值,就会产生闭包。换言之,闭包并不是特指某个函数或者某个变量,而是一种现象。
如何创建闭包
说了这么多,我们先来看看闭包长什么样:
1 |
|
上面的代码,就生成了一个闭包。在函数foo
的结尾,函数bar
被当成函数foo
的返回值return
出去。当foo()
执行后,函数bar
的引用被变量baz
所持有,正常来讲,函数foo
执行完成后,生命周期结束,其内部的词法环境都会被销毁,这样才能被垃圾回收器用来释放,但似乎结果并不是这样。
由于函数bar
声明在函数foo
内部,所以根据词法作用域模型,它能够访问到函数foo
内部的变量a
。很不巧,在函数bar
的内部对a
变量进行了引用console.log(a)
,这使得函数foo
的作用域能够一直存活,能够方便bar()
在任何时间进行引用。所以即使foo()
执行完成,其作用域也不会被回收释放。
上面的话似乎有一点绕,不太好理解,小呆举个例子,尽量用俗话的话把闭包讲清楚:
- 小呆在银行(函数
foo
)开了一个账户(函数bar
)[函数bar
声明在函数foo
内部] - 然后小呆通过这个账户,能与银行系统相连接(作用域链),访问系统内部的数据(
a
)[console.log(a)
,形成了对foo
作用域的引用] - 银行有一个规定,就是这个账户只能在银行内部,才能访问到数据[作用域链规定只有内部访问外部,反之则不行]
- 小呆偷偷的把账户的使用权给了在银行外面的小萌(内部函数
bar
的引用被赋值给了当前父级作用域以外的变量baz
) - 银行还有一个规定,只要还有客户在访问系统内部的数据(函数
bar
引用着foo
作用域内的数据),哪怕银行倒闭了(即使foo
执行完成,生命周期结束),系统也得开着(foo
的作用域会一直保留,不被回收) - 小萌利用第5条的规定,在银行外面(
foo
作用域以外的地方)访问到银行内部数据(foo
作用域内的变量a
)的这种现象,就叫做闭包
经过这么一个小场景,是不是一下子就好理解多了呢?
闭包真的跟return
有关吗?
在小呆初学闭包时,由于对作用域和执行上下文并不了解,看了网上很多文章,最后就记住了一句话:“闭包就是函数内部嵌套并返回一个函数”,所以早些年有很多面试中,我回答了这句话后,面试官就不再问了,因为他知道你真的没理解闭包。哪怕到后来小呆当面试官去问什么是闭包的时候,也依然有很多候选人回答“闭包就是函数内部return
一个函数”。闭包真的跟return
有关系吗?我们不妨看一个《你不知道的Javascript》书中的例子:
1 |
|
上面的代码中,我们把内部函数baz
传递给了bar
,当调用这个内部函数时(现在它被函数bar
的arguments对象里fn
属性所引用),即使它是在函数bar
的作用域内调用,但是它依然能够访问到函数foo
作用域内的变量a
,所以也形成了闭包。但是需要注意的是:这段代码我们没有用到任何return
。
通过VS Code
的调试插件,我们调用了Chrome
的调式工具,并通过断点的方式观察到了闭包,如下图。注意这里有个区别:还记得上面小呆的理解吗?一个执行上下文A,和这个执行上下文中创建的函数B。大部分的书籍和文章会以函数B的名字代指生成的闭包,而在Chrome中,则以执行上下文A的函数名代指闭包。(注意看上面的代码fn()
后面的注释,和下图左侧的Closure(foo)
。
除了上面将函数当做参数来传递以外,我们还可以通过间接的方式进行函数传递。来看一个例子:
1 |
|
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数,都会使用闭包。
我们来修改一下上面的代码:
1 |
|
我们在调用fn(b)
时,给他传入了一个全局变量b,然后再通过调式工具去观察,发现Closure没了。因为函数baz
内没有再引用函数foo
内的变量。自然而然,也就不会产生闭包。
所以我们可以得出一个结论:一个闭包所产生的必要条件,必须由A(一个执行上下文)和B(在该执行上下文中创建的函数)共同组成。
如何通过Chrome
调试闭包:
- 打开
Chrome
调试工具。 - 找到
Sources
-> 进行断点调试 - 通过右侧的
Scope
进行观察
再来看另一个代码:
1 |
|
观察上面的代码,我们生成了两个闭包,但是我们却发现这两个闭包之间互不影响。这是为什么呢?回想执行上下文的创建过程,每次创建执行上下文都会重新走一遍流程,相当于重新创建了一份词法环境记录和作用域链。所以即使用相同的代码,产生的闭包也是相互独立的。
闭包的应用
常见的闭包有哪些
很多候选人在面试中被问到常见的闭包有哪些,都会支支吾吾或者哑口无言。殊不知其实在日常的开发过程中到处都能够看到闭包的身影。举个例子:
1 |
|
将一个内部函数timer
,传递给setTimeout
,函数timer
引用了创建它的执行上下文函数wait
的参数,这就形成了一个闭包。wait
执行完毕后,它的内部作用域并不会消失。再来看另一个常见的闭包:
1 |
|
你看,在我们的日常开发中,其实闭包无处不在。本质上无论何时何地,如果将(访问了它们各种词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。比如:定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者其他任务中,只要使用了回调函数,就很容易产生闭包。
闭包有什么作用
其实闭包的作用最重要的就是用于模块化,一定程度上保护函数内的变量的安全性,基本可以解决函数污染或变量随意被修改的问题!比如说Java、PHP
等语言支持将方法声明为私有,它们只能被同一个类中的其他方法所调用。私有方法不仅仅有利于限制对代码的访问权限,还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。
1 |
|
上面的代码表现了如何使用闭包来定义公共函数,并让它可以访问私有函数和变量。而且只能通过Counter
暴露的特定方法才能访问到内部的变量和函数。这就是闭包的艺术。
闭包会造成内存泄露?
有些文章会说闭包会造成内存泄露,这其实是错误的。
在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。
内存泄漏的简单理解:无用的内存还在占用,得不到释放和归还。比较严重时,无用的内存会持续递增,从而导致整个系统的卡顿,甚至崩溃。
我们注意一点:内存泄露指的是那些用不到(访问不到)的变量,依然占据着内存空间,不能被再次利用。而闭包里的变量,我们是有在使用的,所以不叫内存泄露。
产生该说法的原因:该死的IE(幸好现在没有IE了,遥想当年兼容各种IE版本,各种泪)。IE有个bug,会在我们使用完闭包之后,依然回收不了闭包里面引用的变量。
小结
通过这篇文章,希望能够帮助大家正确的认识并掌握闭包的概念。并学会如何通过调试工具观察闭包,以及场景的闭包场景和闭包的作用。闭包是JavaScript
中比较难理解的一个知识点,但是掌握之后,你会发现闭包还是挺有趣的!
引用
本文内容参考了以下书籍及内容,感兴趣的同学可以购买正版图书进行阅读。
《你不知道的JavaScript——上卷》——作者:KYLE SIMPSON
《JavaScript 高级程序设计 第3版》——作者:Nicholas C.Zakas