该系列文章连载于公众号coderwhy和掘金XiaoYu2002中
- 对该系列知识感兴趣和想要一起交流的可以添加wx:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
- 课程对照进度:JavaScript高级系列18-22集(coderwhy)
- 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力
脉络探索
-
闭包是JavaScript中一个非常容易让人迷惑的知识点:
- 如图7-1,小黄书对此知识点给出了非常高的评价,虽然在当下我们可能觉得也就这样,说得是不是太夸张了。但对于当时的他们来说,前面都是迷途,有大量的未建设的道路需要探索,每一步探索的背后都是日日夜夜的思考,突破性的发现对于他们来说,真的就是振奋人心的事情,特别是在学习资料尚不全面完整的时候
- 我们如今所学习的,是他们已经替我们铺好的道路,站在巨人肩膀的我们,看当年荆棘丛生的迷雾如今已是一片的坦途。对此,我对这些先驱者保持一定的敬意
-
那么,就让我们出发去攻克这当年让他们如此痴迷的知识点,究竟有什么魅力
图7-1 《你不知道的JavaScript》上卷中对闭包的评语
一、闭包和闭包的内存泄漏
1.1. 闭包的概念定义
- 闭包定义分为两个:在计算机科学中(**因为闭包不是JavaScript特有的,在其他语言中也是有的)**和在JavaScript中
- 在计算机科学中对闭包的定义:
- 闭包(Closure),又称词法闭包(Lexical Closure)或者函数闭包(function closures)
- 是在支持头等函数的编程语言中,实现词法绑定的一种技术
- 头等函数是指在程序设计语言中,函数被当作一等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中
- 解析函数的时候,就会确定它的上层作用域,这是在词法解析的时候进行确定的
- 闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(关联的自由变量)(相当于一个符号查找表)
- 这个结构体在C语言中就是指一个结构
- 但在JavaScript中,它其实是指一个对象,对象里面存储着一个函数和一个关联环境(想表达是一个整体)
- 闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行(闭包核心观念)
- 自由变量:假如在全局中定义了变量a,在函数中使用了这个a,这个a就是自由变量,可以这样理解,凡是跨了自己的作用域的变量都叫自由变量。
- 脱离捕捉的上下文:在你函数的上下文之外的地方调用,你脱离了这个作用域范围能够调用,证明了本来该被销毁的自由变量却得以保存
- 闭包的概念最早出现于60年代,最早实现闭包的程序是Scheme的,那么我们就可以理解为什么JavaScript中有闭包:
- 因为JavaScript中有大量的设计来源于Scheme的。(Scheme是最早实现闭包的语言)
- MDN对JavaScript闭包的解释:
- 一个函数以及其捆绑的周边环境状态(
lexical environment 词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包。 - 换而言之,闭包让开发者可以从内部函数访问外部函数的作用域
- 在 JavaScript 中,每当创建一个函数,闭包会随着函数的创建而被同时创建
- 概括就是有函数就有闭包
- 之所以会有函数就有闭包是因为,当函数被创建出来的时候,定义在最外层,它的上层作用域就是全局作用域,如果在函数内引用了全局作用域的内容,那也是形成了一个闭包
- 一个函数以及其捆绑的周边环境状态(
- 理解总结:
- 一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包
- 从广义的角度来说,JavaScript的函数都是闭包
- 从狭义的角度来说,JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包
1.2. 高阶函数执行过程
function foo(){
//bar预解析,前面有讲过
function bar(){
console.log("小余");
}
return bar
}
var fn = foo()
fn()
//小余
一旦我们想要调用函数,它会在执行栈里面创建一个函数的执行上下文
- 这个时候不会马上调用函数执行的上下文,会先创建一个AO对象(函数执行之前创建)
- 为什么不每个函数都创建AO对象呢?因为如果你如果每个都创建,当数量一多,就会创建很多个AO对象出来,当你都放着不调用,那岂不是就很浪费,所以设置当我们即将调用的前一刻会将AO对象创建出来,这样每个创建出来的AO对象都会被马上用上
1.2.1. 流程图
-
在函数正式开始执行之前,我们将其分为堆与栈两种空间
-
在foo正式调用之前,这个空间情况如图7-2所示。GO对象是一开始就会被创建出来的
-
我们的步骤分为两个
- foo调用
- fn调用
-
图7-2 高阶函数执行前
- 当foo进行调用的时候,bar函数从预解析转为真正的解析,此时bar函数应该也要创建出对应的AO对象出来的,如图7-3,但我们目前的案例中,bar函数并没有存放内容,所以没有发挥出作用,我们这里也暂时没有画进去,直到如图7-4的bar调用阶段
图7-3 foo函数调用阶段内存图
图7-4 foo函数中的bar函数调用阶段内存图
- AO对象(foo)里面有一个bar,也就是我们刚刚上面代码块中的bar,在foo函数体里面进行了return
- 这个bar存放的其实只是一个地址,原本全局对象GO(global object)里面的fn是underfined,现在变成bar的内存地址(类似
0Xb00之类的东西)了 - 执行完之后,
FEC函数执行上下文会销毁掉 - 然后我们执行了fn(),此时我们应该注意到fn里面的内容其实已经是bar的内存地址了,所以我们执行的时候fn其实是通过bar的内存地址去进行指针指向执行
- 然后指向的对应ECStack调用栈的全局执行上下文又会创建出来一个bar函数执行上下文进行执行内容,执行完之后就会把这个函数执行上下文进行一个销毁
- 最后fn()就会打印出bar中的内容
1.3. 闭包到底是什么
- 在刚刚,我们特地忽略掉了一个细节,那就是
bar函数作为闭包返回,它能够访问foo函数作用域内的变量- 尽管在图中没有明确显示
bar使用外部变量,但其能够访问foo的作用域是闭包的核心特性。这一点在图中通过bar被保存在AO中并被外部变量fn引用的方式间接表示
- 尽管在图中没有明确显示
function foo(){
var name = "coderwhy"
function bar(){
console.log("小余",name);
}
return bar
}
var fn = foo()
fn()
//小余 coderwhy
- 从var fn = foo()开始,这个时候在GO对象中,fn还是一个undefined
- 一样的,在执行foo的时候,会先创建出来一个foo的AO执行对象 => 里面有一个name为undefined跟一个预解析的bar函数,bar函数里面存放的是函数指针的一个引用(指向了bar函数创建出来的函数对象0xb00地址,0xb00是一个举例,不一定就是这个)
- 下一刻中将name中的内容填入,取代undefined,然后就是function bar(){xxx}并不执行,而是直接跳到return bar中,这里return返回的bar其实就是0xb00地址,所以在fn = foo()的fn就能拿到我们返回的0xb00地址(fn = 0xb00)。这个时候foo函数内的东西就都执行结束了,那这个对应的函数执行上下文就会销毁掉
- 在GO对象中的fn也会对应的替换成bar的指针地址0xb00
- 最后执行fn(),这个又是一个函数的执行,这个时候我们又会创建出来一个函数的执行上下文,但是这次的函数执行上下文,其实就是bar的执行上下文,在第3点中我们已经能感受到替换成bar的过程了。创建bar的AO对象,然后有创建对应的执行上下文,首先里面是VO,VO对应的是AO,接着执行里面的内容,一个控制台打印命令,"小余"是字符串,能够直接被打印出来,但是,里面这时引用了一个name,那name应该要沿着作用域链去查找(VO+parentScope),VO里面没有找到,在父级foo对象中找到了name,foo对象在定义的时候就已经确定了。我们在bar函数对象0xb00中除了包含了代码执行体之外,还包含了parentScope:foo的AO对象(就是上面闭包定义中说的词法解析的时候),所以能够打印出来name的内容
- 当我们在调用fn函数的时候,就已经形成闭包了,因为我们在var fn = foo()执行的时候,foo函数就已经执行完了,然后return返回了bar这个内容,按道理来说,这个时候name就需要随着foo的函数执行上下文销毁掉了,但我们根据结果却依旧能够进行访问到name。这就是js内部帮我们实现的功能,如图7-5
bar函数通过它的[[scope]]属性保持对foo的活动对象(AO)(其中包含name变量)的引用,而这个[[scope]]是parentScope+VO- 而这也是使
foo函数执行完毕,bar仍然可以访问foo函数作用域内的变量name的闭包原因
- 结论:
- 闭包是两部分组成的,函数+可以访问的自由变量(bar本身加上它内部引用的自由变量形成闭包)
- 此时foo函数中的name就是自由变量
图7-5 bar函数中的name形成闭包内存图
-
此时我们对自由变量就有了一个大概的了解
- 而维基百科说这是实现词法绑定的一种技术,这说明了在词法解析阶段,能访问哪些变量就都已经确定了。而这也是它被称为词法闭包的原因
- 而一旦在词法解析环节确定之后,之后哪怕脱离了上下文,也不会对我们的引用产生影响,因为在一开始已经被捕获,换流行一点的说法就是你已经被锁定了。位置上的蛇皮走位是没用的,准星已经瞄准你了
-
但社区对于闭包的理解其实是有一定争议的
-
而这也就是我们在绘画流程图的时候,所针对的两个案例
-
虽然两个案例都是可以形成闭包的效果的
-
前者没有实现闭包效果,但他本身是可以做到这件事情的。我们称为可以访问到name(自由变量)
-
后者实现了闭包效果,他本身做到了这件事情,我们称之有访问到name(自由变量)
-
-
而社区的争论点就在这个角度,有人认为前者就算闭包,有人认为后者才算。但这是一个看待角度的问题,如果从后者来看,会更加严谨一点
-
//可以访问name:test算闭包
//有访问到name:test算闭包(更严谨)
var name = "放寒假了"
function test(){
console.log(name);
}
test()
补充:执行上下文跟作用域的区别:
当我们要执行函数的时候,就会创建出来一个环境,环境叫做执行上下文,执行上下文有我们的作用域还有作用域链
1.4. 函数执行过程的内存表现
function foo(){
var name = "xiaoyu"
var age = 20
}
function test(){
console.log("test");
}
foo()
test()
图7-6 foo与test函数执行前的初始化表现
- 正常来说,还是比较好理解的,当我们在全局调用foo和test两个函数的时候,在GO之中的表现就为内存地址,如图7-6
- 这两个函数都创建了自己的执行上下文,它们的父作用域链指向全局对象(GO)。意味着它们在执行时可以访问全局对象中定义的任何变量和其他函数
- 而内存地址中,具体的内容为父级作用域+函数的执行体
- 所以形成了GO中foo和test指向具体内存,而具体内存又指向了GO,如图7-7
图7-7 foo函数和test函数的内存图执行过程
foo的执行上下文销毁前后对比:
图7-8 foo的执行上下文销毁前后对比
- 我们写了foo函数跟test函数,从foo()开始执行,这个时候会先创建出foo函数的函数对象(0xa00
内存地址),然后函数对象里面包括了parentScope父级作用域跟函数执行体 - 然后foo函数这个父级作用域parentScope在上面的代码块中指GO(0x100
内存地址),没错,parentScope是指向一个内存地址(根据上图,我们能知道他们其实是一个互相引用的关系)。test函数 同理 - 最后foo执行的时候同理的创建出来对应的函数执行上下文,在执行上下文中,我们知道VO其实就是指AO,存放的AO其实也是内存地址,会对应的去进行引用,接着按顺序将name跟age进行了一次输出,覆盖掉了AO对象中name、age原本默认输出的undefined。输出完了内容之后,一样的会销毁掉执行上下文VO
- 而需要注意这个对比过程,我们存放在AO对象中的name和age也跟着销毁了,自由变量没能真的自由
1.5. 闭包的执行过程
以下是我们已经非常熟悉的闭包过程,这次我们来看下他是怎么进行执行的,这次会解开我们之前还不了解的,为什么闭包会让本该执行完的执行上下文的自由变量不会被销毁掉
function foo(){
var name = "xiaoyu"
var age = 20
function bar(){
//引用了外层变量,形成闭包
console.log("这是我的名字",name);
console.log("这是我的年龄",age);
}
return bar
}
var fn = foo()
fn()
//这是我的名字 xiaoyu
//这是我的年龄 20
- 执行之前一样是非常熟悉的流程,如图7-9
- 我们的AO对象之中,多出了bar函数的内存地址
- 在bar函数之中,我们要实现我们的闭包效果。让刚才AO对象之中的name和age不会随着foo函数执行结束,一并被弹出执行上下文
图7-9 闭包执行前内存图
- 当foo开始执行之后:
- AO对象的name和age逐渐进行一个内容的替换,如图7-10
图7-10 foo函数执行内存图
-
当foo执行完了之后:这个时候,bar的内存地址已经存放到fn中了(也就是fn已经指向bar了),并且在后续被fn()给调用了,所以不管foo的函数执行上下文有没有被销毁,都不会影响到bar的函数对象了(因为GO根对象的fn已经指向了bar函数对象了
上面有介绍JavaScript的垃圾回收,也就是标记清除部分,让bar函数对象不被销毁),然后bar函数对象连锁反应又跟foo的AO对象相互进行引用了(最关键的是bar指向foo的AO对象,这是可达的部分),所以foo的AO对象也不会被销毁。这就是为什么bar引用的父级自由变量会得以保留的原因 -
我们接下来就要继续执行fn的函数执行上下文(bar的)了,如图7-11
图7-11 bar的函数执行上下文
- 当bar的执行上下文被销毁掉的时候,也不会影响闭包,因为根对象依旧指向着fn,也就是bar的函数对象,而bar函数对象的父级作用域parentScope指着foo的AO对象,所以这就是脱离了捕捉时的上下文的情况(FEC),它也能照常运行。自由变量依旧存在而没有被销毁,如图7-12
图7-12 bar脱离捕捉时的上下文,自由变量依旧存在
二、闭包的内存泄露
从上面的代码块中,我们可以知道,当bar函数不被销毁的时候,foo的AO对象就永远不会被销毁,因为我们bar要访问foo的AO对象里面的内容
- 目前因为在全局作用域下fn变量对0xb00的函数对象有引用,而0xb00的作用域中AO(0x200)有引用,所以会造成这些内存都是无法被释放的
但如果我们的bar函数只执行一次,后面就再也不需要了,那这个AO对象一直保存着就没有意义了,该销毁的却一直保留着,我们就叫这个是内存泄漏
2.1. 闭包内存泄露解决方案
-
内存泄露的案例其实在我们刚才就已经不知不觉的发生了,我们的name和age其实就打印了一次。后面再也没有进行使用,其实就属于浪费内存的一种行为
-
那我们要如何在用完这个自由变量之后,把它释放掉?
- 最小化闭包作用域:尽量只保持闭包所需的最小作用域,避免闭包访问不需要的数据
- 解除引用:在不需要DOM元素或对象时,手动解除事件绑定或将引用设置为
null,比如在上面示例中可以加上一行代码element.onclick = null;在适当的时候(比如元素被移除或不再需要处理事件时) - 使用弱引用:在一些现代JavaScript应用中,可以使用
WeakMap或WeakSet来存储对对象的引用,这些数据结构不会阻止其键值对中的对象被垃圾回收,但这是ES6之后的语法,在早期的代码中,我们大概是见不到这些的
-
-
在这些步骤之中,第一点是我们平时就需要去注意的规范操作,第二点则是我们目前主要进行的,第三点则放到ES6语法之后进行
//内存泄漏解决方法
function foo(){
var name = "xiaoyu"
var age = 20
function test(){
console.log("这是我的名字",name);
console.log("这是我的年龄",age);
}
return test
}
var fn = foo()
fn()
//解除引用
fn = null//将fn指向null,null的内存地址为0x0。此时fn指向bar的指针就会断开了,AO对象跟bar函数对象就形成了一个对于根对象的不可达的对象,将再下次被销毁掉。注意,你把它置为null之后,不会马上回收的,会在发现之后的下一轮进行回收
图7-13 fn指向bar的指针
- 我们的关键步骤,就是将fn重新设置为null,将右半部分形成孤岛,图7-13到图7-14的转变
- 这个方式,我们在之前讲解垃圾回收的时候由说过,由于JS的V8引擎是主要采纳
标记清除的方式,这主要依靠可达性。一旦两者互相引用形成孤岛,根对象将无法到达,这时候产生内存泄露的闭包就会被回收 - 此时的GO就是根对象,GO收回了指向bar内存地址的指针。则不管foo的AO对象还是bar内存地址,都不在可达,一旦不可达就会被销毁
- 这个方式,我们在之前讲解垃圾回收的时候由说过,由于JS的V8引擎是主要采纳
图7-14 fn指向bar的指针置为null
2.2. 闭包内存泄露案例
- 我们通过一个极端点的案例来进行展示
- 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间。我们通过return的函数内部引用对应长度形成闭包,导致内存无法释放掉,如图7-15中GO对函数对象0xb00的引用
- 而我们如果释放掉,又能节省多少内存
function createFnArray(){
// 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
//4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
//在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
var arr = new Array(1024*1024).fill(1)
return function(){
console.log(arr.length);
}
}
var arrayFn = createFnArray()
图7-15 闭包泄露案例
- 这arr创建出来的内容会不断的堆叠,并且由于闭包原因无法进行释放
- 只要arrayFns数组不被销毁,则createFnArray函数也会一直保留着不被销毁,如图7-16的引用叠加
图7-16 引用叠加,闭包无法释放
- 从浏览器的提供的性能检测
- 选择浏览器的性能选项卡,勾选内存选项。进行刷新执行
- 如图7-17,可以看到在脚本上花费的时间非常的长
图7-17 闭包的性能检测
- 而当我们把闭包将其释放掉的话,并不是马上销毁的。而是GC会查看当前情况是否属于空闲时间,再进行销毁
function createFnArray(){
// 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
//4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
//在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
var arr = new Array(1024*1024).fill(1)
return function(){
console.log(arr.length);
}
}
//var arrayFn = createFnArray()
//arrayFn()
var arrayFns = []
for(var i = 0 ; i<100 ; i++){
//createFnArray()//我们通过for循环不断调用createFnArray这个函数,我们没有使用任何函数去接收他,所以当他创建进入下一个循环之后就会马上被销毁掉
arrayFns.push(createFnArray())
}
setTimeout(() => {
arrayFns = null
}, 2000)
图7-18 性能提升效果
- 而通过性能绘制图7-18,我们也能够看到这个闭包函数在整体占据了多少的流程,还是一目了然的
- 根据我们图7-19的调用树,也能够看到耗时来源都来自我们的闭包部分
图7-19 闭包耗时来源
2.3. AO不使用的属性
我们来研究一个问题:AO对象不会被销毁时,是否里面未用到的部分会进行销毁?
- 下面代码中的name属于闭包的父作用域里面的变量
- 我们知道形成闭包之后name一定不会被销毁掉,那么未使用到的age是否会被销毁掉呢?
- 会,没有被使用到的会销毁掉,
V8引擎做的优化,如图7-20
- 会,没有被使用到的会销毁掉,
function foo() {
var name = "coderwhy"
var age = 18
function bar() {
debugger
console.log(name)
}
return bar
}
var fn = foo()
fn()
图7-20 V8引擎优化效果(未使用变量被销毁)
- 并且此时如果我们在控制台直接打印name,也是可以拿到的。因为在控制台打印的时刻,就是我们目前debugger暂停的时刻
- name可以打印出来,而age因未使用,则被
JS引擎优化回收掉了,通过图7-21的控制台报错可以看出
- name可以打印出来,而age因未使用,则被
图7-21 debugger检测未使用的age变量是否真被回收
- foo的AO对象有bar在指向着,因为bar函数内含父级作用域foo的AO对象的内存地址且正处于引用状态,这个内存地址指向着AO对象,让AO对象不会被销毁掉,但是我们只是引用name这个自由变量,age并没有使用到,按照ECMA规范,正规AO对象都不会被销毁,当然也就包含了我们没有用上的age变量了
- 但是js引擎是非常灵活的,为了提高内存的利用率,这个可能永远使用不上的age属性是会被回收掉的,从而提高空余的内存空间,提高性能
三、JS闭包引用的自由变量销毁
- 当我们除了声明了fn来接收foo()之外,又声明了baz同样子接收foo()
- 这个时候是又执行了一遍foo函数里面的bar部分,fn跟baz不是同时指向同一个地方,而是又创建了一个新的foo的AO对象和bar的函数对象。这点在我们一开始学习的时候就有说明,上下文执行栈调用完就被弹出了,直到下次调用重新创建。两者自然不是指向同一个内容
- 当我们将fn指向null,将内存进行回收时的时候,销毁的也只是fn对应的bar函数对象跟foo()对象,而对baz产生的bar函数对象跟foo的AO对象没有任何的影响
- 毕竟baz是又重新走了一遍流程,baz跟fn是互相独立的(PS:foo的AO对象是由bar的父级作用域内存地址指向而产生出来的)
function foo(){
var name = '小余'
var age = 18
function bar(){
console.log(name);
console.log(age);
}
return bar
}
var fn = foo()
fn()
var baz = foo()
fn = null
baz()
这里有一个值得思考的问题,那就是为什么null可以解除引用,而undefined不行?
- 在这里我们已经知道,当置为null(空)值的时候,内存指向空处,最核心的作用就是
断开引用,明确没有对象 - 而undefined,则是JS引擎给予的默认值。我们在前面章节的变量提升中,知道了变量会被上升到VO对象之中,而在一开始的时候被赋予的就是undefined。这说明了一点,undefined默认值那也是值,只能在一开始表达尚未初始化而已,但并不是指向空处,引用没有被断开
断开引用:断开变量与它所指向的值或对象之间的联系
后续预告
- 通过本章节,我们了解了闭包的运行原理是怎么样的
- 虽然对于应用场景来说,我们还并不全面。这是因为有类似还未学习到的模块化,所以还不是掌握的时候。或者是IIFE立即执行函数这种已经很不经常的见的情况
- 但已经种下的正确的理解种子,当我们遇到其他情况的时候,就会恍然大悟,最重要的内容我们已经学习到了,剩下的只是一些应用方式的部分需要随着知识的积累再去沉淀
- 在下一章节中,我们就需要学习非常重要的知识点this
- 在vue2中,this很常见,经常造成开发者的困扰,不知道该this指向于哪里。但在Vue3中,this淡化出了视野,因为Vue3通过组合式API把this淡化出了视野。但在React中,this是使用非常频繁的内容
- 如果想要在即将发布的React19拔得头筹,this是需要好好掌握的。该知识点并没有退出知识的舞台,因为大多数场景还是能够经常见到的
- 在下一章节中,我们会讨论为什么使用this,this的绑定规则以及各种绑定之间的优先级,比如同时绑定,会生效的是谁?