一文顛覆大眾對(duì)閉包的認(rèn)知
大廠技術(shù)??高級(jí)前端??Node進(jìn)階
點(diǎn)擊上方?程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
閉包 & 內(nèi)存泄漏
網(wǎng)絡(luò)上對(duì)閉包的解釋基本上都和 MDN 大同小異,“閉包就是訪問(wèn)了自由變量的函數(shù)”,其實(shí)這是為了大眾方便理解而給出的錯(cuò)誤結(jié)論(即使是這樣似乎也有許多人無(wú)法理解閉包)
對(duì)于閉包產(chǎn)生的內(nèi)存泄漏,網(wǎng)絡(luò)中流傳的大多數(shù)說(shuō)法都是:“因?yàn)樽雍瘮?shù)執(zhí)行時(shí)父函數(shù)的執(zhí)行上下文已經(jīng)退出執(zhí)行上下文棧,但是由于子函數(shù)作用域鏈的引用導(dǎo)致父函數(shù)的 活動(dòng)對(duì)象AO 無(wú)法被銷(xiāo)毀”導(dǎo)致的。
其實(shí)上面的這兩個(gè)廣為流傳的方法都是錯(cuò)誤的,下面我將為你介紹真正的閉包和其內(nèi)存泄漏的產(chǎn)生原理。
作用域鏈 [[Scopes]]
全局代碼存儲(chǔ)其變量的地方叫做變量對(duì)象(VO),函數(shù)存儲(chǔ)其變量的叫活動(dòng)對(duì)象(AO),VO 和 AO 都是在預(yù)編譯時(shí)確定其內(nèi)容,然后在代碼運(yùn)行時(shí)被修改值。
每一個(gè)函數(shù)都有一個(gè) [[Scopes]] 屬性,其存儲(chǔ)的是這個(gè)函數(shù)運(yùn)行時(shí)的作用域鏈,除了當(dāng)前函數(shù)的 AO,作用域鏈的其他部分都會(huì)在其父函數(shù)預(yù)編譯時(shí)添加到函數(shù)的 [[Scopes]] 屬性上(因?yàn)楦负瘮?shù)也需要預(yù)編譯后才能確定自己的AO),所以 js 的作用域是詞法作用域。
//?1:?global.VO?=?{t}
let?t?=?111
function?fun(){
????//?3:?fun.AO?=?{a,b}
????let?a?=?1
????let?b?=?2
????function?fun1()?{
????????//?5:?fun1.AO?=?{c}
????????let?c?=?3
????}
????//?4:?fun1.[[Scopes]]?=?[...fun.[[Scopes]],?fun.AO]
}
//?2:?fun.[[Scopes]]?=?[global.VO]
fun()
上面代碼在 fun() 被調(diào)用前,會(huì)立即預(yù)編譯 fun 函數(shù),這一步會(huì)得到 fun 的活動(dòng)對(duì)象(AO),然后運(yùn)行 fun 函數(shù),在執(zhí)行到 let a = 1 的時(shí)候,會(huì)將變量對(duì)象到 a 屬性改成 1。后面也是一樣
[[Scopes]] 就像一個(gè)數(shù)組一樣,每一個(gè)函數(shù)的 [[Scopes]] 中都存在當(dāng)前函數(shù)的 AO 和上級(jí)函數(shù)的 [[Scopes]]。在函數(shù)運(yùn)行時(shí)會(huì)優(yōu)先取距離當(dāng)前函數(shù) AO 近的變量值,這就是作用域的就近原則。
對(duì)于 作用域鏈、AO、VO 本文不詳細(xì)介紹了,如果想詳細(xì)了解可以 看這里
但是(重點(diǎn)來(lái)了)
上面介紹的 [[Scopes]] 可能就是大家熟知的,這在以前是對(duì)的。
但是最新的 V8 中已經(jīng)發(fā)生了變化(Chrome 中已經(jīng)可以看到這些變化),在為一個(gè)函數(shù)綁定詞法作用域時(shí),并不會(huì)粗暴的直接把父函數(shù)的 AO 放入其 [[Scopes]] 中,而是會(huì)分析這個(gè)函數(shù)中會(huì)使用父函數(shù) AO 中的哪些變量,而這些可能會(huì)被使用到的變量會(huì)被存儲(chǔ)在一個(gè)叫做 Closure 的對(duì)象中,每一個(gè)函數(shù)都有且只有一個(gè) Closure 對(duì)象,最終這個(gè) Closure 將會(huì)代替父函數(shù)的 AO 出現(xiàn)在子函數(shù)的 [[Scopes]] 中
“網(wǎng)絡(luò)上的說(shuō)法是:父函數(shù)的 AO 直接會(huì)被放入子函數(shù)的
”[[Scopes]]中,也沒(méi)有提到Closure對(duì)象,很明顯這放在現(xiàn)在來(lái)看是不對(duì)的,當(dāng)前后面我會(huì)給出例子證明。
閉包對(duì)象 Closure
在V8中每一個(gè)函數(shù)執(zhí)行前都會(huì)進(jìn)行預(yù)編譯,預(yù)編譯階段都會(huì)執(zhí)行3個(gè)重要的字節(jié)碼
CreateFunctionContext 創(chuàng)建函數(shù)執(zhí)行上下文 PushContext 上下文入棧 CreateClosure 創(chuàng)建函數(shù)的閉包對(duì)象
也就是說(shuō),每一個(gè)函數(shù)執(zhí)行前都會(huì)創(chuàng)建一個(gè)閉包,無(wú)論這個(gè)閉包是否被使用,那么閉包中的內(nèi)容是什么?如何確定其內(nèi)容?
Closure 跟 [[Scopes]] 一樣會(huì)在函數(shù)預(yù)編譯時(shí)被確定,區(qū)別是當(dāng)前函數(shù)的 [[Scopes]] 是在其父函數(shù)預(yù)編譯時(shí)確定, 而 Closure 是在當(dāng)前函數(shù)預(yù)編譯時(shí)確定(在當(dāng)前函數(shù)執(zhí)行上下文創(chuàng)建完成入棧后就開(kāi)始創(chuàng)建閉包對(duì)象了)。
當(dāng) V8 預(yù)編一個(gè)函數(shù)時(shí),如果遇到內(nèi)部函數(shù)的定義不會(huì)選擇跳過(guò),而是會(huì)快速的掃描這個(gè)內(nèi)部函數(shù)中使用到的本函數(shù) AO 中的變量,然后將這些變量的引用加入 Closure 對(duì)象。再來(lái)為這個(gè)內(nèi)部函數(shù)函數(shù)綁定 [[Scopes]] ,并且使用當(dāng)前函數(shù)的 Closure 作為內(nèi)部函數(shù) [[Scopes]] 的一部分。
“注意:每一次遇到內(nèi)部聲明的函數(shù)/方法時(shí)都會(huì)這么做,無(wú)論其內(nèi)部函數(shù)/方法的聲明嵌套有多深,并且他們使用的都是同一個(gè)
”Closure對(duì)象。并且這個(gè)過(guò)程 是在預(yù)編譯時(shí)進(jìn)行的而不是在函數(shù)運(yùn)行時(shí)。
//?1:?global.VO?=?{t}
var?t?=?111
//?2:?fun.[[Scopes]]?=?[global.VO]
function?fun(){
????//?3:?fun.AO?=?{a,b},并創(chuàng)建一個(gè)空的閉包對(duì)象fun.Closure?=?{}
????let?a?=?1,b?=?2,c?=?3
????//?4:?遇到函數(shù),解析到函數(shù)會(huì)使用a,所以?fun.Closure={a:1}?(實(shí)際沒(méi)這么簡(jiǎn)單)
????//?5:?fun1.[[Scopes]]?=?[global.VO,?fun.Closure]
????function?fun1()?{
????????debugger
????????console.log(a)
????}
????fun1()
????let?obj?=?{
????????//?6:?遇到函數(shù),解析到函數(shù)會(huì)使用b,所以?fun.Closure={a:1,b:2}
????????//?7:?method.[[Scopes]]?=?[global.VO,?fun.Closure]
????????method(){
????????????console.log(b)
????????}
????}
}
//?執(zhí)行到這里時(shí),預(yù)編譯?fun
fun()
“1、2發(fā)生在全局代碼的預(yù)編譯階段,3、4、5、6、7發(fā)生在 fun 的預(yù)編譯階段。
”
fun1 執(zhí)行時(shí)的作用域鏈?zhǔn)沁@樣的:[fun1.AO, fun.Closure, global.VO]
我們可以看到 fun1 的作用域鏈中的確不存在 fun.AO ,而是存在 fun.Closure。并且 fun.Closure 中的內(nèi)容是 a 和 b 兩個(gè)變量,并沒(méi)有 c。這足以證明所有子函數(shù)使用的是同一個(gè)閉包對(duì)象。
細(xì)心的你會(huì)發(fā)現(xiàn) Closure 在 method 的定義執(zhí)行前就已經(jīng)包含 b 變量,這說(shuō)明 Closure 在函數(shù)執(zhí)行前早已確定好了,還有一點(diǎn)就是 Closure 中的變量存儲(chǔ)的是對(duì)應(yīng)變量的引用地址,如果這個(gè)變量值發(fā)生變化,那么 Closure 中對(duì)應(yīng)的變量也會(huì)發(fā)生變化(后面會(huì)證明)
而且這里 fun1 并沒(méi)有返回到外部調(diào)用形成網(wǎng)絡(luò)上描述的閉包(網(wǎng)絡(luò)上很多說(shuō)法是需要返回一個(gè)函數(shù)才會(huì)形成閉包,很顯然這也是不對(duì)的),而是直接在函數(shù)內(nèi)部同步調(diào)用。
結(jié)論:每一個(gè)函數(shù)都會(huì)產(chǎn)生閉包,無(wú)論 閉包中是否存在內(nèi)部函數(shù) 或者 內(nèi)部函數(shù)中是否訪問(wèn)了當(dāng)前函數(shù)變量 又或者 是否返回了內(nèi)部函數(shù),因?yàn)殚]包在當(dāng)前函數(shù)預(yù)編譯階段就已經(jīng)創(chuàng)建了。
“是不是有點(diǎn)顛覆到你對(duì)閉包的認(rèn)知了呢?別急,后面還有更多呢。
”
內(nèi)存泄漏
說(shuō)到閉包那么就不得不說(shuō)內(nèi)存泄漏,首先我們要搞清楚為什么會(huì)內(nèi)存泄漏?
所謂閉包產(chǎn)生的內(nèi)存泄漏就是因?yàn)殚]包對(duì)象 Closure 無(wú)法被釋放回收,那么什么情況下 Closure 才會(huì)被回收呢?
這當(dāng)然是在沒(méi)有任何地方引用 Closure 的時(shí)候,因?yàn)?Closure 會(huì)被所有的子函數(shù)的作用域鏈 [[Scopes]] 引用,所以想要 Closure 不被引用就需要所有子函數(shù)都被銷(xiāo)毀,從而導(dǎo)致所有子函數(shù)的 [[Scopes]] 被銷(xiāo)毀,然后 Closure 才會(huì)被銷(xiāo)毀。
這與許多網(wǎng)絡(luò)上的資料是不一樣的,常見(jiàn)的說(shuō)法是必須返回的函數(shù)中使用的自由變量才會(huì)產(chǎn)生閉包,也就是下面這樣
function?fun(){
????let?arr?=?Array(10000000)
????return?function(){
????????console.log(arr);//?使用了?arr
????}
}
window.f?=?fun()
但是其實(shí)不然,即使返回的的函數(shù)沒(méi)有訪問(wèn)自由變量,只要有任何一個(gè)函數(shù)將 arr 添加到閉包對(duì)象 Closure 中,arr 都不會(huì)正常被銷(xiāo)毀,所以下面兩段代碼都會(huì)產(chǎn)生內(nèi)存泄漏
function?fun(){
????let?arr?=?Array(10000000)
????function?fun1(){//?arr?加入?Closure
????????console.log(arr)
????}
????return?function?fun2(){}
}
window.f?=?fun()//?長(zhǎng)久持有fun2的引用
“因?yàn)?
”fun1讓arr加入了Closure,fun2又被window.f持有引用無(wú)法釋放,因?yàn)?fun2的作用域鏈包含Closure,所以Closure也無(wú)法釋放,最終導(dǎo)致arr無(wú)法釋放產(chǎn)生內(nèi)存泄漏。
function?fun(){
????let?arr?=?Array(10000000)
????function?fun1()?{//?arr?加入?Closure
????????console.log(arr)
????}
????window.obj?=?{//?長(zhǎng)久持有?window.obj.method?的引用
????????method(){}
????}
}
fun()
“同理是因?yàn)?
”window.obj.method作用域鏈持有fun1的Closure引用導(dǎo)致arr無(wú)法釋放。
那么我們將 arr = null 會(huì)不會(huì)讓 arr 被釋放呢?答案是會(huì)。這里有人可能會(huì)疑惑了:
Closure.arr = arr 將 arr 加入到 Closure,然后將 arr = null,這為什么會(huì)讓 Closure.arr 發(fā)生變化呢?
這說(shuō)明將變量加入到 Closure 并不是簡(jiǎn)單的 Closure.arr = arr 的過(guò)程,這是一個(gè)引用傳遞,也就是說(shuō) Closure.arr 存儲(chǔ)的是對(duì)變量 arr 的引用,當(dāng) arr 變化時(shí) Closure.arr 也會(huì)發(fā)生變化。這對(duì)于 js 來(lái)說(shuō)可能有點(diǎn)難實(shí)現(xiàn),但是 c++ 借助指針的特性要實(shí)現(xiàn)這一點(diǎn)是輕而易舉的。
上面我們簡(jiǎn)單的介紹了一下閉包產(chǎn)生內(nèi)存泄漏的根本原因是因?yàn)?Closure 被其所有子函數(shù)的作用域鏈引用,只要有一個(gè)子函數(shù)沒(méi)有銷(xiāo)毀,Closure 就無(wú)法銷(xiāo)毀,導(dǎo)致其中的變量也無(wú)法銷(xiāo)毀,最終產(chǎn)生了內(nèi)存泄漏。
“什么?看了這么多你告訴我你還不知道怎么看是否發(fā)生了內(nèi)存泄漏?
打開(kāi)Chrome瀏覽器的控制臺(tái)的 Performance monitor,看到 JS heap size 變化曲線了嗎?如果它不斷上升并且你 點(diǎn)擊 Memory 中這個(gè)垃圾回收的按鈕后它依然沒(méi)有下降到正常值,那么你的代碼大概率是發(fā)生了內(nèi)存泄漏,
現(xiàn)在我執(zhí)行了一段上面的demo,可以看到內(nèi)存大小是上升了一個(gè)量級(jí)
過(guò)了一段時(shí)間發(fā)現(xiàn)他并沒(méi)有下降的趨勢(shì),即使我手動(dòng)點(diǎn)擊垃圾回收按鈕,內(nèi)存也沒(méi)有回到最開(kāi)始的正常值,很明顯,這就是內(nèi)存泄漏
”如果你還沒(méi)有搗鼓出這個(gè)界面來(lái),建議先暫停一下然后去 谷歌 一下,因?yàn)楹竺娴?demo 我不會(huì)貼出運(yùn)行結(jié)果圖,需要你自己在電腦上運(yùn)行查看內(nèi)存變化。
提升一下難度
下面是一個(gè)經(jīng)典的內(nèi)存泄漏的例子,在大多數(shù)與閉包內(nèi)存泄漏的文章或者書(shū)籍中都能看到他的影子
let?theThing?=?null;
let?replaceThing?=?function?()?{
????let?leak?=?theThing;
????function?unused?()?{?
????????if?(leak){}
????};
????theThing?=?{??
????????longStr:?new?Array(1000000),
????????someMethod:?function?()?{??
???????????????????????????????????
????????}
????};
};
let?index?=?0;
while(index?100){
????replaceThing()
????index++;
}
“為了防止各位看官輕易嘗試導(dǎo)致電腦崩潰,我把原來(lái)例子中的 setInterval 換成了一個(gè)有限的循環(huán)
”
可能比較容易發(fā)現(xiàn)上面代碼發(fā)生內(nèi)存泄漏的原因是因?yàn)?someMethod ,因?yàn)?theThing 是全局變量導(dǎo)致 someMethod 無(wú)法釋放最終導(dǎo)致 replaceThing 的 Closure 無(wú)法釋放。但是 replaceThing 的 Closure 中存在什么呢?
let?leak?=?theThing;
function?unused?()?{?//?leak?加入?Closure
????if?(leak){}?
};
是的,存在 leak,又因?yàn)?leak 指向的是 theThing 的值,雖然首次執(zhí)行 replaceThing 時(shí) theThing 是 null,但是第二次執(zhí)行 replaceThing 時(shí) theThing 就變?yōu)榱艘粋€(gè)存在大對(duì)象的對(duì)象了。
因?yàn)? Closure無(wú)法釋放導(dǎo)致其中的leak變量也無(wú)法釋放,導(dǎo)致theThing無(wú)法釋放theThing會(huì)導(dǎo)致someMethod無(wú)法釋放從而導(dǎo)致 ?Closure無(wú)法釋放
可能你已經(jīng)看了幾遍,最終開(kāi)始看出了問(wèn)題。沒(méi)錯(cuò),這是一個(gè)循環(huán),theThing 導(dǎo)致 Closure 無(wú)法釋放,Closure 又導(dǎo)致另一個(gè) theThing 無(wú)法釋放......
這段代碼參數(shù)內(nèi)存泄漏的原因可以是因?yàn)橐画h(huán)扣一環(huán)的引用引起的,我們把第 i 次 replaceThing 執(zhí)行時(shí)產(chǎn)生的 leak 叫做 leaki,theThing 叫做 theThingi, Closure 叫做 Closurei,如果這個(gè)函數(shù)執(zhí)行3次,那么它的引用鏈路應(yīng)該是這樣的:
theThing3(全局作用域) -> someMethod3 -> Closure3 -> leak3 -> theThing2 -> someMethod2 -> Closure2 -> leak2 -> theThing1 -> someMethod1 -> Closure1 -> leak1 -> theThing0 -> null
可見(jiàn) replaceThing 每執(zhí)行一次這個(gè)鏈路中就會(huì)多一個(gè) theThing,因?yàn)?theThing.longStr 上一個(gè)大對(duì)象導(dǎo)致內(nèi)存飆升并且無(wú)法回收(引用的源頭總是全局的 theThing )。
最粗暴的解決方法肯定是將全局 theThing 變?yōu)?null,這如同切斷水流的源頭一樣。
但是在 replaceThing 的最后將 leak = null 也可以打破這個(gè)微妙的引用鏈路。因?yàn)檫@可以讓 Closure 中的 leak 也變?yōu)?null 從而失去對(duì) theThing 的引用,當(dāng)在下一次執(zhí)行 replaceThing 時(shí)會(huì)因?yàn)?theThing = xxx 導(dǎo)致原來(lái)的 theThing 失去最后的引用而回收掉,這也會(huì)讓 theThing.someMethod 和 Closure 可以被回收。
let?theThing?=?null;
let?replaceThing?=?function?()?{
????let?leak?=?theThing;
????function?unused?()?{
????????if?(leak){}
????};
????theThing?=?{??
????????longStr:?new?Array(1000000),
????????someMethod:?function?()?{
???????????????????????????????????
????????}
????};
????leak?=?null?//?解決問(wèn)題
};
let?index?=?0;
while(index?100){
????replaceThing()
????index++;
}
總結(jié)
好了,現(xiàn)在我們來(lái)吧之前介紹的內(nèi)容總結(jié)一下
每一個(gè)函數(shù)在執(zhí)行之前都會(huì)進(jìn)行預(yù)編譯,預(yù)編譯時(shí)會(huì)創(chuàng)建一個(gè)空的閉包對(duì)象。 每當(dāng)這個(gè)函數(shù)預(yù)編譯時(shí)遇到其內(nèi)部的函數(shù)聲明時(shí),會(huì)快速的掃描內(nèi)部函數(shù)使用了當(dāng)前函數(shù)中的哪些變量,將可能使用到的變量加入到閉包對(duì)象中,最終這個(gè)閉包對(duì)象將作為這些內(nèi)部函數(shù)作用域鏈中的一員。 只有所有內(nèi)部函數(shù)的作用域鏈都被釋放才會(huì)釋放當(dāng)前函數(shù)的閉包對(duì)象,所謂的閉包內(nèi)存泄漏也就是因?yàn)殚]包對(duì)象無(wú)法釋放產(chǎn)生的。 我們還介紹的一個(gè)巧妙且經(jīng)典的內(nèi)存泄漏案例,并且通過(guò)一些demo的運(yùn)行結(jié)果證明了上面這些結(jié)論的正確性。
不知道這些知識(shí)也沒(méi)有顛覆你對(duì)閉包的認(rèn)知呢?如果對(duì)文章有疑問(wèn)歡迎評(píng)論,如果有收獲感謝點(diǎn)贊??。
相關(guān)資料
在js里,如果父函數(shù)中的子函數(shù)沒(méi)有交給外部,那么V8對(duì)子訪問(wèn)父的變量還會(huì)當(dāng)做closure(閉包)嗎?——知乎
輕松排查線上Node內(nèi)存泄漏問(wèn)題——nodejs社區(qū)
JavaScript中變量存儲(chǔ)在堆中還是棧中?——知乎
JavaScript 深入系列——掘金
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺(jué)得這篇內(nèi)容對(duì)你有幫助,我想請(qǐng)你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客?www.inode.club?讓我們一起成長(zhǎng) 點(diǎn)贊和在看就是最大的支持??



如果你還沒(méi)有搗鼓出這個(gè)界面來(lái),建議先暫停一下然后去 谷歌 一下,因?yàn)楹竺娴?demo 我不會(huì)貼出運(yùn)行結(jié)果圖,需要你自己在電腦上運(yùn)行查看內(nèi)存變化。