閉包拾遺
之前寫了篇《閉包初窺》,談了一些我對閉包的淺顯認識,在前文基礎上,補充並且更新些對於閉包的認識。
還是之前的那個經典的例子,來補充些經典的解釋。
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的垃圾收集器才能釋放相應的內存空間。
讓我們說的更透徹一些。所謂“閉包”,就是在構造函數體內定義另外的函數作為目標對象的方法函數,而這個對象的方法函數反過來引用外層函數體中的臨時變量。這使得只要目標對象在生存期內始終能保持其方法,就能間接保持原構造函數體當時用到的臨時變量值。盡管最開始的構造函數調用已經結束,臨時變量的名稱也都消失了,但在目標對象的方法內卻始終能引用到該變量的值,而且該值只能通這種方法來訪問。即使再次調用相同的構造函數,但只會生成新對象和方法,新的臨時變量只是對應新的值,和上次那次調用的是各自獨立的。
還是前文的例子:
<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垃圾回收的機制很簡單:找出不再使用的變量,然后釋放掉其占用的內存,但是這個過程不是實時的,因為其開銷比較大,所以垃圾回收器會按照固定的時間間隔周期性的執行。不再使用的變量也就是生命周期結束的變量,當然只可能是局部變量,全局變量的生命周期直至瀏覽器卸載頁面才會結束。局部變量只在函數的執行過程中存在,而在這個過程中會為局部變量在棧或堆上分配相應的空間,以存儲它們的值,然后在函數中使用這些變量,直至函數結束,而閉包中由於內部函數的原因,外部函數並不能算是結束。
還是上代碼說明吧:
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的引用。
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對象,就會存在循環引用的問題。
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元素之間的連接:
myObject.element = null; element.o = null;
將變量設置為null意味着切斷變量與它此前引用的值之間的連接。當垃圾回收器下次運行時,就會刪除這些值並回收它們占用的內存。