閉包拾遺
之前寫了篇《閉包初窺》,談了一些我對閉包的淺顯認識,在前文基礎上,補充並且更新些對於閉包的認識。
還是之前的那個經典的例子,來補充些經典的解釋。
1 2 3 4 5 6 7 8 9 10 11 |
function outerFn() { var a = 0; function innerFn() { console.log(a++); } return innerFn; } var fn = outerFn(); fn(); // 0 fn(); // 1 |
這裡並沒有在outerFn內部修改全域性變數,而是從outerFn中返回了一個對innerFn的引用。通過呼叫outerFn能夠獲得這個引用,而且這個引用可以可以儲存在變數中。 這種即使離開函式作用域的情況下仍然能夠通過引用呼叫內部函式的事實,意味著只要存在呼叫內部函式的可能,JavaScript就需要保留被引用的函式。而且JavaScript執行時需要跟蹤引用這個內部函式的所有變數,直到最後一個變數廢棄,JavaScript的垃圾收集器才能釋放相應的記憶體空間。
讓我們說的更透徹一些。所謂“閉包”,就是在建構函式體內定義另外的函式作為目標物件的方法函式,而這個物件的方法函式反過來引用外層函式體中的臨時變數。這使得只要目標物件在生存期內始終能保持其方法,就能間接保持原建構函式體當時用到的臨時變數值。儘管最開始的建構函式呼叫已經結束,臨時變數的名稱也都消失了,但在目標物件的方法內卻始終能引用到該變數的值,而且該值只能通這種方法來訪問。即使再次呼叫相同的建構函式,但只會生成新物件和方法,新的臨時變數只是對應新的值,和上次那次呼叫的是各自獨立的。
還是前文的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<ul> <li>0</li> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul> <script> var lis = document.getElementsByTagName('li'); for(var i = 0; i < lis.length; i++) { ~function(num) { lis[i].onclick = function() { alert(num) }; }(i) } </script> |
為什麼不加立即執行函式,alert的都會是5呢?
如果不加IIFE,當i的值為5的時候,判斷條件不成立,for迴圈執行完畢,但是因為每個li的onclick方法這時候為內部函式,所以i被閉包引用,記憶體不能被銷燬,i的值會一直保持5,直到程式改變它或者所有的onclick函式銷燬(主動把函式賦為null或者頁面解除安裝)時才會被回收。這樣每次我們點選li的時候,onclick函式會查詢i的值(作用域鏈是引用方式),一查等於5,然後就alert給我們了。加上IIFE後即是又建立了一層閉包,函式宣告放在括號內就變成了表示式,後面再加上括號就是呼叫了,這時候把i當引數傳入,函式立即執行,num儲存每次i的值。
垃圾回收機制(GC)
接下來說說垃圾回收機制(Garbage Collecation)。
在上面的第一個例子中,變數始終儲存在記憶體中,說到底與JavaScript的垃圾回收機制有關。JavaScript垃圾回收的機制很簡單:找出不再使用的變數,然後釋放掉其佔用的記憶體,但是這個過程不是實時的,因為其開銷比較大,所以垃圾回收器會按照固定的時間間隔週期性的執行。不再使用的變數也就是生命週期結束的變數,當然只可能是區域性變數,全域性變數的生命週期直至瀏覽器解除安裝頁面才會結束。區域性變數只在函式的執行過程中存在,而在這個過程中會為區域性變數在棧或堆上分配相應的空間,以儲存它們的值,然後在函式中使用這些變數,直至函式結束,而閉包中由於內部函式的原因,外部函式並不能算是結束。
還是上程式碼說明吧:
1 2 3 4 5 6 7 8 9 10 11 |
function fn1() { var obj = {name: 'hanzichi', age: 10}; } function fn2() { var obj = {name:'hanzichi', age: 10}; return obj; } var a = fn1(); var b = fn2(); |
我們來看程式碼是如何執行的。首先定義了兩個function,分別叫做fn1和fn2,當fn1被呼叫時,進入fn1的環境,會開闢一塊記憶體存放物件{name: ‘hanzichi’, age: 10},而當呼叫結束後,出了fn1的環境,那麼該塊記憶體會被js引擎中的垃圾回收器自動釋放;在fn2被呼叫的過程中,返回的物件被全域性變數b所指向,所以該塊記憶體並不會被釋放。
垃圾回收機制的種類
函式中的區域性變數的生命週期:區域性變數只在函式執行的過程中存在。而在這個過程中,會為區域性變數在棧(或堆)記憶體上分配相應的空間,以便儲存它們的值。然後在函式中使用這些變數,直至函式執行結束。此時,區域性變數就沒有存在的必要了,因此可以釋放它們的記憶體以供將來使用。在這種情況下,很容易判斷變數是否還有存在的必要;但並非所有情況下都這麼容易就能得出結論。垃圾回收器必須跟蹤哪個變數有用,哪個變數沒用,對於不再有用的變數打上標記,以備將來收回其佔用的記憶體。用於標識無用變數的策略可能會因實現而異,但具體到瀏覽器中的實現,則通常有兩個策略。
- 標記清除
js中最常用的垃圾回收方式就是標記清除。當變數進入環境時,例如,在函式中宣告一個變數,就將這個變數標記為“進入環境”。從邏輯上講,永遠不能釋放進入環境的變數所佔用的記憶體,因為只要執行流進入相應的環境,就可能會用到它們。而當變數離開環境時,則將其標記為“離開環境”。
垃圾回收器在執行的時候會給儲存在記憶體中的所有變數都加上標記(當然,可以使用任何標記方式)。然後,它會去掉環境中的變數以及被環境中的變數引用的變數的標記(閉包)。而在此之後再被加上標記的變數將被視為準備刪除的變數,原因是環境中的變數已經無法訪問到這些變數了。最後,垃圾回收器完成記憶體清除工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。
到2008年為止,IE、Firefox、Opera、Chrome、Safari的js實現使用的都是標記清除的垃圾回收策略或類似的策略,只不過垃圾收集的時間間隔互不相同。
- 引用計數
引用計數的含義是跟蹤記錄每個值被引用的次數。當宣告瞭一個變數並將一個引用型別值賦給該變數時,則這個值的引用次數就是1。如果同一個值又被賦給另一個變數,則該值的引用次數加1。相反,如果包含對這個值引用的變數又取得了另外一個值,則這個值的引用次數減1。當這個值的引用次數變成0時,則說明沒有辦法再訪問這個值了,因而就可以將其佔用的記憶體空間回收回來。這樣,當垃圾回收器下次再執行時,它就會釋放那些引用次數為0的值所佔用的記憶體。
Netscape Navigator3是最早使用引用計數策略的瀏覽器,但很快它就遇到一個嚴重的問題:迴圈引用。迴圈引用指的是物件A中包含一個指向物件B的指標,而物件B中也包含一個指向物件A的引用。
1 2 3 4 5 6 7 8 |
function fn() { var a = {}; var b = {}; a.pro = b; b.pro = a; } fn(); |
以上程式碼a和b的引用次數都是2,fn()執行完畢後,兩個物件都已經離開環境,在標記清除方式下是沒有問題的,但是在引用計數策略下,因為a和b的引用次數不為0,所以不會被垃圾回收器回收記憶體,如果fn函式被大量呼叫,就會造成記憶體洩露。
我們知道,IE中有一部分物件並不是原生js物件。例如,其DOM和BOM中的物件就是使用C++以COM物件的形式實現的,而COM物件的垃圾回收機制採用的就是引用計數策略。因此,即使IE的js引擎採用標記清除策略來實現,但js訪問的COM物件依然是基於引用計數策略的。換句話說,只要在IE中涉及COM物件,就會存在迴圈引用的問題。
1 2 3 4 |
var element = document.getElementById("some_element"); var myObject = new Object(); myObject.e = element; element.o = myObject; |
這個例子在一個DOM元素(element)與一個原生js物件(myObject)之間建立了迴圈引用。其中,變數myObject有一個名為element的屬性指向element物件;而變數element也有一個屬性名為o回指myObject。由於存在這個迴圈引用,即使例子中的DOM從頁面中移除,它也永遠不會被回收。
為了避免類似這樣的迴圈引用問題,最好是在不使用它們的時候手工斷開原生js物件與DOM元素之間的連線:
1 2 |
myObject.element = null; element.o = null; |
將變數設定為null意味著切斷變數與它此前引用的值之間的連線。當垃圾回收器下次執行時,就會刪除這些值並回收它們佔用的記憶體。