JavaScript深入之閉包

冴羽發表於2017-04-27

JavaScript深入系列第八篇,介紹理論上的閉包和實踐上的閉包,以及從作用域鏈的角度解析經典的閉包題。

定義

MDN 對閉包的定義為:

閉包是指那些能夠訪問自由變數的函式。

那什麼是自由變數呢?

自由變數是指在函式中使用的,但既不是函式引數也不是函式的區域性變數的變數。

由此,我們可以看出閉包共有兩部分組成:

閉包 = 函式 + 函式能夠訪問的自由變數

舉個例子:

var a = 1;

function foo() {
    console.log(a);
}

foo();複製程式碼

foo 函式可以訪問變數 a,但是 a 既不是 foo 函式的區域性變數,也不是 foo 函式的引數,所以 a 就是自由變數。

那麼,函式 foo + foo 函式訪問的自由變數 a 不就是構成了一個閉包嘛……

還真是這樣的!

所以在《JavaScript權威指南》中就講到:從技術的角度講,所有的JavaScript函式都是閉包。

咦,這怎麼跟我們平時看到的講到的閉包不一樣呢!?

彆著急,這是理論上的閉包,其實還有一個實踐角度上的閉包,讓我們看看湯姆大叔翻譯的關於閉包的文章中的定義:

ECMAScript中,閉包指的是:

  1. 從理論角度:所有的函式。因為它們都在建立的時候就將上層上下文的資料儲存起來了。哪怕是簡單的全域性變數也是如此,因為函式中訪問全域性變數就相當於是在訪問自由變數,這個時候使用最外層的作用域。
  2. 從實踐角度:以下函式才算是閉包:
    1. 即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回)
    2. 在程式碼中引用了自由變數

接下來就來講講實踐上的閉包。

分析

讓我們先寫個例子,例子依然是來自《JavaScript權威指南》,稍微做點改動:

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

var foo = checkscope();
foo();複製程式碼

首先我們要分析一下這段程式碼中執行上下文棧和執行上下文的變化情況。

另一個與這段程式碼相似的例子,在《JavaScript深入之執行上下文》中有著非常詳細的分析。如果看不懂以下的執行過程,建議先閱讀這篇文章。

這裡直接給出簡要的執行過程:

  1. 進入全域性程式碼,建立全域性執行上下文,全域性執行上下文壓入執行上下文棧
  2. 全域性執行上下文初始化
  3. 執行 checkscope 函式,建立 checkscope 函式執行上下文,checkscope 執行上下文被壓入執行上下文棧
  4. checkscope 執行上下文初始化,建立變數物件、作用域鏈、this等
  5. checkscope 函式執行完畢,checkscope 執行上下文從執行上下文棧中彈出
  6. 執行 f 函式,建立 f 函式執行上下文,f 執行上下文被壓入執行上下文棧
  7. f 執行上下文初始化,建立變數物件、作用域鏈、this等
  8. f 函式執行完畢,f 函式上下文從執行上下文棧中彈出

瞭解到這個過程,我們應該思考一個問題,那就是:

當 f 函式執行的時候,checkscope 函式上下文已經被銷燬了啊(即從執行上下文棧中被彈出),怎麼還會讀取到 checkscope 作用域下的 scope 值呢?

以上的程式碼,要是轉換成 PHP,就會報錯,因為在 PHP 中,f 函式只能讀取到自己作用域和全域性作用域裡的值,所以讀不到 checkscope 下的 scope 值。(這段我問的PHP同事……)

然而 JavaScript 卻是可以的!

當我們瞭解了具體的執行過程後,我們知道 f 執行上下文維護了一個作用域鏈:

fContext = {
    Scope: [AO, checkscopeContext.AO, globalContext.VO],
}複製程式碼

對的,就是因為這個作用域鏈,f 函式依然可以讀取到 checkscopeContext.AO 的值,說明當 f 函式引用了 checkscopeContext.AO 中的值的時候,即使 checkscopeContext 被銷燬了,但是 JavaScript 依然會讓 checkscopeContext.AO 活在記憶體中,f 函式依然可以通過 f 函式的作用域鏈找到它,正是因為 JavaScript 做到了這一點,從而實現了閉包這個概念。

所以,讓我們再看一遍實踐角度上閉包的定義:

  1. 即使建立它的上下文已經銷燬,它仍然存在(比如,內部函式從父函式中返回)
  2. 在程式碼中引用了自由變數

在這裡再補充一個《JavaScript權威指南》英文原版對閉包的定義:

This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.

閉包在電腦科學中也只是一個普通的概念,大家不要去想得太複雜。

必刷題

接下來,看這道刷題必刷,面試必考的閉包題:

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();複製程式碼

答案是都是 3,讓我們分析一下原因:

當執行到 data[0] 函式之前,此時全域性上下文的 VO 為:

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}複製程式碼

當執行 data[0] 函式的時候,data[0] 函式的作用域鏈為:

data[0]Context = {
    Scope: [AO, globalContext.VO]
}複製程式碼

data[0]Context 的 AO 並沒有 i 值,所以會從 globalContext.VO 中查詢,i 為 3,所以列印的結果就是 3。

data[1] 和 data[2] 是一樣的道理。

所以讓我們改成閉包看看:

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
        return function(){
            console.log(i);
        }
  })(i);
}

data[0]();
data[1]();
data[2]();複製程式碼

當執行到 data[0] 函式之前,此時全域性上下文的 VO 為:

globalContext = {
    VO: {
        data: [...],
        i: 3
    }
}複製程式碼

跟沒改之前一模一樣。

當執行 data[0] 函式的時候,data[0] 函式的作用域鏈發生了改變:

data[0]Context = {
    Scope: [AO, 匿名函式Context.AO globalContext.VO]
}複製程式碼

匿名函式執行上下文的 AO 為:

匿名函式Context = {
    AO: {
        arguments: {
            0: 0,
            length: 1
        },
        i: 0
    }
}複製程式碼

data[0]Context 的 AO 並沒有 i 值,所以會沿著作用域鏈從匿名函式 Context.AO 中查詢,這時候就會找 i 為 0,找到了就不會往 globalContext.VO 中查詢了,即使 globalContext.VO 也有 i 的值(值為3),所以列印的結果就是 0。

data[1] 和 data[2] 是一樣的道理。

下一篇文章

JavaScript深入之引數按值傳遞

相關連結

如果想了解執行上下文的具體變化,不妨循序漸進,閱讀這六篇:

《JavaScript深入之詞法作用域和動態作用域》

《JavaScript深入之執行上下文棧》

《JavaScript深入之變數物件》

《JavaScript深入之作用域鏈》

《JavaScript深入之從ECMAScript規範解讀this》

《JavaScript深入之執行上下文》

深入系列

JavaScript深入系列目錄地址:github.com/mqyqingfeng…

JavaScript深入系列預計寫十五篇左右,旨在幫大家捋順JavaScript底層知識,重點講解如原型、作用域、執行上下文、變數物件、this、閉包、按值傳遞、call、apply、bind、new、繼承等難點概念。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎star,對作者也是一種鼓勵。

相關文章