很久之前就想寫一篇關於閉包的博客了,但是總是擔心寫的不夠完全、不夠好,不管怎樣,還是要把我理解的閉包和大家分享下,比較長,希望耐心看完。
定義
說實話,給閉包下一個定義是很困難的,原因在於javascript設計的時候並沒有專門設計閉包這樣一個規則,閉包是隨着作用域鏈、函數可以作為一等公民這樣的規則而誕生的。
盡管不能下一個很完美的定義,但是我們還是可以給閉包下一個盡量准確的定義。
閉包:當函數可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行。
閉包是基於詞法作用域書寫代碼時所產生的自然結果,你甚至不需要為了利用它們而有意識地創建閉包。閉包的創建和使用在你的代碼中隨處可見。
哪些是閉包?
來看下面這個例子1:
function foo() { var a = 2; function bar() { console.log( a ); // 2 } bar(); } foo();
基於詞法作用域的查找規則,函數bar() 可以訪問外部作用域中的變量a(這個例子中的是一個RHS 引用查詢)。
那么這個是閉包嗎?很遺憾不是,因為bar函數執行在其定義的詞法作用域處。
不過稍加修改后就是個閉包了,例子2:
function foo() { var a = 2; function bar() { console.log( a ); } return bar; } var baz = foo(); baz(); // 2 —— 朋友,這就是閉包的效果。
baz函數執行實際上只是通過不同的標識符引用調用了內部的函數bar()
bar()函數顯然可以被正常執行,也就是在自己定義的詞法作用域以外的地方執行。
根據作用域的規則,函數bar()函數能夠訪問foo()的內部作用域,因此foo()執行完后,其內部作用域並不會被回收,bar() 依然持有對該作用域的引用,而這個引用就叫作閉包。
這個函數在定義時的詞法作用域以外的地方被調用。閉包使得函數可以繼續訪問定義時的詞法作用域。
當然,無論使用何種方式對函數類型的值進行傳遞,當函數在別處被調用時都可以觀察到閉包。
來看例子3:
function foo() { var a = 2; function baz() { console.log( a ); // 2 } bar( baz ); } function bar(fn) { fn(); // 媽媽快看呀,這就是閉包! }
是的,這也是個閉包,這里將baz傳遞出去了在bar()函數中執行,而不是在自己定義的詞法作用域中執行,但是它卻保留這對定義時詞法作用域的引用
再看例子4:
var fn; function foo() { var a = 2; function baz() { console.log( a ); } fn = baz; // 將baz 分配給全局變量 } function bar() { fn(); // 媽媽快看呀,這就是閉包! } foo(); bar(); // 2
是的沒錯,這還是個閉包,無論通過何種手段將內部函數傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函數都會使用閉包。
那我們看一個難一點的例子5:
function wait(message) { setTimeout( function timer() { console.log( message ); }, 1000 ); } wait( "Hello, closure!" );
這是閉包嗎?答案是的,在這里我們向setTimeOut傳入timer()函數,並且timer函數可以訪問wait的內部作用域,保持着對wait內部作用域的引用,比如里面的message變量。
這時候你肯定會心生疑惑:不對呀?這在哪執行呢?不是說要在定義的詞法作用域以外執行嗎?
傳入的timer函數當然會被執行,只是內部引擎調用執行的。
深入到引擎的內部原理中,內置的工具函數setTimeout(..) 持有對一個參數的引用,這個參數也許叫作fn 或者func,或者其他類似的名字。引擎會調用這個函數,在例子中就是內部的timer 函數,而詞法作用域在這個過程中保持完整,time函數保持着對wait內部作用域的引用。
IIFE(立即執行函數)是閉包嗎?
例子6:
var a = 2; (function IIFE() { console.log( a ); })();
按照我們的定義來說,這不是閉包。
但是,盡管IIFE 本身並不是觀察閉包的恰當例子,但它的確創建了閉包,並且也是最常用來創建可以被封閉起來的閉包的工具。
因此IIFE 的確同閉包息息相關,即使本身並不會真的使用閉包。
這也是為什么很難給閉包下定義的地方,因為如果從內存或者作用來看,IIFE創建了閉包(也就是在內存中創建了一塊區域,這塊區域保存着作用域鏈上作用域的引用,稍后可見例子9),或者說效果等同於創建了閉包。
而如果從閉包的定義來看,這卻不是閉包。
我們來看例子7:
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); }
大家都知道這段代碼會輸出五次6,為什么呢?
因為setTimeOut()是異步函數,也就是等循環結束后才去執行setTimeOut()中的回調函數,而在for循環中,並不存在着塊級作用域,也就是這個i聲明在全局作用域中,並且自始至終只有一個i(因為var聲明會變量聲明提升,也就是其實只聲明了一次),而在for循環結束后,這個i的值是6。setTimeOut()中的回調函數timer()保持着對i的引用,但是5次timer()函數引用的只是同一個i,所以輸出5次6。
例子8:
for (var i=1; i<=5; i++) { (function() { setTimeout( function timer() { console.log( i ); }, i*1000 ); })(); }
這樣有效果么?答案是沒有的,雖然通過IIFE每次都創建了一個作用域,但是這個作用域是空的(也就是創建了一個空作用域),所以還會沿着詞法作用域鏈去上一層找i,結果找到的還是全局作用域中的i,也就是只有一個i,還是會輸出五次6。
所以我們需要這樣改,來看例子9:
// 它需要有自己的變量,用來在每個迭代中儲存i 的值: for (var i=1; i<=5; i++) { (function() { var j = i; setTimeout( function timer() { console.log( j ); }, j*1000 ); })(); } // 行了!它能正常工作了!。 // 可以對這段代碼進行一些改進: for (var i=1; i<=5; i++) { (function(j) { setTimeout( function timer() { console.log( j ); }, j*1000 ); })( i ); } //當然你也可以這樣寫 for (var i=1; i<=5; i++) { (function(i) { setTimeout( function timer() { console.log( i ); }, i*1000 ); })( i ); }
在迭代內使用IIFE 會為每個迭代都生成一個新的作用域,使得延遲函數的回調可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變量供我們訪問。
好在ES6出來了let的解決方案,let並不會變量聲明提升,並且具有塊級作用域的效果,也就是這里會產生5個i的內存空間,被五個timer()函數引用着。
例子10:
for (let i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); }
關於閉包的垃圾回收
問題1:閉包會造成內存泄漏嗎?
我們常說閉包會造成內存泄漏,這是真的嗎?答案是不會的。
之所以之前一直說閉包會造成垃圾泄露是由於IE9 之前的版本對JavaScript 對象(標記清除)和COM 對象(引用計數)使用不同的垃圾收集方法。因此閉包在IE 的這些版本中會導致一些特殊的問題。具體來說,如果閉包的作用域鏈中保存着一個HTML 元素,那么就意味着該元素將無法被銷毀
例子11:
function assignHandler(){ var element = document.getElementById("someElement"); element.onclick = function(){ alert(element.id); }; }
以上代碼創建了一個作為element 元素事件處理程序的閉包,而這個閉包則又創建了一個循環引用。由於匿名函數保存了一個對assignHandler()的活動對象的引用,因此就會導致無法減少element 的引用數。只要匿名函數存在,element 的引用數至少也是1,因此它所占用的內存就永遠不會被回收。
解決辦法就是把element.id 的一個副本保存在一個變量中,從而消除閉包中該變量的循環引用同時將element變量設為null。
例子12:
function assignHandler(){ var element = document.getElementById("someElement"); var id = element.id; element.onclick = function(){ alert(id); }; element = null; }
問題2:閉包中沒有使用的變量會被回收嗎?
答案是會的。
來看例子13:
function foo() { var x = {}; var y = "whatever"; return function bar() { alert(y); }; } var z = foo();
在這里x沒有被使用,那么x會被回收嗎?答案是的。
理論上來說,bar函數保存着foo作用域中的引用,那么x不應該會被回收。但是現代javascript引擎是非常智能的,對這里進行了優化。
javascript引擎經過逃逸分析(分析函數調用關系,以判斷變量是否“逃逸”出當前作用域范圍)后判斷出來x沒有在閉包中使用到,那么它就會把x從堆中的作用域中移除出去。
一般是如何分析呢?很簡單,如果閉包中沒有引用到這個變量,並且沒有使用 eval
或者 new Function,那么javascript引擎可以知道閉包的內存中的作用域不需要這個變量x.
具體測試可以看之前司徒正美的一篇文章:JS閉包測試
或者可以看看stackoverflow上的一篇解答:JavaScript Closures Concerning Unreferenced Variables
問題3:閉包中函數里的變量是分配在堆中還是棧中?
在簡單的解釋器實現里,函數里的變量是分配在堆而不是在棧上的。現代 JS 引擎當然就比較牛逼了,通過逃逸分析是可以知道哪些可以分配在棧上,哪些需要分配在堆上的。
也就是閉包中使用到的變量會分配在堆中,沒有使用到的會分配在棧中(針對簡單類型而言),以方便回收。
比如例子13的x,沒有被閉包使用,不過是一個復雜類型,所以它在內存中是變量x存儲在棧中,同時棧中x的值是堆中的對象{}的地址,大概是下面這樣
【棧x】---->(堆{})
例子13中的y,被閉包使用了,閉包的函數就基於原先的詞法作用域單獨在堆中分配了內存,也就是閉包保存在了堆,同時其使用的變量也隨着閉包一起保存在堆,大概是下面這樣。
(堆(閉包(y:“whatever”)))
好了,以上這就是我的個人理解了,如果有什么疑問或者建議歡迎討論。