JavaScript 閉包的底層執行機制

leapoahead發表於2016-09-24

我研究JavaScript閉包(closure)已經有一段時間了。我之前只是學會了如何使用它們,而沒有透徹地瞭解它們具體是如何運作的。那麼,究竟什麼是閉包?

Wikipedia給出的解釋並沒有太大的幫助。閉包是什麼時候被建立的,什麼時候被銷燬的?具體的實現又是怎麼樣的?

"use strict";

var myClosure = (function outerFunction() {

  var hidden = 1;

  return {
    inc: function innerFunction() {
      return hidden++;
    }
  };

}());

myClosure.inc();  // 返回 1
myClosure.inc();  // 返回 2
myClosure.inc();  // 返回 3

// 相信對JS熟悉的朋友都能很快理解這段程式碼
// 那麼在這段程式碼執行的背後究竟發生了怎樣的事情呢?

現在,我終於知道了答案,我感到很興奮並且決定向大家解釋這個答案。至少,我一定是不會忘記這個答案的。

Tell me and I forget. Teach me and I remember. Involve me and I learn.
© Benjamin Franklin

並且,在我閱讀與閉包相關的現存的資料時,我很努力地嘗試著去在腦海中想想每個事物之間的聯絡:物件之間是如何引用的,物件之間的繼承關係是什麼,等等。我找不到關於這些負責關係的很好的圖表,於是我決定自己畫一些。

我將假設讀者對JavaScript已經比較熟悉了,知道什麼是全域性物件,知道函式在JavaScript當中是“first-class objects”,等等。

作用域鏈(Scope Chain)

當JavaScript在執行的時候,它需要一些空間讓它來儲存本地變數(local variables)。我們將這些空間稱為作用域物件(Scope object),有時候也稱作LexicalEnvironment。例如,當你呼叫函式時,函式定義了一些本地變數,這些變數就被儲存在一個作用域物件中。你可以將作用域函式想象成一個普通的JavaScript物件,但是有一個很大的區別就是你不能夠直接在JavaScript當中直接獲取這個物件。你只可以修改這個物件的屬性,但是你不能夠獲取這個物件的引用。

作用域物件的概念使得JavaScript和C、C++非常不同。在C、C++中,本地變數被儲存在棧(stack)中。在JavaScript中,作用域物件是在堆中被建立的(至少表現出來的行為是這樣的),所以在函式返回後它們也還是能夠被訪問到而不被銷燬。

正如你做想的,作用域物件是可以有父作用域物件(parent scope object)的。當程式碼試圖訪問一個變數的時候,直譯器將在當前的作用域物件中查詢這個屬性。如果這個屬性不存在,那麼直譯器就會在父作用域物件中查詢這個屬性。就這樣,一直向父作用域物件查詢,直到找到該屬性或者再也沒有父作用域物件。我們將這個查詢變數的過程中所經過的作用域物件乘坐作用域鏈(Scope chain)。

在作用域鏈中查詢變數的過程和原型繼承(prototypal inheritance)有著非常相似之處。但是,非常不一樣的地方在於,當你在原型鏈(prototype chain)中找不到一個屬性的時候,並不會引發一個錯誤,而是會得到undefined。但是如果你試圖訪問一個作用域鏈中不存在的屬性的話,你就會得到一個ReferenceError

在作用域鏈的最頂層的元素就是全域性物件(Global Object)了。執行在全域性環境的JavaScript程式碼中,作用域鏈始終只含有一個元素,那就是全域性物件。所以,當你在全域性環境中定義變數的時候,它們就會被定義到全域性物件中。當函式被呼叫的時候,作用域鏈就會包含多個作用域物件。

全域性環境中執行的程式碼

好了,理論就說到這裡。接下來我們來從實際的程式碼入手。

// my_script.js
"use strict";

var foo = 1;
var bar = 2;

我們在全域性環境中建立了兩個變數。正如我剛才所說,此時的作用域物件就是全域性物件。

在全域性環境中建立兩個變數

在上面的程式碼中,我們有一個執行的上下文(myscript.js自身的程式碼),以及它所引用的作用域物件。全域性物件裡面還含有很多不同的屬性,在這裡我們就忽略掉了。

沒有被巢狀的函式(Non-nested functions)

接下來,我們看這段程式碼

"use strict";
var foo = 1;
var bar = 2;

function myFunc() {
  //-- define local-to-function variables
  var a = 1;
  var b = 2;
  var foo = 3;

  console.log("inside myFunc");
}

console.log("outside");

//-- and then, call it:
myFunc();

myFunc被定義的時候,myFunc的識別符號(identifier)就被加到了當前的作用域物件中(在這裡就是全域性物件),並且這個識別符號所引用的是一個函式物件(function object)。函式物件中所包含的是函式的原始碼以及其他的屬性。其中一個我們所關心的屬性就是內部屬性[[scope]][[scope]]所指向的就是當前的作用域物件。也就是指的就是函式的識別符號被建立的時候,我們所能夠直接訪問的那個作用域物件(在這裡就是全域性物件)。

“直接訪問”的意思就是,在當前作用域鏈中,該作用域物件處於最底層,沒有子作用域物件。

所以,在console.log("outside")被執行之前,物件之間的關係是如下圖所示。

物件之間的關係

溫習一下。myFunc所引用的函式物件其本身不僅僅含有函式的程式碼,並且還含有指向其被建立的時候的作用域物件。這一點非常重要!

myFunc函式被呼叫的時候,一個新的作用域物件被建立了。新的作用域物件中包含myFunc函式所定義的本地變數,以及其引數(arguments)。這個新的作用域物件的父作用域物件就是在執行myFunc時我們所能直接訪問的那個作用域物件。

所以,當myFunc被執行的時候,物件之間的關係如下圖所示。

物件之間的關係(函式執行後)

現在我們就擁有了一個作用域鏈。當我們試圖在myFunc當中訪問某些變數的時候,JavaScript會先在其能直接訪問的作用域物件(這裡就是myFunc() scope)當中查詢這個屬性。如果找不到,那麼就在它的父作用域物件當中查詢(在這裡就是Global Object)。如果一直往上找,找到沒有父作用域物件為止還沒有找到的話,那麼就會丟擲一個ReferenceError

例如,如果我們在myFunc中要訪問a這個變數,那麼在myFunc scope當中就可以找到它,得到值為1

如果我們嘗試訪問foo,我們就會在myFunc() scope中得到3。只有在myFunc() scope裡面找不到foo的時候,JavaScript才會往Global Object去查詢。所以,這裡我們不會訪問到Global Object裡面的foo

如果我們嘗試訪問bar,我們在myFunc() scope當中找不到它,於是就會在Global Object當中查詢,因此查詢到2。

很重要的是,只要這些作用域物件依然被引用,它們就不會被垃圾回收器(garbage collector)銷燬,我們就一直能訪問它們。當然,當引用一個作用域物件的最後一個引用被解除的時候,並不代表垃圾回收器會立刻回收它,只是它現在可以被回收了

所以,當myFunc()返回的時候,再也沒有人引用myFunc() scope了。當垃圾回收結束後,物件之間的關係變成回了呼叫前的關係。

物件之間的關係恢復

接下來,為了圖表直觀起見,我將不再將函式物件畫出來。但是,請永遠記著,函式物件裡面的[[scope]]屬性,儲存著該函式被定義的時候所能夠直接訪問的作用域物件。

巢狀的函式(Nested functions)

正如前面所說,當一個函式返回後,沒有其他物件會儲存對其的引用。所以,它就可能被垃圾回收器回收。但是如果我們在函式當中定義巢狀的函式並且返回,被呼叫函式的一方所儲存呢?(如下面的程式碼)

function myFunc() {
  return innerFunc() {
    // ...
  }
}

var innerFunc = myFunc();

你已經知道的是,函式物件中總是有一個[[scope]]屬性,儲存著該函式被定義的時候所能夠直接訪問的作用域物件。所以,當我們在定義巢狀的函式的時候,這個巢狀的函式的[[scope]]就會引用外圍函式(Outer function)的當前作用域物件。

如果我們將這個巢狀函式返回,並被另外一個地方的識別符號所引用的話,那麼這個巢狀函式及其[[scope]]所引用的作用域物件就不會被垃圾回收所銷燬。

"use strict";

function createCounter(initial) {
  var counter = initial;

  function increment(value) {
    counter += value;
  }

  function get() {
    return counter;
  }

  return {
    increment: increment,
    get: get
  };
}

var myCounter = createCounter(100);

console.log(myCounter.get());   // 返回 100
myCounter.increment(5);
console.log(myCounter.get());   // 返回 105

當我們呼叫createCounter(100)的那一瞬間,物件之間的關係如下圖

呼叫createCounter(100)時物件間的關係

注意incrementget函式都存有指向createCounter(100) scope的引用。如果createCounter(100)沒有任何返回值,那麼createCounter(100) scope不再被引用,於是就可以被垃圾回收。但是因為createCounter(100)實際上是有返回值的,並且返回值被儲存在了myCounter中,所以物件之間的引用關係變成了如下圖所示

呼叫createCounter(100)後物件間的關係

所以,createCounter(100)雖然已經返回了,但是它的作用域物件依然存在,可以且僅只能被巢狀的函式(incrementget)所訪問。

讓我們試著執行myCounter.get()。剛才說過,函式被呼叫的時候會建立一個新的作用域物件,並且該作用域物件的父作用域物件會是當前可以直接訪問的作用域物件。所以,當myCounter.get()被呼叫時的一瞬間,物件之間的關係如下。

呼叫myCounter.get()物件間的關係

myCounter.get()執行的過程中,作用域鏈最底層的物件就是get() scope,這是一個空物件。所以,當myCounter.get()訪問counter變數時,JavaScript在get() scope中找不到這個屬性,於是就向上到createCounter(100) scope當中查詢。然後,myCounter.get()將這個值返回。

呼叫myCounter.increment(5)的時候,事情變得更有趣了,因為這個時候函式呼叫的時候傳入了引數。

呼叫myCounter.increment(5)物件間的關係

正如你所見,increment(5)的呼叫建立了一個新的作用域物件,並且其中含有傳入的引數value。當這個函式嘗試訪問value的時候,JavaScript立刻就能在當前的作用域物件找到它。然而,這個函式試圖訪問counter的時候,JavaScript無法在當前的作用域物件找到它,於是就會在其父作用域createCounter(100) scope中查詢。

我們可以注意到,在createCounter函式之外,除了被返回的getincrement兩個方法,沒有其他的地方可以訪問到value這個變數了。這就是用閉包實現“私有變數”的方法

我們注意到initial變數也被儲存在createCounter()所建立的作用域物件中,儘管它沒有被用到。所以,我們實際上可以去掉var counter = initial;,將initial改名為counter。但是為了程式碼的可讀性起見,我們保留原有的程式碼不做變化。

需要注意的是作用域鏈是不會被複制的。每次函式呼叫只會往作用域鏈下面新增一個作用域物件。所以,如果在函式呼叫的過程當中對作用域鏈中的任何一個作用域物件的變數進行修改的話,那麼同時作用域鏈中也擁有該作用域物件的函式物件也是能夠訪問到這個變化後的變數的。

這也就是為什麼下面這個大家都很熟悉的例子會不能產出我們想要的結果。

"use strict";

var elems = document.getElementsByClassName("myClass"), i;

for (i = 0; i < elems.length; i++) {
  elems[i].addEventListener("click", function () {
    this.innerHTML = i;
  });
}

在上面的迴圈中建立了多個函式物件,所有的函式物件的[[scope]]都儲存著對當前作用域物件的引用。而變數i正好就在當前作用域鏈中,所以迴圈每次對i的修改,對於每個函式物件都是能夠看到的。

“看起來一樣的”函式,不一樣的作用域物件

現在我們來看一個更有趣的例子。

"use strict";

function createCounter(initial) {
  // ...
}

var myCounter1 = createCounter(100);
var myCounter2 = createCounter(200);

myCounter1myCounter2被建立後,物件之間的關係為

myCounter1和myCounter2被建立後,物件之間的關係

在上面的例子中,myCounter1.incrementmyCounter2.increment的函式物件擁有著一樣的程式碼以及一樣的屬性值(namelength等等),但是它們的[[scope]]指向的是不一樣的作用域物件

這才有了下面的結果

var a, b;
a = myCounter1.get();   // a 等於 100
b = myCounter2.get();   // b 等於 200

myCounter1.increment(1);
myCounter1.increment(2);

myCounter2.increment(5);

a = myCounter1.get();   // a 等於 103
b = myCounter2.get();   // b 等於 205

作用域鏈和this

this的值不會被儲存在作用域鏈中,this的值取決於函式被呼叫的時候的情景。

譯者注:對這部分,譯者自己曾經寫過一篇更加詳盡的文章,請參考《用自然語言的角度理解JavaScript中的this關鍵字》。原文的這一部分以及“this在巢狀的函式中的使用”譯者便不再翻譯。

總結

讓我們來回想我們在本文開頭提到的一些問題。

  • 什麼是閉包?閉包就是同時含有對函式物件以及作用域物件引用的最想。實際上,所有JavaScript物件都是閉包。
  • 閉包是什麼時候被建立的?因為所有JavaScript物件都是閉包,因此,當你定義一個函式的時候,你就定義了一個閉包。
  • 閉包是什麼時候被銷燬的?當它不被任何其他的物件引用的時候。

專有名詞翻譯表

本文采用下面的專有名詞翻譯表,如有更好的翻譯請告知,尤其是加*的翻譯

  • *全域性環境中執行的程式碼:top-level code
  • 引數:arguments
  • 作用域物件:Scope object
  • 作用域鏈:Scope Chain
  • 棧:stack
  • 原型繼承:prototypal inheritance
  • 原型鏈:prototype chain
  • 全域性物件:Global Object
  • 識別符號:identifier
  • 垃圾回收器:garbage collector

相關文章