閉包—-你所不知道的JavaScript系列(4)

李某龍發表於2018-07-02

一、閉包是什麼?

  · 閉包就是可以使得函式外部的物件能夠獲取函式內部的資訊。

  · 閉包是一個擁有許多變數和繫結了這些變數的環境的表示式(通常是一個函式),因而這些變數也是該表示式的一部分。

  · 閉包就是一個“捕獲”或“攜帶”了其被生成的環境中、所屬的變數範圍內所引用的所有變數的函式。

  還有很多很多解釋……

 

  函式物件可以通過作用域鏈互相關聯起來,函式體內部的變數都可以儲存在函式作用域內,這叫做“閉包”。      –《JavaScript權威指南》

  當函式可以記住並訪問所在的詞法作用域時, 就產生了閉包, 即使函式是在當前詞法作用域之外執行。   –《你所不知道的JavaScript》

 

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}

var baz = foo();
baz(); // 2     

 

這就是閉包的效果。 函式 bar() 的詞法作用域能夠訪問 foo() 的內部作用域。然後我們將 bar() 函式本身當作一個值型別進行傳遞。在這個例子中,我們將 bar 所引用的函式物件本身當作返回值。bar()顯然可以被正常執行。但是在這個例子中,它在自己定義的詞法作用域以外的地方執行。在 foo() 執行後, 通常會期待 foo() 的整個內部作用域都被銷燬,因為我們知道引擎有垃圾回收器用來釋放不再使用的記憶體空間。由於看上去 foo() 的內容不會再被使用,所以很自然地會考慮對其進行回收。而閉包的“神奇” 之處正是可以阻止這件事情的發生。事實上內部作用域依然存在,因此沒有被回收。 誰在使用這個內部作用域? 原來是 bar() 本身在使用。拜 bar() 所宣告的位置所賜,它擁有涵蓋 foo() 內部作用域的閉包,使得該作用域能夠一直存活,以供 bar() 在之後任何時間進行引用。bar() 依然持有對該作用域的引用, 而這個引用就叫作閉包。
無論使用何種方式對函式型別的值進行傳遞,當函式在別處被呼叫時都可以觀察到閉包。閉包使得函式可以繼續訪問定義時的詞法作用域。

 

二、作用域鏈和js垃圾回收機制

  在深入理解閉包之前,最好能先理解一下作用域鏈的含義以及js垃圾回收機制。

  簡單來說,作用域鏈就是函式在定義的時候建立的(而不是在函式呼叫時定義),用於尋找使用到的變數的值的一個索引,而他內部的規則是,把函式自身的本地變數放在最前面,把自身的父級函式中的變數放在其次,把再高一級函式中的變數放在更後面,以此類推直至全域性物件為止。當函式中需要查詢一個變數的值的時候,js直譯器會去作用域鏈去查詢,從最前面的本地變數中先找,如果沒有找到對應的變數,則到下一級的鏈上找,一旦找到了變數,則不再繼續。如果找到最後也沒找到需要的變數,則直譯器返回undefined。

  瞭解了作用域鏈,我們再來看看js的記憶體回收機制。一般來說,一個函式在執行開始的時候,會給其中定義的變數劃分記憶體空間儲存,以備後面的語句所用,等到函式執行完畢返回了,這些變數就被認為是無用的了,對應的記憶體空間也就被回收了。下次再執行此函式的時候,所有的變數又回到最初的狀態,重新賦值使用。但是如果這個函式內部又巢狀了另一個函式,而這個函式是有可能在外部被呼叫到的,並且這個內部函式又使用了外部函式的某些變數的話,這種記憶體回收機制就會出現問題。如果在外部函式返回後,又直接呼叫了內部函式,那麼內部函式就無法讀取到他所需要的外部函式中變數的值了。所以js直譯器在遇到函式定義的時候,會自動把函式和他可能使用的變數(包括本地變數和父級和祖先級函式的變數(自由變數))一起儲存起來。也就是構建一個閉包,這些變數將不會被記憶體回收器所回收,只有當內部的函式不可能被呼叫以後(例如被刪除了,或者沒有了指標),才會銷燬這個閉包,而沒有任何一個閉包引用的變數才會被下一次記憶體回收啟動時所回收。

 

三、閉包的缺點以及優點

  缺點:

    (1)由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除。

    (2)閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數量當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

  

  優點:

    (1)希望一個變數長期駐紮在記憶體中。
    (2)避免全域性變數的汙染。
    (3)私有成員的存在。

 

接下來,我們就針對閉包的三個優點進行解析,一起來看看吧。

 

儲存變數

function test() {  
   var a = 1;  
   return function(){
       alert(a++)
   };  
}         
var fun = test();  
fun();   // 1  執行後 a++,然後a還在~
fun();   // 2
fun = null;   //解除引用,等待垃圾回收

  在執行fun = test()時,其實就相當於fun = function(){ alert(a++) }。如果我們平時這樣宣告並賦值一個變數的時候,在呼叫的時候就會報錯(a未被宣告)。但是在閉包中卻不會報錯,因為在呼叫test() 的時候,變數a是存在於內部匿名函式的作用域鏈上的,並且在呼叫完之後變數a不會消失。

原因:由於匿名函式一直在引用變數a,js垃圾回收機制將不會把變數a當做垃圾去回收,所以a會一直存在記憶體當中。

 

既然知道了閉包能將變數一直儲存在記憶體中,那麼我們再來看看一下幾個例子的區別,來加深對閉包的瞭解。

第一段程式碼:

var scope = "global scope";
function checkscope(){
     var scope = "local scope";
     function f(){
          return scope;
     } 
     return f();
} 


checkscope();    //返回值會是什麼?

第二段程式碼:

var scope = "global scope";
function checkscope(){
     var scope = "local scope";
     function f(){
          return scope;
     } 
     return f;
} 


checkscope()();    //返回值會是什麼?

  我們可以看到第一段程式碼,在函式checkscope內返回的函式f的結果,很清楚可以知道返回值為”local scope”。而在第二段程式碼中,函式checkscope內返回的函式物件f,返回的是一個物件而不是一個運算的結果。現在在定義函式的作用域外面,呼叫這個巢狀函式f,返回的結果依舊是”local scope”。為什麼在定義函式外部呼叫巢狀函式,返回的結果卻還是函式內部變數。這裡面就涉及到了函式的作用域鏈。上面說過,函式的作用域鏈是在函式定義時就生成的,而不是在函式呼叫時生成的。巢狀函式f()定義時在checkscope的作用域鏈內,其中的scope是區域性變數,其值為local scope,不管在何時何地執行函式f(),這種繫結在執行f()時依然有效,因此返回的值是”local scope”。

 

避免全域性汙染

  在這裡,我先提個問題,如果要讓你實現一個從數字1開始累加的功能,你會怎麼實現?

  我們來看看下面兩個程式碼。

 

//使用全域性變數
var a = 1;
function abc(){
    a++;
    alert(a);
}
abc();   //2
abc();   //3

 

//使用區域性變數
function abc(){
    var a = 1;
    a++;
    alert(a);
}
abc();   //2                  
abc();   //2

  在上面的例子可以看出,使用全域性變數可以很輕鬆就實現累加效果,但是使用全域性變數會造成全域性汙染。我們可以使用區域性變數來實現累加,但是上面使用區域性變數的結果不盡人意(原因:只是對函式進行簡單的呼叫,每次呼叫完之後函式內的變數都會被銷燬。再次呼叫時會重新賦值初值,並不會儲存上一次呼叫後的值)。那我們要怎麼才能利用區域性變數實現累加呢?

  在前面的例子中,我們看到了閉包可以儲存函式內的變數,所以我們可以利用閉包將變數a的值儲存起來,每次呼叫之後,a的值會累加並且不會被銷燬。來看看下面怎麼利用閉包怎麼實現累加功能的。

function outer(){
    var x=1;
    return function(){                     
        x++;
        alert(x);
    }
}
var y = outer();   //外部函式賦給變數y
y();    //y函式呼叫一次,結果為2,相當於outer()()
y();    //y函式呼叫第二次,結果為3,實現了累加

  我們知道,js是沒有塊級作用域的概念的,這裡我們就可以看到,可以用閉包來模擬模擬塊級作用域,從而避免全域性汙染。

 

私有成員

  由於閉包可以捕捉到單個函式呼叫的區域性變數,並將這些區域性變數用做私有狀態,所以可以來定義私有成員。

var init = (function(){
         var counter = 0;
    return function(){
                return counter++;
        }
})();

init();   //0
init();   //1
init();   //2

  上面這段程式碼定義了一個立即呼叫的函式,因此這個函式的返回值賦值給了變數init。再來看一下函式體,這個函式返回另外一個函式,由於被返回的函式能夠訪問自己作用域內的變數,而且能夠訪問其外部函式(函式體)中定義的counter變數。當立即執行函式執行之後,其他任何程式碼都無法訪問變數counter,只有其內部的函式才能訪問到它。所以此時變數counter就變成一個私有變數,存在於閉包中。

  像counter這樣的私有變數不單隻可以存在一個單獨的閉包中,在同一個外部函式內定義的多個巢狀函式也可以訪問到它,這多個巢狀函式都共享一個作用域鏈,現在看一下這段程式碼。

function counter(){
     var n = 0;
     return{
         count : function(){ return n++; }
         reset :  function(){ n =0; }
     }
}

var c = counter(), d = counter();
c.count();    //0
d.count();    //0
c.reset();     //reset()和count()方法共享
c.count();    //0  (因為重置了c)
d.count();    //1  (沒有重置d,因此n繼續累加)

如果現在需要實現一個功能:返回一個函式組成的陣列,並且它們的返回值分別是0~9。你會怎麼實現?會不會跟下面一段程式碼一樣?

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

var funcs = contsfuncs();
funcs[5]();   //返回值是什麼?

  上面這段程式碼建立了10個閉包,並且將它們儲存到一個陣列中。由於這些閉包都是在同一個函式呼叫中定義的,所以他們都可以共享變數i。當contsfuncs()返回時,變數i都是10,所以所有的閉包都共享這一個變數值,因此,陣列中的函式的返回值都是同一個值。那我們要怎麼做才能實現我們想要的功能呢?

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

funcs[5]();   //5

  由於外部函式contsfuncs()總是返回一個返回變數v的值,所以在for迴圈中,由於每次呼叫外部函式contsfuncs()時,傳入的v的值是不同的,所以所形成的閉包都是不同的,這十個閉包中變數v的值分別為0~9,所以陣列funcs中在第五個位置的元素所表示的函式返回值為5。

總結:
(1)在同一個呼叫函式內部定義多個閉包,這些閉包共享呼叫函式的變數,每個閉包對其操作都會影響到其他閉包對其引用的值。
(2)利用同一個呼叫函式在函式外部構造的多個閉包,則這些閉包都是獨立的,擁有自己的作用域鏈,互不干擾。

 

四、閉包中的this

  在閉包中使用this,需要特別小心,因為很容易就出錯。不信?看看下面例子就知道了。

var name = "window";
var obj = {
    name : "object",
    getName : function(){
        return function(){
           return this.name;
        }
    }
};
alert(obj.getName()());   // window

  我們本來的想法是想呼叫閉包後返回obj物件中的name的值,即”object”,但是結果卻是返回”window”。為什麼這個閉包返回的this.name的值不是區域性變數name的值,而是全域性變數name的值?

  在這裡首先必須要說的是,this的指向在函式定義的時候是確定不了的,只有函式執行的時候才能確定this到底指向誰,實際上this的最終指向的是那個呼叫它的物件。this是JavaScript的關鍵字,而不是變數,每個函式呼叫都包含一個this的值,如果閉包在外部函式裡是無法訪問到閉包裡面的this值的。因為這個this和當初定義函式時的this不是同一個,即便是同一個this,this的值是隨著呼叫棧的變化而變化的,而閉包裡的邏輯所取到的this的值也不是確定的。由於匿名函式的執行具有全域性性,因此其this通常指向window。當然,我們還是有辦法來解決這種問題的,就是將this轉存為一個變數就可以避免this的不確定性帶來的歧義。如下:

var name = "window";
var obj = {
    name : "object",
    getName : function(){
        var that = this;
        return function(){
            return that.name;
        }
    }
};
alert(obj.getName()());   // object

 

五、小試牛刀

看看點選不同li標籤時,alert的值會是多少?

HTML:
<
ul> <li>0</li> <li>1</li> <li>2</li> <li>3</li> </ul>
JS:
window.onload = function(){ var aLi = document.getElementsByTagName(`li`); for (var i=0;i<aLi.length;i++){ aLi[i].onclick = function(){ alert(i); }; }

  這是一道很經典的筆試題,也是很多初學者經常犯錯而且找不到原因的一段程式碼。想要實現的效果是點選不同的<li>標籤,alert出其對應的索引值,但是實際上程式碼執行之後,我們會發現不管點選哪一個<li>標籤,alert出的i都為4。為什麼呢?因為在執行for迴圈之後,i的值已經變成了4,等到點選<li>標籤時,alert的i值是4。下面就用閉包來解決這個問題。


window.onload = function(){
     var aLi = document.getElementsByTagName(`li`);
   for (var i=0;i<aLi.length;i++){
(function(i){
aLi[i].onclick = function(){
alert(i);
};
       })(i);
   }
}

你做對了嗎?

相關文章