JavaScript閉包與變數的經典問題

迷路的約翰發表於2015-10-28

許多人第一次接觸閉包大概都是從高程裡這段程式碼開始的:

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

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

 或者是用for迴圈在給網頁中一連串元素繫結例如onclick事件時。

所有的教材在講到這一點時都會給出這樣的解釋: 因為每個函式都儲存著createFunction中的活動物件,所以它們引用的都是同一個變數 i 。而迴圈結束後 i 的值為10,所以每個函式的輸出都是10.

解釋非常簡潔與正確。

然而還是會有一部分人看了這個解釋後一知半解,比如我。

我第一次看到這個解釋後有了這麼一連串疑問: 雖然知道 i 最終是 10,但是在每次賦值過程中 i 並不是 10 啊,為什麼非要取最後一個值呢?i 並不是引用資料型別,為什麼可以說“它們引用的都是同一個變數 i ?

如果你和我一樣有這個疑問,其實對這個問題而言我們不理解的地方並不是閉包,但是這個問題被打上了一個嚴重的”閉包“標籤,導致很長一段時間裡我都以為自己不瞭解閉包。

實際上,我不理解的並不是閉包這個概念,而是更為基礎的,函式呼叫的時機。

 

我們把程式碼中賦值的哪一段改一下:

result[i] = function() {
    return j;
}

 把 i 改成 j, 一個並沒有定義的變數。

如果我們僅僅把改完之後的程式碼貼到console裡執行,它是不會報錯的。因為雖然createFunctions被呼叫了,卻並未呼叫賦給result的函式。

只有繼續使用語句呼叫result中的某個元素:

result[0](1);

 這樣才會丟擲 undefined 錯誤。

這說明了一個問題:僅僅宣告某一個函式,引擎並不會對函式內部的任何變數進行查詢或賦值操作。只會對函式內部的語法錯誤進行檢查(如果往內部函式加上非法語句,那麼不用呼叫也會報錯)。

 

所以開頭問題裡的迴圈語句:

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

 我原本以為它是這樣的:

 result[0] = function() { return 0; };
 result[1] = function() { return 1; };
 result[2] = function() { return 2; };

 實際上它是這樣的:

 result[0] = function() { return i; };
 result[1] = function() { return i; };
 result[2] = function() { return i; };

 陣列裡的 i 和 函式裡的 i 並不是一回事, 外面的是常量, 裡面的是變數。

而當我們呼叫result[0]函式時, 這個函式執行到 return 語句,發現並沒有 i 這個變數,於是順著作用鏈去找,在createFunctions裡找到了已經變成10的 i ,於是輸出 10. 這個過程才是閉包的尋找變數的過程。

 

根據這個思路尋找解決方案時思路就明確多了,只要在每次賦值過程中,不讓 i 作為變數,而是確確實實地利用當時 i 的值,方法就是將 i 作為函式引數進行呼叫:

result[i] = (function(val) { return val; })(i);

 這樣一來在每一次賦值的過程中,每一個result[i]都與 i 的當前值產生了聯絡。

當然,這樣修改的問題在於,原題返回的是一個函式,這裡返回的卻是一個值。

所以還要把返回值改成相應的函式:

1 result[i] = (function (val) {
2   return function () {
3     return val;
4   };
5 })(i);

 這樣相當於給目標函式套上了一層塊級作用域,並且在 i 每次迴圈時都將它的值賦給了這個塊級作用域中的一個臨時變數。這個臨時變數其實和 i 沒有太大區別,只不過 i 在它的作用域宣告時值為 0 ,結束後變成了10.而對每個臨時變數而言,開始是多少,結束還是多少。

 

進一步談閉包

任何宣告在另一個函式內部的函式都可以稱為閉包。也就是說,閉包是一個函式。不過也有些地方會講閉包是內部函式以及其作用域鏈組成的一個整體。兩種說法其實一個意思,畢竟嚴格來說,函式的作用域也是函式的一部分。不過我更喜歡後面一種說法,因為它強調了閉包的重點:維持作用域。

閉包主要有兩個概念:可以訪問外部函式,維持函式作用域。第一個概念並沒有什麼特別,大部分程式語言都有這個特性,內部函式可以訪問其外部變數這種事情很常見。所以重點在於第二點。舉例如下:

var globalValue;

function out() {
    var value = 1;
    function inner() {
        return value;
    }
    globalValue = inner;
}

out();

globalValue() // return 1;

 我們先不考慮閉包地看一下這個問題:首先宣告瞭一個全域性變數,然後呼叫了out函式,呼叫函式的過程中全域性變數被賦值了一個函式。out函式呼叫結束之後,按照記憶體處理機制,它內部的所有變數應該都被釋放掉了,不過還好我們把inner複製給了全域性變數,所以還可以在外部呼叫它。接下來我們呼叫了全域性變數,這時候因為out內部作用域已經被釋放了,所以應該找不到value的值,返回應該是undefined。

但是事實是,它的確返回了 1,即內部變數。本該已經消失了,只能存在於out函式內部的變數,走到了牆外。這就是閉包的強大之處。

 

相關文章