JavaScript 進階 從實現理解閉包(校對版)

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

關鍵字
Closure 閉包
Lexical Environment 詞法環境
Environment Record 環境記錄
outer Lexical Environment 外部詞法環境
global Lexical Environment 全局語法環境javascript

閉包(Closure)

JavaScript 是一個 function-oriented(直譯:面向函數)的語言,這個特性爲咱們帶來了很大的操做自由。函數只需建立一次,賦值到一個變量,或者做爲參數傳入另外一個函數而後在一個全新的環境調用。前端

函數能夠訪問它外部的變量,是一個經常使用 feature。java

可是當外部變量改變時會發生什麼?函數會獲取最新的值,仍是函數建立當時的值?git

還有一個問題,當函數被傳入其餘地方再調用……他能訪問那個地方的外部變量嗎?github

不一樣語言的表現有所不一樣,下面咱們研究一下 JavaScript 中的表現。面試

兩個問題

咱們先思考下面兩種狀況,看完這篇文章你就能夠回答這兩個問題,更復雜的問題也不在話下。express

  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(變量儲存點),箭頭表明一個外部引用。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 與 let 不一樣,並不是處理於被執行的時候,而是(譯者注:意思就是全局詞法環境建立時處理函數聲明)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,因此要循着 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() 能夠訪問外部變量,幫助咱們很方便地返回組合後的全名。

更有趣的是,嵌套函數能夠做爲一個新對象的屬性或者本身自己被 return。這樣它們就能在其餘地方使用,不管在哪裏,它都能訪問一樣的外部變量。

一個構造函數的例子:

// 構造函數返回一個新對象
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. 直到全局變量。

第 2 步咱們找到了 count。外部變量會直接在其所在的地方被修改。因此 count++ 檢索外部變量,並在該變量本身的 Lexical Environment 進行 +1 操做。就像操做了 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 Environment:全局,和 makeCounter(它的 outer 指向全局)。

  1. makeCounter() 執行的過程當中,建立了一個嵌套函數。

    這無關於函數建立使用的是 Function Declaration(函數聲明)仍是 Function Expression(函數表達式)。全部函數都會獲得一個指向他們建立時 Lexical Environment 的 [[Environment]] 屬性。

    這個嵌套函數的 [[Environment]]makeCounter()(它的誕生地)的 Lexical Environment:

    image

    一樣注意,這一步是函數聲明而非調用。

  2. 代碼繼續執行,makeCounter() 調用結束,內嵌函數被賦值到全局變量 counter

    image

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

  3. counter() 被調用,自動建立一個空的 Lexical Environment。此函數無局部變量,可是 [[Environment]] 引用了外面一層,因此它能夠訪問 makeCounter() 的變量。

    image

    要訪問變量,先檢索本身的 Lexical Environment(空),而後是 makeCounter() 的,最後是全局的。例子中在外層一層 Lexical Environment makeCounter 中發現了 count

    重點來了,內存在這裏是怎麼管理的?儘管 makeCounter() 調用結束了,它的 Lexical Environment 依然保存在內存中,這是由於嵌套函數的 [[Environment]] 引用了它。

    一般,Lexical Environment 對象隨着使用它的函數的存在而存在。沒有函數引用它的時候,它纔會被清除。

  4. counter() 函數不僅是返回 count,還會對其 +1 操做。這個修改已經在「適當的位置」完成了。count 的值在它的當前環境中被修改。

    image

    這一步再次調用 count,原理徹底相同。

    (譯者:總結一下,聲明時記錄環境 [[Environment]](函數所在環境),執行時建立詞法環境(局部+ outer 就是引用 [[Environment]]),而閉包就是函數 + 它的詞法環境,因此定義上來講全部函數都是閉包,可是以後被返回出來可使用的閉包纔是「實用意義」上的閉包

  5. 下一個 counter() 調用操做同上。

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

如下代碼的 work() 函數經過外層 lexical environment 引用了它原地點的 name

image

因此這裏的答案是 "Pete"

可是若是 makeWorker() 沒了 let name ,如咱們所見,做用域搜索會到達外層,獲取全局變量。這個狀況下答案會是 "John"

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

代碼塊、循環、 IIFE

上面的例子都着重於函數,可是 Lexical Environment 也存在於代碼塊 {...}

它們在代碼塊運行時建立,包含塊局部變量。這裏有一些例子。

If

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

image

與函數一樣原理,塊內能夠找到 phrase,可是塊外不能使用塊內的變量和函數。若是執意在 if 外面用 user,那隻能獲得一個報錯了。

For, while

對於循環,每一次迭代都會有本身的 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,或許未來會被修改。至於改沒改,運行一下上面的例子就能判斷啦。
相關文章
相關標籤/搜索