「JS-Learning」理解JS的闭包
本篇涉及到的名词:作用域,词法作用域,作用域链,闭包等。
一、引言
- 《JavaScript高级程序设计(第3版)》:
- 闭包是指有权访问另一个函数作用域中的变量的函数。(P178)
- 《你不知道的JavaScript(上)》:
- 当函数可以记住并访问所在的词法作用域时,就产生了闭包。(P44)
- MDN:
- 函数和对其词法环境的引用捆绑在一起构成闭包,闭包可以让你从内部函数访问外部函数作用域。(原链接)
以上是几种对闭包概念的描述,比较专业,但是各有说法、比较拗口。
其实不必要太纠结于概念,因为如何定义闭包不会影响到实际的使用,了解闭包是如何产生的,这才是本质的东西。下面就根据我的理解,用通俗的语言梳理下闭包的前因后果。
二、作用域
在《JavaScript高级程序设计(第3版)》对闭包的定义中提到了「函数作用域」,所以先理解 JS 中的作用域。
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
1. 全局作用域和函数作用域
在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。(ES3 开始,try /catch
分句结构和 with
中也具有块作用域,这里不加讨论)
- 全局作用域 中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域 就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 {}
都可以被看作是一个块级作用域。
简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。
遗憾的是,JavaScript 在 ES6 之前是不支持块级作用域的。没有块级作用域,对作者当时设计 JavaScript 来说会比较简单快速,但这也直接导致了「变量提升」的问题。本文点到为止,不展开讲,防止跑题。
2. 块级作用域
为了解决变量提升带来的一系列问题,ES6 引入了 let
和 const
关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
需要注意的是,只有 let
,const
+ 大括号({}
)才能构成块级作用域,否则单纯的大括号({}
)只是用作代码分割,让代码阅读起来更简单轻快一点,纯粹代码维护上的需求。
通过 let
或者 const
声明的变量会在进入块级作用域的时候被创建,但是在该变量没有赋值之前,引用该变量 JavaScript 引擎会抛出错误,这就是「暂时性死区」,对于暂时性死区的问题,本文不展开讨论。
三、词法作用域
在《你不知道的JavaScript(上)》对闭包的定义中提到了「词法作用域」,在 JS 中什么是词法作用域呢?
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。(标识符:在 JS 中所有可以由我们自主命名的都可以称为是标识符,例如:变量名、函数名、属性名都属于标识符)
预先说一个结论:词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。这句话什么意思,别急,先留个印象,下面开始讲解作用域链,这也是一个耳熟能详的名词术语。
四、作用域链
作用域链也称词法作用域链,顾名思义,它跟词法作用域有关。
作用域链本质上就是查找变量的链条(确定变量来自于哪里,变量是否可以访问,或者说,确定一个变量来自于哪个作用域)。
简单来说,作用域链可以用以下几句话来概括:
- Step1 查看当前作用域,如果当前作用域声明了这个变量,就确定结果;
- Step2 查找当前作用域的上级作用域,也就是当前函数的上级函数,看看上级函数中有没有声明;
- Step3 再查找上级函数的上级函数,直到全局作用域为止;
- Step4 如果全局作用域中也没有,我们就认为这个变量未声明(抛出异常:
xxx is not defined
)。
这么讲可能不太好理解,你可以看下面这张图:
前面说过,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。什么意思呢,看下面这个例子:
function innerFunction() {
console.log(myName)
}
function outerFunction() {
var myName = "程序员博客"
innerFunction()
}
var myName = "文渊博客"
outerFunction()
要知道上述代码执行后打印出来的内容是什么,就要分析下这两段代码的执行流程了。
从代码中可以看出,全局执行上下文和 outerFunction
函数的执行上下文中都包含变量 myName
,那 innerFunction
函数里面 myName
的值到底该选择哪个呢?
根据上面提到的查找顺序,查找方式如下:
- Step1 查看当前作用域,
innerFunction
函数里面不存在变量myName
; - Step2
innerFunction
函数中使用了外部变量,向上级查找。
重点来了,由于 innerFunction
函数里面不存在变量 myName
,此时 JavaScript 引擎会去全局执行上下文中查找,而不是它的调用方 outerFunction
函数的执行上下文。
这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。
而前面已经说过,这里再重复一遍:词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。(重要的话说三遍)
既然如此,根据词法作用域,outerFunction
和 innerFunction
的上级作用域都是全局作用域,所以如果 outerFunction
或者 innerFunction
函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。
五、闭包是什么
理解了作用域链,下面就可以聊聊闭包了。关于闭包,理解起来可能会是一道坎,特别是在你不太熟悉 JavaScript 这门语言的时候,接触闭包很可能会让你产生一些挫败感,因为你很难通过理解背后的原理来彻底理解闭包,从而导致学习过程中似乎总是似懂非懂。最要命的是,JavaScript 代码中还总是充斥着大量的闭包代码。
我发现网上所有的文章都喜欢上一段一段的代码,代码中函数的命名又很随意,一下子就让初学者望而却步。接下来我用一个人性化点的示例 —— 博客制造机,相信结合下面这段代码你一定能理解什么是闭包。
function blogFactory() {
var name = "程序员博客"
const domain = 'www.wenyuanblog.com'
let age = 2
var blogMaker = {
getName:function(){
console.log(age)
return name
},
setName:function(newName){
name = newName
}
}
return blogMaker
}
var myBlog = blogFactory()
myBlog.setName("文渊博客")
myBlog.getName()
console.log(myBlog.getName())
从上面的代码可以看出,blogFactory
是一个对象,包含了 getName
和 setName
的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 blogFactory
函数内部定义的,并且这两个方法内部使用了 name
和 domain
两个变量。
根据词法作用域的规则,内部函数 getName
和 setName
总是可以访问它们的外部函数 blogFactory
中的变量,所以当 blogMaker
对象返回给全局变量 myBlog
时,虽然 blogFactory
函数已经执行结束,但是 getName
和 setName
函数依然可以使用 blogFactory
函数中的变量 name
和 domain
。
换言之,blogFactory
函数执行完成之后,其执行上下文销毁,但是由于返回的 setName
和 getName
方法中使用了 blogFactory
函数内部的变量 name
和 domain
,所以这两个变量依然保存在内存中。这像极了 setName
和 getName
方法背的一个专属背包,无论在哪里调用了 setName
和 getName
方法,它们都会背着这个 blogFactory
函数的专属背包。
之所以是专属背包,是因为除了 setName
和 getName
函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 blogFactory
函数的闭包。
好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 blogFactory
,那么这些变量的集合就称为 blogFactory
函数的闭包。
六、闭包的作用
- 模块化(利用闭包的原理,将一个大的系统放在一个自调用函数中)
- 防止变量被破坏(封装私有变量,保护函数内的变量安全)
如下代码所示,在开发一些组件的时候,要实现模块化就可以使用闭包:
var common = (function() {
return {
isStr:function() {
……
},
isNumber:function() {
……
}
}
})()
七、闭包内存释放
如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。
通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。
当然了,如果想释放以全局变量形式存在的闭包,也可以用下面这种方式:
function foo() {
var a = 5
return function() {
a++
console.log(a)
}
}
var bar = foo()
// 要想释放 bar 里面保存的 a,只能通过释放 bar
bar = null // 或者 bar = undefined
八、面试官问
回到大家最关心的,如果面试官问:「简述什么是闭包,闭包的作用是什么。」,该怎么回答呢?
- 当函数嵌套时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局作用域下可访问时,就形成了闭包。
- 模块化;防止变量被破坏。
注意:以上只是我的一种回答,并不是标准答案,每个人都应该有自己的理解和侧重点。
参考
《JavaScript高级程序设计(第3版)》
《你不知道的JavaScript(上)》
MDN
极客时间