深入學習javaScript閉包(閉包的原理,閉包的作用,閉包與內存管理)


前言

雖然JavaScript是一門完整的面向對象的編程語言,但這門語言同時也擁有許多函數式語言的特性。

函數式語言的鼻祖是LISP,JavaScript在設計之初參考了LISP兩大方言之一的Scheme,引入了Lambda表達式、閉包、高階函數等特性。使用這些特性,我們經常可以用一些靈活而巧妙的方式來編寫JavaScript代碼。

閉包

閉包是指有權訪問另一個函數作用域中的變量的函數。創建閉包的常見方式,就是在一個函數內部創建另一個函數

一個最常見的閉包案例

var closure = function(){
    var count = 0;
    return function(){
        count ++;
    }
}
closure(); // 1
closure(); // 2
closure(); // 3

對於JavaScript程序員來說,閉包(closure)是一個難懂又必須征服的概念。閉包的形成與變量的作用域以及變量的生存周期密切相關。

變量的作用域

變量的作用域,就是指變量的有效范圍。我們最常談到的是在函數中聲明的變量作用域。

當在函數中聲明一個變量的時候,如果該變量前面沒有帶上關鍵字var,這個變量就會成為全局變量,這當然是一種容易造成命名沖突的做法。

另外一種情況是用var關鍵字在函數中聲明變量,這時候的變量即是局部變量,只有在該函數內部才能訪問到這個變量,在函數外面是訪問不到的。代碼如下:

var func = function(){
    var a = 1;
    alert ( a );     // 輸出: 1
};

func();
alert ( a );     // 輸出:Uncaught ReferenceError: a is not defined

[重點]在JavaScript中,函數可以用來創造函數作用域。此時的函數像一層半透明的玻璃,在函數里面可以看到外面的變量,而在函數外面則無法看到函數里面的變量。這是因為當在函數中搜索一個變量的時候,如果該函數內並沒有聲明這個變量,那么此次搜索的過程會隨着代碼執行環境創建的作用域鏈往外層逐層搜索,一直搜索到全局對象為止。變量的搜索是從內到外而非從外到內的。

變量的生存周期

除了變量的作用域之外,另外一個跟閉包有關的概念是變量的生存周期。

對於全局變量來說,全局變量的生存周期當然是永久的,除非我們主動銷毀這個全局變量。

而對於在函數內用var關鍵字聲明的局部變量來說,當退出函數時,這些局部變量即失去了它們的價值,它們都會隨着函數調用的結束而被銷毀:

var func = function(){
    var a = 1;      // 退出函數后局部變量a將被銷毀
    alert ( a );
};

func();

看閉包的形式

var func = function(){
    var a = 1;
    return function(){
        a++;
        alert ( a );
    }
};

var f = func();

f();    // 輸出:2
f();    // 輸出:3
f();    // 輸出:4
f();    // 輸出:5

當退出函數后,局部變量a並沒有消失,而是似乎一直在某個地方存活着。這是因為當執行var f = func();時,f返回了一個匿名函數的引用,它可以訪問到func()被調用時產生的環境,而局部變量a一直處在這個環境里。既然局部變量所在的環境還能被外界訪問,這個局部變量就有了不被銷毀的理由。在這里產生了一個閉包結構,局部變量的生命看起來被延續了。

【閉包經典經典案例】

function createFunctions(){
    var result = new Array();

    for (var i=0; i < 10; i++){
        result[i] = function(){
            return i;
        };
    }

    return result;
} 

這個函數會返回一個函數數組。表面上看,似乎每個函數都應該返自己的索引值,即位置0的函數返回0,位置1的函數返回1,以此類推。但實際上,每個函數都返回10。因為每個函數的作用域鏈中都保存着createFunctions()函數的活動對象,所以它們引用的都是同一個變量i。當createFunctions()函數返回后,變量i的值是10,此時每個函數都引用着保存變量i的同一個變量對象,所以在每個函數內部i的值都是10。但是,我們可以通過創建另一個匿名函數強制讓閉包的行為符合預期,如下所示。

function createFunctions(){
    var result = new Array();
    for (var i=0; i < 10; i++){
        result[i] = function(num){
            return function(){
                return num;
            };
        }(i);
    }
    return result;
}

[重點]在重寫了前面的createFunctions()函數后,每個函數就會返回各自不同的索引值了。在這個版本中,我們沒有直接把閉包賦值給數組,而是定義了一個匿名函數,並將立即執行該匿名函數的結果賦給數組。這里的匿名函數有一個參數num,也就是最終的函數要返回的值。在調用每個匿名函數時,我們傳入了變量i。由於函數參數是按值傳遞的,所以就會將變量i的當前值復制給參數num。而在這個匿名函數內部,又創建並返回了一個訪問num的閉包。這樣一來,result數組中的每個函數都有自己num變量的一個副本,因此就可以返回各自不同的數值了。

閉包的作用

【封裝變量】
閉包可以幫助把一些不需要暴露在全局的變量封裝成“私有變量”。假設有一個計算乘積的簡單函數:

var mult = function(){
    var a = 1;
    for ( var i = 0, l = arguments.length; i < l; i++ ){
        a = a * arguments[i];
    }
    return a;
};

mult函數接受一些number類型的參數,並返回這些參數的乘積。現在我們覺得對於那些相同的參數來說,每次都進行計算是一種浪費,我們可以加入緩存機制來提高這個函數的性能:

var cache = {};

var mult = function(){
    var args = Array.prototype.join.call( arguments, ',' ); // 通過call方法借用數組方法
    if ( cache[ args ] ){
        return cache[ args ];
    }

    var a = 1;
    for ( var i = 0, l = arguments.length; i < l; i++ ){
        a = a * arguments[i];
    }

    return cache[ args ] = a;
};

alert ( mult( 1,2,3 ) );     // 輸出:6
alert ( mult( 1,2,3 ) );     // 輸出:6

我們看到cache這個變量僅僅在mult函數中被使用,與其讓cache變量跟mult函數一起平行地暴露在全局作用域下,不如把它封閉在mult函數內部,這樣可以減少頁面中的全局變量,以避免這個變量在其他地方被不小心修改而引發錯誤。代碼如下:

var mult = (function(){
    var cache = {};
    return function(){
        var args = Array.prototype.join.call( arguments, ',' );
        if ( args in cache ){
            return cache[ args ];
        }
        var a = 1;
        for ( var i = 0, l = arguments.length; i < l; i++ ){
            a = a * arguments[i];
        }
        return cache[ args ] = a;
    }
})();

提煉函數是代碼重構中的一種常見技巧。如果在一個大函數中有一些代碼塊能夠獨立出來,我們常常把這些代碼塊封裝在獨立的小函數里面。獨立出來的小函數有助於代碼復用,如果這些小函數有一個良好的命名,它們本身也起到了注釋的作用。如果這些小函數不需要在程序的其他地方使用,最好是把它們用閉包封閉起來。代碼如下:

var mult = (function(){
    var cache = {};
    var calculate = function(){   // 封閉calculate函數
        var a = 1;
        for ( var i = 0, l = arguments.length; i < l; i++ ){
            a = a * arguments[i];
        }
        return a;
    };

    return function(){
        var args = Array.prototype.join.call( arguments, ',' );
        if ( args in cache ){
            return cache[ args ];
        }

        return cache[ args ] = calculate.apply( null, arguments );
    }
})();

【延續局部變量的壽命】
img對象經常用於進行數據上報,如下所示:

var report = function( src ){
    var img = new Image();
    img.src = src;
};

report( 'http://xxx.com/getUserInfo' );

但是通過查詢后台的記錄我們得知,因為一些低版本瀏覽器的實現存在bug,在這些瀏覽器下使用report函數進行數據上報會丟失30%左右的數據,也就是說,report函數並不是每一次都成功發起了HTTP請求。丟失數據的原因是img是report函數中的局部變量,當report函數的調用結束后,img局部變量隨即被銷毀,而此時或許還沒來得及發出HTTP請求,所以此次請求就會丟失掉。

現在我們把img變量用閉包封閉起來,便能解決請求丟失的問題:

 var report = (function(){
    var imgs = [];
    return function( src ){
        var img = new Image();
        imgs.push( img );
        img.src = src;
    }
})();

閉包和面向對象設計

過程與數據的結合是形容面向對象中的“對象”時經常使用的表達。
對象以方法的形式包含了過程,而閉包則是在過程中以環境的形式包含了數據。
通常用面向對象思想能實現的功能,用閉包也能實現。反之亦然。

閉包的寫法

var extent = function(){
    var value = 0;
    return {
        call: function(){
            value++;
            console.log( value );
        }
    }
};

var extent = extent();

extent.call();     // 輸出:1
extent.call();     // 輸出:2
extent.call();     // 輸出:3

換成面向對象的寫法,代碼如下

var extent = {
    value: 0,
    call: function(){
        this.value++;
        console.log( this.value );
    }
};

extent.call();     // 輸出:1
extent.call();     // 輸出:2
extent.call();     // 輸出:3

閉包與this

在閉包中使用this對象也可能會導致一些問題。我們知道,this對象是在運行時基於函數的執行環境綁定的:在全局函數中,this等於window,而當函數被作為某個對象的方法調用時,this等於那個對象。不過,匿名函數的執行環境具有全局性,因此其this對象通常指向window1。但有時候由於編寫閉包的方式不同,這一點可能不會那么明顯。下面來看一個例子。
[注意]在通過call()或apply()改變函數執行環境的情況下,this就會指向其他對象。

var name = "The Window";

var object = {
    name : "My Object",

    getNameFunc : function(){
        return function(){
            return this.name;
        };
    }
};

alert(object.getNameFunc()());  //"The Window"(在非嚴格模式下)

以上代碼先創建了一個全局變量name,又創建了一個包含name屬性的對象。這個對象還包含一個方法——getNameFunc(),它返回一個匿名函數,而匿名函數又返回this.name。由於getNameFunc()返回一個函數,因此調用object.getNameFunc()()就會立即調用它返回的函數,結果就是返回一個字符串。然而,這個例子返回的字符串是"The Window",即全局name變量的值。為什么匿名函數沒有取得其包含作用域(或外部作用域)的this對象呢?

因為每個函數在被調用時都會自動取得兩個特殊變量:this和arguments。內部函數在搜索這兩個變量時,只會搜索到其活動對象為止,因此永遠不可能直接訪問外部函數中的這兩個變量;不過,把外部作用域中的this對象保存在一個閉包能夠訪問到的變量里,就可以讓閉包訪問該對象了,如下所示。

var name = "The Window";

var object = {
    name : "My Object",

    getNameFunc : function(){
        var that = this;
        return function(){
            return that.name;
        };
    }
};

alert(object.getNameFunc()());  //"My Object"

閉包與內存管理

閉包是一個非常強大的特性,但人們對其也有諸多誤解。一種聳人聽聞的說法是閉包會造成內存泄露,所以要盡量減少閉包的使用。

局部變量本來應該在函數退出的時候被解除引用,但如果局部變量被封閉在閉包形成的環境中,那么這個局部變量就能一直生存下去。從這個意義上看,閉包的確會使一些數據無法被及時銷毀。使用閉包的一部分原因是我們選擇主動把一些變量封閉在閉包中,因為可能在以后還需要使用這些變量,把這些變量放在閉包中和放在全局作用域,對內存方面的影響是一致的,這里並不能說成是內存泄露。如果在將來需要回收這些變量,我們可以手動把這些變量設為null。

跟閉包和內存泄露有關系的地方是,使用閉包的同時比較容易形成循環引用,如果閉包的作用域鏈中保存着一些DOM節點,這時候就有可能造成內存泄露。但這本身並非閉包的問題,也並非JavaScript的問題。在IE瀏覽器中,由於BOM和DOM中的對象是使用C++以COM對象的方式實現的,而COM對象的垃圾收集機制采用的是引用計數策略。在基於引用計數策略的垃圾回收機制中,如果兩個對象之間形成了循環引用,那么這兩個對象都無法被回收,但循環引用造成的內存泄露在本質上也不是閉包造成的。

同樣,如果要解決循環引用帶來的內存泄露問題,我們只需要把循環引用中的變量設為null即可。將變量設置為null意味着切斷變量與它此前引用的值之間的連接。當垃圾收集器下次運行時,就會刪除這些值並回收它們占用的內存。

閉包關聯關鍵詞

1、變量的作用域
2、函數像一層半透明的玻璃
3、閉包經典案例:for循環中i值的獲取
4、閉包作用:1封裝變量 2延續局部變量的壽命(圖片ping案例)
5、閉包與面向對象
6、閉包與內存管理


免責聲明!

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



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