本篇文章,我們來說說老(bei)生(xie)常(lan)談(le)的閉包,很多文章包括一些權威書籍中對於閉包的解釋不盡相同,每個人的理解也都不一樣。並且在其他語言中,也有對閉包的不同實現,讓我們來看看
Javascript
中是如何實現閉包的以及有哪些特性。
直接進入主題,上一段簡短的程式碼:
function outer(count) {
var temp = new Array(count)
function log() {
console.log(temp)
}
log()
function inner() {
console.log('done')
}
return inner
}
var o = {}
for(var i = 0; i < 1000000; i++) {
o["f"+i] = outer(i)
}
複製程式碼
如果你不知道這段程式碼可能帶來的問題,那麼這篇文章就值得你讀一讀。
執行上下文 & 作用域鏈
我們先把上面的問題放一放,先讓我們來看一看下面這段簡單的程式碼:
function outer() {
var b = 2
function inner() {
console.log(a, b)
}
return inner
}
var a = 1
var inner = outer()
inner()
複製程式碼
在 JS引擎
中,是通過執行上下文棧來管理和執行程式碼的。上述程式碼的偽執行過程如下(本節內容主要參考冴羽大大的系列文章):
0、程式開始
ECStack = []
複製程式碼
1、建立全域性上下文globalContext,並將其入棧
ECStack = [
globalContext
]
複製程式碼
2、在執行之前初始化這個全域性上下文
globalContext = {
VO: {
a: undefined,
inner: undefined,
outer: function outer() {...}
},
Scope: [globalContext]
}
複製程式碼
初始化作用域鏈屬性 Scope
為 [globalContext]
,此時程式碼還沒有執行,由於變數提升的緣故, inner
和 a
變數為 undefined
,需要注意的是,這個時候,outer
函式的作用域 [[scope]]
內部屬性已確定(靜態作用域):
outer.[[scope]] = [
globalContext.VO
]
複製程式碼
3、執行
globalContext
全域性上下文
在執行過程中,不斷改變 VO
,執行到 a = 1
語句,將 VO
中的 a
置為 1
,執行到 inner = outer()
語句,執行 outer
函式,進入 outer
函式的執行上下文。
4、建立outerContext執行上下文,將其入棧
ECStack = [
outerContext,
globalContext
]
複製程式碼
5、初始化
outerContext
執行上下文
outerContext = {
VO: {
b: undefined,
inner: function inner() {...}
},
Scope: [VO, globalContext.VO]
}
複製程式碼
初始化作用域鏈屬性 Scope
為 [VO].concat(outer.[[scope]])
即 [VO, globalContext.VO]
。
並在此時,確定 inner
函式的 [[scope]]
屬性:
inner.[[scope]] = [
outerContext.VO,
globalContext.VO
]
複製程式碼
6、執行
outerContext
上下文
執行語句 b = 2
,將 VO
中的 b
置為 2
,最後返回 inner
。
7、
outerContext
執行完畢,出棧,繼續回到globalContext
執行餘下的程式碼
ECStack = [
globalContext
]
複製程式碼
繼續執行 inner = outer()
語句的賦值操作,將 outer
函式的返回結果賦給 inner
變數。
執行 inner()
語句,進入 inner
函式的執行上下文。
8、建立
innerContext
執行上下文,將其入棧
ECStack = [
innerContext,
globalContext
]
複製程式碼
9、初始化
innerContext
執行上下文
innerContext = {
VO: {},
Scope: [VO, outerContext.VO, globalContext.VO]
}
複製程式碼
初始化作用域鏈屬性 Scope
為 [VO].concat(inner.[[scope]])
即 [VO, outerContext.VO, globalContext.VO]
。
10、執行
innerContext
上下文
執行語句 console.log(a, b)
,VO
中沒有變數 a
,往上查詢到 outerContext.VO
,找到變數 a
,VO
中沒有變數 b
,依次往上查詢到 globalContext.VO
,找到變數 b
。執行 console.log
函式,這裡同樣涉及到 變數console
的作用域鏈查詢,console.log
函式的執行上下文切換,不再贅述。
11、
globalContext
執行完畢,出棧,程式結束
ECStack = []
複製程式碼
在第7步中,outerContext
執行完畢後,雖然其已出棧並在隨後被垃圾回收機制回收,但是可以看到 innerContext.Scope
仍有對 outerContext.VO
的引用。當 outerContext
被回收後,outerContext.VO
並不會被回收,如下圖:
這就使得我們在執行 inner
函式時仍可以通過其作用域鏈訪問到已執行完畢的 outer
函式中的變數,這就是閉包。
通過執行上下文和作用域鏈相關知識,我們引出了閉包的概念,讓我們繼續。
在第5步中,我們說到,初始化 outerContext
的過程中,同時確定了 inner
函式的作用域屬性 [[scope]]
為 [outerContext.VO, globalContext.VO]
,這其實是不準確的。
我們稍微改動下加上兩句程式碼:
function outer() {
var b = 2
var c = new Array(100000).join('*')
var d = 3
function inner() {
console.log(a, b)
}
return inner
}
var a = 1
var inner = outer()
inner()
複製程式碼
聰明的你會發現,變數 c
和 d
在inner中並不會用到,如果按照如上所述,將 inner
函式的 [[scope]]
屬性置為 [outerContext.VO, globalContext.VO]
,那麼變數 c
(準確的說應該是變數 c
指向的那塊記憶體,下同)只能一直等到 inner
函式執行完畢後才會被銷燬,如果 inner
函式一直不執行的話,new Array(100000).join('*')
所佔用的記憶體一直無法被釋放。
那麼,你可能會想,我們在確定 inner
函式 [[scope]]
屬性的時候,只引用 inner
函式體內用到的變數不就好了嗎?實際上,JS引擎
和你一樣聰明,就是這麼幹的,在 Chrome
除錯工具下:
可以看到,並沒有對變數 c
的引用,我們可以認為 inner
函式 [[scope]]
屬性為:
inner.[[scope]] = [
Closure(outerContext.VO),
globalContext.VO
]
複製程式碼
這裡,我們用 Closure
這樣一個函式來表示得到內部函式體中(包括內部函式中的內部函式,一直下去...)引用外部函式變數的集合,即閉包。
共享閉包
讓我們繼續前進的腳步,把上面的程式碼再稍微改動下:
function outer() {
var b = 2
var c = new Array(100000).join('*')
var d = 3
function log() {
console.log(c)
}
function inner() {
console.log(a, b)
}
log()
return inner
}
var a = 1
var inner = outer()
inner()
複製程式碼
這裡,我們只是加了一個 log
函式,並將變數 c
列印出來。對於 inner
函式來說,並沒有什麼改變,果真如此嗎?我們看下 Chrome
除錯工具下作用域和閉包相關資訊。
outer函式執行之前:
outer函式執行完成:
咦,我們可以看到 inner
函式中的閉包中竟然包含了變數 c
!但是 inner
函式中並沒有用到 c
啊,你可能隱隱發現了什麼,是的,我們在 log
函式中引用了變數 c
,這竟然會影響到 inner
函式的閉包。
在前文中,我們說到確定 inner
函式 [[scope]]
屬性時,會通過 Closure
函式得到 inner
函式體內引用到的所有閉包變數集合,那有多個內部函式呢?
其實,JS引擎
會通過 Clousre
函式得到 outer
函式下所有內部函式體中用到的閉包變數集合 Closure(outerContext.VO)
,並且所有的內部函式的 [[scope]]
屬性都引用這個共同的閉包,所以:
inner.[[scope]] = [
Closure(outerContext.VO),
globalContext.VO
]
log.[[scope]] = [
Closure(outerContext.VO),
globalContext.VO
]
Closure(outerContext.VO) = { b, c }
複製程式碼
讓我們來看看 log
函式的閉包資訊,同樣也有變數 b
:
這裡,你可能會有疑問,變數 a
哪裡去了,其實變數 a
在 globalContext
下。
讀到這裡,細心的你會發現,這和文章開頭給出的程式碼幾乎一毛一樣啊,那究竟會帶來什麼問題呢,我想你應該知道了:記憶體洩露!
讓我們回到文章開頭的那段程式碼,返回的 inner
函式中,一直引用著 temp
變數,在 inner
函式不執行的情況下,temp
變數一直無法被垃圾回收。
我們再稍微改下程式碼:
function outer(count) {
var temp = new Array(count)
function log() {
console.log(temp)
}
log()
function inner() {
var message = 'done'
return function innermost() {
console.log(message)
}
}
return inner()
}
var o = {}
for(var i = 0; i < 1000000; i++) {
o["f"+i] = outer(i)
}
複製程式碼
這裡,我們在 inner
函式裡面又包了一層,那最終返回的 innermost
還有對 temp
變數的引用嗎?
按照前面關於執行上下文相關內容的邏輯分析下去,其實是有的。innermost
的 [[scope]]
屬性如下:
innermost.[[scope]] = [
Closure(innerContext.VO): { message },
Closure(outerContext.VO): { temp },
globalContext
]
複製程式碼
當然,你可能會說,只要 inner
函式執行完成後,這些記憶體就會被回收掉。OK,那我們再來看一個更經典的例子:
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
複製程式碼
unused
函式引用了 originalThing
,由於共享閉包的特性,theThing.someMethod
函式的閉包中也包含了對 originalThing
的引用,而 originalThing
是上一個 theThing
,也就是說下一個 theThing
引用者上一個 theThing
,形成了一個鏈。並隨著 setInterval
的執行,這個鏈越來越長,最終導致記憶體洩露,如下:
如果把間隔時間改小點,分分鐘 out of memory
。
這個例子來源於這裡,建議大家都點進去讀一讀(我記得之前有小夥伴翻譯了這篇文章的,一時找不到了,有知道中文翻譯連結的小夥伴在評論裡貼一下哈)。
Real Local Variable
vs Context Variable
Real Local Variable
,直譯過來就是真正的區域性變數,在這裡變數 d
就是 Real Local Variable
,在C++層面,它可以直接分配在棧上,隨著 inner
函式執行完畢的出棧操作而被立即回收掉,不需要後面垃圾回收機制的干預。
Context Variable
,上下文環境變數或者稱之為閉包變數,在這裡變數 b
就是 Context Variable
, 在C++層面,它一定分配在堆上,儘管這裡它是一個基本型別。
那變數 c
呢,你可以認為它是一個 Real Local Variable
,只是在棧上存的是指向這個 new Array()
的記憶體地址,而 new Array()
的實際內容是存在堆上的。
記憶體分佈如下:
通過上面的分析,我們在很多文章中經常看到的 基本型別分佈在棧上,引用型別分佈在堆上
這句話明顯是錯誤的,對於被閉包引用的變數,不管其是什麼型別,肯定是分配在堆上的。
eval
與閉包
前文中已經提到,JS引擎
會分析所有內部函式體中引用了哪些外部函式的變數,但是對於 eval
的直接呼叫是無法分析的。因為無法預料到 eval
中可能會訪問那些變數,所以會把外部函式中的所有變數都囊括進來。
function outer() {
var b = 2
var c = new Array(100000).join('*')
var d = 3
function inner() {
eval("console.log(1)")
}
return inner
}
var a = 1
var inner = outer()
inner()
複製程式碼
JS引擎
內心OS是這樣的:eval
這傢伙什麼事情都乾的出來,你們(區域性變數)統統不準走!
如果,你在層層巢狀的函式下面來一個 eval
,那麼 eval
所在函式的所有父級函式中的變數都無法被釋放掉,想想就可怕...
那對於 eval
的間接呼叫呢?
function outer() {
var b = 2
var c = new Array(100000).join('*')
var d = 3
function inner() {
(0, eval)("console.log(a)") // 輸出1
}
return inner
}
var a = 1
var inner = outer()
inner()
複製程式碼
這時 JS引擎
內心OS又是這樣的:eval
是誰,不認識,你們(區域性變數)都回家收衣服吧...
其實,對於 eval
和 function
的組合還有各種姿勢,比如:
function outer() {
var b = 2
var c = new Array(100000).join('*')
var d = 3
return eval("(function() { console.log(a) })")
// return (0,eval)("(function() { console.log(a) })")
// return (function(){ return eval("(function(){ console.log(a) })") })()
// ...
// 更多姿勢留待各位自己去發掘和嘗試,逃...
}
var a = 1
var inner = outer()
inner()
複製程式碼
到這裡就寫完了,希望各位對閉包有一個新的認識和見解。
最後歡迎各路大佬們啪啪打臉...