【譯】JavaScript進階 從實現理解閉包

ssshooter發表於2018-06-04

來源於 現代JavaScript教程
閉包章節
中文翻譯計劃
本文很清晰地解釋了閉包是什麼,以及閉包如何產生,相信你看完也會有所收穫

關鍵字 Closure 閉包 Lexical Environment 詞法環境 Environment Record 環境記錄

閉包(Closure)

JavaScript 是一個 function-oriented 的語言。這帶來了很大的操作自由。函式只需建立一次,可以拷貝到另一個變數,或者作為一個引數傳入另一個函式然後在一個全新的環境呼叫。

我們知道函式可以訪問它外部的變數,這個 feature 十分常用。

但是當外部變數改變時會發生什麼?函式時獲取最新的值,還是函式建立當時的值?

還有一個問題,當函式被送到其他地方再呼叫……他能訪問那個地方的外部變數嗎?

不同語言的表現有所不同,下面我們研究一下 JavaScript 中的表現。

兩個問題

我們先思考下面兩種情況,看完這篇文章你就可以回答這兩個問題,更復雜的問題也不在話下。

  1. sayHi 函式使用了外部變數 name。函式執行時,會使用兩個值中的哪個?

    let name = "John";
    
    function sayHi() {
      alert("Hi, " + name);
    }
    
    name = "Pete";
    
    sayHi(); // "John" 還是 "Pete"?
    複製程式碼

    這個情況不論是瀏覽器端還是伺服器端都很常見。函式很可能在它建立一段時間後才執行,例如等待使用者操作或者網路請求。

    問題是:函式是否會選擇變數最新的值呢?

  2. makeWorker 函式創造並返回了另一個函式。這個新函式可以在任何地方呼叫。他會訪問建立時的變數還是呼叫時的變數呢?

    function makeWorker() {
      let name = "Pete";
    
      return function() {
        alert(name);
      };
    }
    
    let name = "John";
    
    // 建立函式
    let work = makeWorker();
    
    // 呼叫函式
    work(); // "Pete" (建立時) 還是 "John" (呼叫時)?
    複製程式碼

Lexical Environment (詞法環境)

要理解裡面發生了什麼,必須先明白“變數”到底是什麼。

在 JavaScript 裡,任何執行的函式、程式碼塊、整個 script 都會關聯一個被叫做 Lexical Environment (詞法環境) 的物件。

Lexical Environment 物件包含兩個部分:(譯者:這裡是重點)

  1. Environment Record (環境記錄)是一個擁有全部區域性變數作為屬性的物件(以及其他如 this 值的資訊)。
  2. *outer lexical environment (外部詞法環境)*的引用,通常詞法關聯外面一層程式碼(花括號外一層)。

所以,“變數”就是內部物件 Environment Record 的一個屬性。要改變一個物件,意味著改變 Lexical Environment 的屬性。

例如在這段簡單的程式碼中,只有一個 Lexical Environment:

lexical environment

這就是所謂 global Lexical Environment (全域性語法環境),對應整個 script。對於瀏覽端,整個 <script> 標籤共享一個全域性環境。

(譯者:這裡是重點) 上圖中,正方形代表 Environment Record (變數儲存),箭頭代表 outer reference (外部引用)。global Lexical Environment 沒有外部引用,所以指向 null

下圖展示 let 變數的工作機制:

lexical environment

右邊的正方形描述 global Lexical Environment 在執行中如何改變:

  1. 指令碼開始執行,Lexical Environment 空。
  2. let phrase 定義出現了。因為沒有賦值所以儲存為 undefined
  3. phrase 被賦值。
  4. phrase 被賦新值。

看起來很簡單對不對?

總結:

  • 變數是一個特殊內部物件的屬性,關聯於執行時的塊、函式、 script 。
  • 對變數的操作實際上是對這個物件屬性的操作。

Function Declaration (函式宣告)

Function Declaration 並非處理於被執行的時候,而是 Lexical Environment 建立的時候。對於 global Lexical Environment ,這意味著 script 開始執行的時候。

這就是函式可以在定義前呼叫的原因。

以下程式碼 Lexical Environment 開始時非空。因為有 say 函式宣告,之後又有了 let 宣告的 phrase

lexical environment

Inner and outer Lexical Environment (內部詞法環境和外部詞法環境)

呼叫 say() 的過程中,它使用了外部變數,一起看看這裡面發生了什麼。

(譯者:這裡是重點) 函式執行時會自動建立一個新的函式 Lexical Environment 。這是所有函式的通用規則。這個新的 Lexical Environment 用於當前執行函式的存放區域性變數和形參。

箭頭標記的是執行 say("John") 時的 Lexical Environment :

lexical environment

函式呼叫過程中,可以看到兩個 Lexical Environment :裡面的是函式呼叫產生的,外面的是全域性的:

  • 內層 Lexical Environment 對應當前執行的 say 。它只有一個變數: 函式實參 name 。我們呼叫 say("John") ,所以 name 的值是 "John"
  • 外層 Lexical Environment 是 global Lexical Environment 。

內層 Lexical Environment 有一個 outer 屬性,指向外層 Lexical Environment。

程式碼要訪問一個變數,首先搜尋內層 Lexical Environment ,接著是外層,再外層,直到鏈的結束。

如果走完整條鏈變數都找不到,在 strict mode 就會報錯了。不使用 use strict 的情況下,對未定義變數的賦值,會創造一個新的全域性變數。

下面一起看看變數搜尋如何處理:

  • say 裡的 alert 想要訪問 name ,立即就能在當前函式的 Lexical Environment 找到。
  • 對於 phrase ,區域性變數不存在 phrase ,所以要循著 outer 在全域性變數裡找到。

lexical environment lookup

現在我們可以回答本章開頭的第一個問題了。

函式獲取外部變數當前值

舊變數值不儲存在任何地方,函式需要他們的時候,它取得來源於自身或外部 Lexical Environment 的當前值。

所以第一個問題的答案是 Pete

let name = "John";

function sayHi() {
 alert("Hi, " + name);
}

name = "Pete"; // (*)

sayHi(); // Pete
複製程式碼

上述程式碼的執行流:

  1. global Lexical Environment 存在 name: "John"
  2. (*) 行中,全域性變數修改了,現在成了這樣 name: "Pete"
  3. say() 執行的時候, 取外部 name 。此時在 global Lexical Environment 中已經是 "Pete"

一次呼叫,一個 Lexical Environment
請注意,每當一個函式執行,就會建立一個新的 function Lexical Environment。
如果一個函式被多次呼叫,那麼每次呼叫都會生成一個屬於當前呼叫的全新 Lexical Environment ,裡面裝載著當前呼叫的變數和實參。

Lexical Environment 是一個標準物件 (specification object)
"Lexical Environment" 是一個標準物件 (specification object)。我們不能直接獲取或設定它,JavaScript 引擎也可能優化它,拋棄未使用的變數來節省記憶體或者作其他優化,但是可見行為應該如上面所述。

巢狀函式

在一個函式中建立另一個函式,稱為“巢狀”。這在 JavaScript 很容易做到:

function sayHiBye(firstName, lastName) {

 // helper nested function to use below
 function getFullName() {
   return firstName + " " + lastName;
 }

 alert( "Hello, " + getFullName() );
 alert( "Bye, " + getFullName() );

}
複製程式碼

巢狀函式 getFullName() 可以訪問外部變數,幫助我們很方便地返回 FullName 。

更有趣的是,巢狀函式可以被 return ,作為一個新物件的屬性或者作為自己的結果。這樣它們就能在其他地方使用,無論在哪裡,它都能訪問同樣的外部變數。

一個建構函式(詳見 info:constructor-new)的例子:

// 建構函式返回一個新物件
function User(name) {

 // 巢狀函式創造物件方法
 this.sayHi = function() {
   alert(name);
 };
}

let user = new User("John");
user.sayHi(); // 方法返回外部 "name"
複製程式碼

一個 return 函式的例子:

function makeCounter() {
 let count = 0;

 return function() {
   return count++; // has access to the outer counter
 };
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
複製程式碼

我們接著研究 makeCounter 。counter 函式每呼叫一次就會返回下一個數。儘管這很簡單,但只要輕微修改,它便具有一定的實用性,例如偽隨機數生成器

counter 內部如何工作?

內部函式執行, count++ 中的變數由內到外搜尋:

image

  1. 巢狀函式區域性變數……
  2. 外層函式……
  3. 直到全域性變數。

第二步我們找到了 count 。當外部變數被修改,它所在的地方就被修改。所以 count++ 檢索外部變數並對其加一是操作於該變數自己的 Lexical Environment 。就像操作了 let count = 1 一樣。

這裡需要思考兩個問題:

  1. 我們能通過 makeCounter 以外的方法重置 counter 嗎?
  2. 如果我們可以多次呼叫 makeCounter() ,返回了很多 counter 函式,他們的 count 是獨立的還是共享的?

繼續閱讀前可以先嚐試思考一下。

...

ok ?

那我們開始揭曉謎底:

  1. 沒門。 counter 是區域性變數,不可能在外部直接訪問。
  2. 每次呼叫 makeCounter() 都會新建 Lexical Environment,每一個環境都有自己的 counter 。所以不同 counter 裡的 count 是獨立的。

一個 demo :

function makeCounter() {
 let count = 0;
 return function() {
   return count++;
 };
}

let counter1 = makeCounter();
let counter2 = makeCounter();

alert( counter1() ); // 0
alert( counter1() ); // 1

alert( counter2() ); // 0 (獨立)
複製程式碼

現在你能清楚外部變數的使用,但是你仍然需要更深入地理解以面對更復雜的情況,現在我們進入下一步。

Environment 細節

對 closure (閉包)有了初步瞭解之後,可以開始深入細節了。

下面是 makeCounter 例子的動作分解,跟著看你就能理解一切了。注意, [[Environment]] 屬性我們之前還未介紹。

  1. 指令碼開始執行,此時只存在 global Lexical Environment :

    image

    這時候只有 makeCounter 一個函式,這是函式宣告,還未被呼叫

    所有函式都帶著一個隱藏屬性 [[Environment]] “誕生”。 [[Environment]] 指向它們建立的 Lexical Environment 。是[[Environment]] 讓函式知道它“誕生”於什麼環境。

    makeCounter 建立於 global Lexical Environment ,所以 [[Environment]] 指向它。

    換句話說,Lexical Environment 在函式誕生時就“銘刻”在這個函式中。[[Environment]] 是指向 Lexical Environment 的隱藏函式屬性。

  2. 程式碼繼續走, makeCounter() 登上舞臺。這是程式碼執行到 makeCounter() 瞬間的快照:

    image

    makeCounter() 呼叫時,儲存當前變數和實參的 Lexical Environment 已經被建立。

    Lexical Environment 儲存 2 個東西:

    1. 帶有區域性變數的 Environment Record 。例子中 count 是唯一的區域性變數( let count 被執行的時候記錄)。
    2. 被繫結到函式 [[Environment]] 的外部詞法引用。例子裡 makeCounter[[Environment]] 引用了 global Lexical Environment 。

    所以這裡有兩個 Lexical Environments :全域性,和 makeCounter (outer 引用全域性)。

  3. makeCounter() 執行的過程中,建立了一個巢狀函式。

    這無關於函式建立使用的是 Function Declaration (函式宣告)還是 Function Expression (函式表示式)。所有函式都會得到引用他們被建立時 Lexical Environment 的 [[Environment]] 屬性。

    這個巢狀函式的 [[Environment]]makeCounter() (它的誕生地)的 Lexical Environment:

    image

    同樣注意,這一步是函式宣告而非呼叫。

  4. 程式碼繼續執行,makeCounter() 呼叫結束,內嵌函式被賦值到全域性變數 counter

    image

    這個函式只有一行: return count++

  5. counter() 被呼叫,自動建立一個 “空” Lexical Environment 。 此函式無區域性變數,但是 [[Environment]] 引用了外面一層,所以它可以訪問 makeCounter() 的變數。

    image

    要訪問變數,先檢索自己的 Lexical Environment (empty),然後是 makeCounter() 的,最後是全域性的。例子中在最近的外層 Lexical Environment makeCounter 中發現了 count

    重點來了,記憶體在這裡是怎麼管理的?儘管 makeCounter() 呼叫結束了,它的 Lexical Environment 依然儲存在記憶體中,這是因為巢狀函式的 [[Environment]] 引用了它。

    通常, Lexical Environment 物件隨著使用它的函式的存在而存在。沒有函式引用它的時候,它才會被清除。

  6. counter() 函式不只是返回 count ,還會對其 +1 操作。這個修改已經在“適當的位置”完成了。count 的值在它被找到的環境中被修改。

    image

    這一步出了返回了新的 count ,其他完全相同。

    (譯者:總結一下,宣告時記錄環境 [[Environment]](函式所在環境),執行時建立詞法環境(區域性+outer 就是引用 [[Environment]] ),而閉包就是函式 + 它的詞法環境,所以定義上來說所有函式都是閉包,但是之後被返回出來可以使用的閉包才是“實用意義”上的閉包)

  7. 下一個 counter() 呼叫操作同上。

本章開頭第二個問題的答案現在顯而易見了。

以下程式碼的 work() 函式通過外層 lexical environment 引用了它原地點的 name

image

所以這裡的答案是 "Pete"

但是如果 makeWorker() 沒了 let name ,如我們所見,作用域搜尋會到達外層,獲取全域性變數。這個情況下答案會是 "John"

閉包 (Closure)
開發者們都應該知道程式設計領域的通用名詞閉包 (closure)。
Closure 是一個記錄並可訪問外層變數的函式。在一些程式語言中,這是不可能的,或者要以一種特殊的方式書寫以實現這個功能。但是如上面解釋的, JavaScript 的所有函式都(很自然地)是個閉包。(有一個例外,詳見info:new-function
這就是閉包:它們使用 [[Environment]] 屬性自動記錄各自的建立地點,然後由此訪問外部變數。
在前端面試中,如果面試官問你什麼是閉包,正確答案應該包括閉包的定義,以及解釋為何 JavaScript 的所有函式都是閉包,最好可以再簡單說說裡面的技術細節: [[Environment]] 屬性和 Lexical Environments 的原理。

程式碼塊、迴圈、 IIFE

上面的例子都著重於函式,但是 Lexical Environment 也存在於程式碼塊 {...}

它們在程式碼塊執行時建立,包含塊區域性變數。這裡有一些例子。

If

下例中,當執行到 if 塊,會為這個塊建立新的 "if-only" Lexical Environment :

image

與函式同樣原理,塊內可以找到 phrase ,但是塊外不能使用塊內的變數和函式。如果執意在 if 外面用 user ,那隻能得到一個報錯了。

For, while

對於迴圈,每個 iteration 都會有自己的 Lexical Environment ,在 for 裡定義的變數,也是塊的區域性變數,也屬於塊的 Lexical Environment :

for (let i = 0; i < 10; i++) {
 // Each loop has its own Lexical Environment
 // {i: value}
}

alert(i); // Error, no such variable
複製程式碼

let i 只在塊內可用,每次迴圈都有它自己的 Lexical Environment ,每次迴圈都會帶著當前的 i ,最後迴圈結束, i 不可用。

程式碼塊

我們也可以直接用 {…} 把變數隔離到一個“區域性作用域”(local scope)。

在瀏覽器中所有 script 共享全域性變數,這就很容易造成變數的重名、覆蓋。

為了避免這種情況我們可以使用程式碼塊隔離自己的程式碼:

{
 // do some job with local variables that should not be seen outside

 let message = "Hello";

 alert(message); // Hello
}

alert(message); // Error: message is not defined
複製程式碼

程式碼塊有自己的 Lexical Environment ,塊外無法訪問塊內變數。

IIFE

以前沒有程式碼塊,要實現上述效果要依靠所謂的“立即執行函式表示式”(immediately-invoked function expressions ,縮寫 IIFE):

(function() {

 let message = "Hello";

 alert(message); // Hello

})();
複製程式碼

這個函式表示式建立後立即執行,這段程式碼立即執行並有自己的私有變數。

函式表示式需要被括號包裹。 JavaScript 執行時遇到 "function" 會理解為一個函式宣告,函式宣告必須有名稱,沒有就會報錯:

// Error: Unexpected token (
function() { // <-- JavaScript cannot find function name, meets ( and gives error

 let message = "Hello";

 alert(message); // Hello

}();
複製程式碼

你可能會說:“那我給他加個名字咯”,但這依然行不通,JavaScript 不允許函式宣告立刻被執行:

// syntax error because of brackets below
function go() {

}(); // <-- can't call Function Declaration immediately
複製程式碼

圓括號告訴 JavaScript 這個函式建立於其他表示式的上下文,因此這是個函式表示式。不需要名稱,也可以立即執行。

也有其他方法告訴 JavaScript 我們需要的是函式表示式:

// 建立 IIFE 的方法

(function() {
 alert("Brackets around the function");
})();

(function() {
 alert("Brackets around the whole thing");
}());

!function() {
 alert("Bitwise NOT operator starts the expression");
}();

+function() {
 alert("Unary plus starts the expression");
}();
複製程式碼

垃圾回收

Lexical Environment 物件與普通的值的記憶體管理規則是一樣的。

  • 通常 Lexical Environment 在函式執行完畢就會被清理:

    function f() {
      let value1 = 123;
      let value2 = 456;
    }
    
    f();
    複製程式碼

    這兩個值是 Lexical Environment 的屬性,但是 f() 執行完後,這個 Lexical Environment 無任何變數引用(unreachable),所以它會從記憶體刪除。

  • ...但是如果有內嵌函式,它的 [[Environment]] 會引用 f 的 Lexical Environment(reachable):

    function f() {
      let value = 123;
    
      function g() { alert(value); }
    
      return g;
    }
    
    let g = f(); // g is reachable, and keeps the outer lexical environment in memory
    複製程式碼
  • 注意, f() 如果被多次呼叫,返回的函式都被儲存,相應的 Lexical Environment 會分別儲存在記憶體:

    function f() {
      let value = Math.random();
    
      return function() { alert(value); };
    }
    
    // 3 functions in array, every one of them links to Lexical Environment
    // from the corresponding f() run
    //         LE   LE   LE
    let arr = [f(), f(), f()];
    複製程式碼
  • Lexical Environment 物件在不被引用 (unreachable) 後被清除: 無巢狀函式引用它。下例中, g 自身不被引用後, value 也會被清除:

    function f() {
      let value = 123;
    
      function g() { alert(value); }
    
      return g;
    }
    
    let g = f(); // while g is alive
    // there corresponding Lexical Environment lives
    
    g = null; // ...and now the memory is cleaned up
    複製程式碼

現實中的優化

理論上,函式還在,它的所有外部變數都會被保留。

但在實踐中,JavaScript 引擎可能會對此作出優化,引擎在分析變數的使用情況後,把沒有使用的外部變數刪除。

在 V8 (Chrome, Opera) 有個問題,這些被刪除的變數不能在 debugger 觀察了。

嘗試在 Chrome Developer Tools 執行以下程式碼:

function f() {
 let value = Math.random();

 function g() {
   debugger; // 在 console 輸入 alert( value ); 發現無此變數!
 }

 return g;
}

let g = f();
g();
複製程式碼

你可以看到,這裡沒有儲存 value 變數!理論上它應該是可訪問的,但是引擎優化移除了這個變數。

還有一個有趣的 debug 問題。下面的程式碼 alert 出外面的同名變數而不是裡面的:

let value = "Surprise!";

function f() {
 let value = "the closest value";

 function g() {
   debugger; // in console: type alert( value ); Surprise!
 }

 return g;
}

let g = f();
g();
複製程式碼

再會!
如果你用 Chrome/Opera 來debug ,很快就能發現這個 V8 feature。
這不是 bug 而是 V8 feature,或許將來會被修改。至於改沒改,執行一下上面的例子就能判斷啦。

相關文章