你不知道的JS(2)深入了解閉包


很久之前就想寫一篇關於閉包的博客了,但是總是擔心寫的不夠完全、不夠好,不管怎樣,還是要把我理解的閉包和大家分享下,比較長,希望耐心看完。

定義

說實話,給閉包下一個定義是很困難的,原因在於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”)))

 

 

好了,以上這就是我的個人理解了,如果有什么疑問或者建議歡迎討論。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM