【譯】JavaScript進階 從實現(xiàn)理解閉包

來源于 現(xiàn)代JavaScript教程
閉包章節(jié)
中文翻譯計劃
本文很清晰地解釋了閉包是什么,以及閉包如何產(chǎn)生,相信你看完也會有所收獲

關(guān)鍵字
Closure 閉包
Lexical Environment 詞法環(huán)境
Environment Record 環(huán)境記錄

閉包(Closure)

JavaScript 是一個 function-oriented 的語言。這帶來了很大的操作自由。函數(shù)只需創(chuàng)建一次,可以拷貝到另一個變量,或者作為一個參數(shù)傳入另一個函數(shù)然后在一個全新的環(huán)境調(diào)用。

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

但是當外部變量改變時會發(fā)生什么?函數(shù)時獲取最新的值,還是函數(shù)創(chuàng)建當時的值?

還有一個問題,當函數(shù)被送到其他地方再調(diào)用……他能訪問那個地方的外部變量嗎?

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

兩個問題

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

  1. sayHi 函數(shù)使用了外部變量 name。函數(shù)運行時,會使用兩個值中的哪個?

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

    這個情況不論是瀏覽器端還是服務器端都很常見。函數(shù)很可能在它創(chuàng)建一段時間后才執(zhí)行,例如等待用戶操作或者網(wǎng)絡請求。

    問題是:函數(shù)是否會選擇變量最新的值呢?

  1. makeWorker 函數(shù)創(chuàng)造并返回了另一個函數(shù)。這個新函數(shù)可以在任何地方調(diào)用。他會訪問創(chuàng)建時的變量還是調(diào)用時的變量呢?

    function makeWorker() {
      let name = "Pete";
    
      return function() {
        alert(name);
      };
    }
    
    let name = "John";
    
    // 創(chuàng)建函數(shù)
    let work = makeWorker();
    
    // 調(diào)用函數(shù)
    work(); // "Pete" (創(chuàng)建時) 還是 "John" (調(diào)用時)?
    

Lexical Environment (詞法環(huán)境)

要理解里面發(fā)生了什么,必須先明白“變量”到底是什么。

在 JavaScript 里,任何運行的函數(shù)、代碼塊、整個 script 都會關(guān)聯(lián)一個被叫做 Lexical Environment (詞法環(huán)境) 的對象。

Lexical Environment 對象包含兩個部分:(譯者:這里是重點)

  1. Environment Record (環(huán)境記錄)是一個擁有全部局部變量作為屬性的對象(以及其他如 this 值的信息)。
  2. outer lexical environment (外部詞法環(huán)境)的引用,通常詞法關(guān)聯(lián)外面一層代碼(花括號外一層)。

所以,“變量”就是內(nèi)部對象 Environment Record 的一個屬性。要改變一個對象,意味著改變 Lexical Environment 的屬性。

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

lexical environment

這就是所謂 global Lexical Environment (全局語法環(huán)境),對應整個 script。對于瀏覽端,整個 <script> 標簽共享一個全局環(huán)境。

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

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

lexical environment

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

  1. 腳本開始運行,Lexical Environment 空。
  2. let phrase 定義出現(xiàn)了。因為沒有賦值所以儲存為 undefined 。
  3. phrase 被賦值。
  4. phrase 被賦新值。

看起來很簡單對不對?

總結(jié):

  • 變量是一個特殊內(nèi)部對象的屬性,關(guān)聯(lián)于執(zhí)行時的塊、函數(shù)、 script 。
  • 對變量的操作實際上是對這個對象屬性的操作。

Function Declaration (函數(shù)聲明)

Function Declaration 并非處理于被執(zhí)行的時候,而是 Lexical Environment 創(chuàng)建的時候。對于 global Lexical Environment ,這意味著 script 開始運行的時候。

這就是函數(shù)可以在定義前調(diào)用的原因。

以下代碼 Lexical Environment 開始時非空。因為有 say 函數(shù)聲明,之后又有了 let 聲明的 phrase

lexical environment

Inner and outer Lexical Environment (內(nèi)部詞法環(huán)境和外部詞法環(huán)境)

調(diào)用 say() 的過程中,它使用了外部變量,一起看看這里面發(fā)生了什么。

(譯者:這里是重點)
函數(shù)運行時會自動創(chuàng)建一個新的函數(shù) Lexical Environment 。這是所有函數(shù)的通用規(guī)則。這個新的 Lexical Environment 用于當前運行函數(shù)的存放局部變量和形參。

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

lexical environment

函數(shù)調(diào)用過程中,可以看到兩個 Lexical Environment :里面的是函數(shù)調(diào)用產(chǎn)生的,外面的是全局的:

  • 內(nèi)層 Lexical Environment 對應當前執(zhí)行的 say 。它只有一個變量: 函數(shù)實參 name 。我們調(diào)用 say("John") ,所以 name 的值是 "John" 。
  • 外層 Lexical Environment 是 global Lexical Environment 。

內(nèi)層 Lexical Environment 有一個 outer 屬性,指向外層 Lexical Environment。

代碼要訪問一個變量,首先搜索內(nèi)層 Lexical Environment ,接著是外層,再外層,直到鏈的結(jié)束。

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

下面一起看看變量搜索如何處理:

  • say 里的 alert 想要訪問 name ,立即就能在當前函數(shù)的 Lexical Environment 找到。
  • 對于 phrase ,局部變量不存在 phrase ,所以要循著 outer 在全局變量里找到。
lexical environment lookup

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

函數(shù)獲取外部變量當前值

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

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

let name = "John";

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

name = "Pete"; // (*)

sayHi(); // Pete

上述代碼的執(zhí)行流:

  1. global Lexical Environment 存在 name: "John" 。
  2. (*) 行中,全局變量修改了,現(xiàn)在成了這樣 name: "Pete" 。
  3. say() 執(zhí)行的時候, 取外部 name 。此時在 global Lexical Environment 中已經(jīng)是 "Pete"。

一次調(diào)用,一個 Lexical Environment
請注意,每當一個函數(shù)運行,就會創(chuàng)建一個新的 function Lexical Environment。
如果一個函數(shù)被多次調(diào)用,那么每次調(diào)用都會生成一個屬于當前調(diào)用的全新 Lexical Environment ,里面裝載著當前調(diào)用的變量和實參。

Lexical Environment 是一個標準對象 (specification object)
"Lexical Environment" 是一個標準對象 (specification object)。我們不能直接獲取或設置它,JavaScript 引擎也可能優(yōu)化它,拋棄未使用的變量來節(jié)省內(nèi)存或者作其他優(yōu)化,但是可見行為應該如上面所述。

嵌套函數(shù)

在一個函數(shù)中創(chuàng)建另一個函數(shù),稱為“嵌套”。這在 JavaScript 很容易做到:

function sayHiBye(firstName, lastName) {

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

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

}

嵌套函數(shù) getFullName() 可以訪問外部變量,幫助我們很方便地返回 FullName 。

更有趣的是,嵌套函數(shù)可以被 return ,作為一個新對象的屬性或者作為自己的結(jié)果。這樣它們就能在其他地方使用,無論在哪里,它都能訪問同樣的外部變量。

一個構(gòu)造函數(shù)(詳見 info:constructor-new)的例子:

// 構(gòu)造函數(shù)返回一個新對象
function User(name) {

 // 嵌套函數(shù)創(chuàng)造對象方法
 this.sayHi = function() {
   alert(name);
 };
}

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

一個 return 函數(shù)的例子:

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 函數(shù)每調(diào)用一次就會返回下一個數(shù)。盡管這很簡單,但只要輕微修改,它便具有一定的實用性,例如偽隨機數(shù)生成器

counter 內(nèi)部如何工作?

內(nèi)部函數(shù)運行, count++ 中的變量由內(nèi)到外搜索:

image
  1. 嵌套函數(shù)局部變量……
  2. 外層函數(shù)……
  3. 直到全局變量。

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

這里需要思考兩個問題:

  1. 我們能通過 makeCounter 以外的方法重置 counter 嗎?
  2. 如果我們可以多次調(diào)用 makeCounter() ,返回了很多 counter 函數(shù),他們的 count 是獨立的還是共享的?

繼續(xù)閱讀前可以先嘗試思考一下。

...

ok ?

那我們開始揭曉謎底:

  1. 沒門。 counter 是局部變量,不可能在外部直接訪問。
  2. 每次調(diào)用 makeCounter() 都會新建 Lexical Environment,每一個環(huán)境都有自己的 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 (獨立)

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

Environment 細節(jié)

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

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

  1. 腳本開始運行,此時只存在 global Lexical Environment :

    image

    這時候只有 makeCounter 一個函數(shù),這是函數(shù)聲明,還未被調(diào)用

    所有函數(shù)都帶著一個隱藏屬性 [[Environment]] “誕生”。 [[Environment]] 指向它們創(chuàng)建的 Lexical Environment 。是[[Environment]] 讓函數(shù)知道它“誕生”于什么環(huán)境。

    makeCounter 創(chuàng)建于 global Lexical Environment ,所以 [[Environment]] 指向它。

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

  2. 代碼繼續(xù)走, makeCounter() 登上舞臺。這是代碼運行到 makeCounter() 瞬間的快照:

    image

    makeCounter() 調(diào)用時,保存當前變量和實參的 Lexical Environment 已經(jīng)被創(chuàng)建。

    Lexical Environment 儲存 2 個東西:

    1. 帶有局部變量的 Environment Record 。例子中 count 是唯一的局部變量( let count 被執(zhí)行的時候記錄)。
    2. 被綁定到函數(shù) [[Environment]] 的外部詞法引用。例子里 makeCounter[[Environment]] 引用了 global Lexical Environment 。

    所以這里有兩個 Lexical Environments :全局,和 makeCounter (outer 引用全局)。

  3. makeCounter() 執(zhí)行的過程中,創(chuàng)建了一個嵌套函數(shù)。

    這無關(guān)于函數(shù)創(chuàng)建使用的是 Function Declaration (函數(shù)聲明)還是 Function Expression (函數(shù)表達式)。所有函數(shù)都會得到引用他們被創(chuàng)建時 Lexical Environment 的 [[Environment]] 屬性。

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

    image

    同樣注意,這一步是函數(shù)聲明而非調(diào)用。

  4. 代碼繼續(xù)執(zhí)行,makeCounter() 調(diào)用結(jié)束,內(nèi)嵌函數(shù)被賦值到全局變量 counter

    image

    這個函數(shù)只有一行: return count++

  5. counter() 被調(diào)用,自動創(chuàng)建一個 “空” Lexical Environment 。 此函數(shù)無局部變量,但是 [[Environment]] 引用了外面一層,所以它可以訪問 makeCounter() 的變量。

    image

    要訪問變量,先檢索自己的 Lexical Environment (empty),然后是 makeCounter() 的,最后是全局的。例子中在最近的外層 Lexical Environment makeCounter 中發(fā)現(xiàn)了 count 。

    重點來了,內(nèi)存在這里是怎么管理的?盡管 makeCounter() 調(diào)用結(jié)束了,它的 Lexical Environment 依然保存在內(nèi)存中,這是因為嵌套函數(shù)的 [[Environment]] 引用了它。

    通常, Lexical Environment 對象隨著使用它的函數(shù)的存在而存在。沒有函數(shù)引用它的時候,它才會被清除。

  6. counter() 函數(shù)不只是返回 count ,還會對其 +1 操作。這個修改已經(jīng)在“適當?shù)奈恢谩蓖瓿闪恕?code>count 的值在它被找到的環(huán)境中被修改。

    image

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

    (譯者:總結(jié)一下,聲明時記錄環(huán)境 [[Environment]](函數(shù)所在環(huán)境),執(zhí)行時創(chuàng)建詞法環(huán)境(局部+outer 就是引用 [[Environment]] ),而閉包就是函數(shù) + 它的詞法環(huán)境,所以定義上來說所有函數(shù)都是閉包,但是之后被返回出來可以使用的閉包才是“實用意義”上的閉包)

  7. 下一個 counter() 調(diào)用操作同上。

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

以下代碼的 work() 函數(shù)通過外層 lexical environment 引用了它原地點的 name

image

所以這里的答案是 "Pete" 。

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

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

代碼塊、循環(huán)、 IIFE

上面的例子都著重于函數(shù),但是 Lexical Environment 也存在于代碼塊 {...}

它們在代碼塊運行時創(chuàng)建,包含塊局部變量。這里有一些例子。

If

下例中,當執(zhí)行到 if 塊,會為這個塊創(chuàng)建新的 "if-only" Lexical Environment :

image

與函數(shù)同樣原理,塊內(nèi)可以找到 phrase ,但是塊外不能使用塊內(nèi)的變量和函數(shù)。如果執(zhí)意在 if 外面用 user ,那只能得到一個報錯了。

For, while

對于循環(huán),每個 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 只在塊內(nèi)可用,每次循環(huán)都有它自己的 Lexical Environment ,每次循環(huán)都會帶著當前的 i ,最后循環(huán)結(jié)束, 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 ,塊外無法訪問塊內(nèi)變量。

IIFE

以前沒有代碼塊,要實現(xiàn)上述效果要依靠所謂的“立即執(zhí)行函數(shù)表達式”(immediately-invoked function expressions ,縮寫 IIFE):

(function() {

 let message = "Hello";

 alert(message); // Hello

})();

這個函數(shù)表達式創(chuàng)建后立即執(zhí)行,這段代碼立即執(zhí)行并有自己的私有變量。

函數(shù)表達式需要被括號包裹。 JavaScript 執(zhí)行時遇到 "function" 會理解為一個函數(shù)聲明,函數(shù)聲明必須有名稱,沒有就會報錯:

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

 let message = "Hello";

 alert(message); // Hello

}();

你可能會說:“那我給他加個名字咯”,但這依然行不通,JavaScript 不允許函數(shù)聲明立刻被執(zhí)行:

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

}(); // <-- can't call Function Declaration immediately

圓括號告訴 JavaScript 這個函數(shù)創(chuàng)建于其他表達式的上下文,因此這是個函數(shù)表達式。不需要名稱,也可以立即執(zhí)行。

也有其他方法告訴 JavaScript 我們需要的是函數(shù)表達式:

// 創(chuàng)建 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 對象與普通的值的內(nèi)存管理規(guī)則是一樣的。

  • 通常 Lexical Environment 在函數(shù)運行完畢就會被清理:

    function f() {
      let value1 = 123;
      let value2 = 456;
    }
    
    f();
    

    這兩個值是 Lexical Environment 的屬性,但是 f() 執(zhí)行完后,這個 Lexical Environment 無任何變量引用(unreachable),所以它會從內(nèi)存刪除。

  • ...但是如果有內(nèi)嵌函數(shù),它的 [[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() 如果被多次調(diào)用,返回的函數(shù)都被保存,相應的 Lexical Environment 會分別保存在內(nèi)存:

    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) 后被清除: 無嵌套函數(shù)引用它。下例中, 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
    

現(xiàn)實中的優(yōu)化

理論上,函數(shù)還在,它的所有外部變量都會被保留。

但在實踐中,JavaScript 引擎可能會對此作出優(yōu)化,引擎在分析變量的使用情況后,把沒有使用的外部變量刪除。

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

嘗試在 Chrome Developer Tools 運行以下代碼:

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

 function g() {
   debugger; // 在 console 輸入 alert( value ); 發(fā)現(xiàn)無此變量!
 }

 return g;
}

let g = f();
g();

你可以看到,這里沒有保存 value 變量!理論上它應該是可訪問的,但是引擎優(yōu)化移除了這個變量。

還有一個有趣的 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 ,很快就能發(fā)現(xiàn)這個 V8 feature。
這不是 bug 而是 V8 feature,或許將來會被修改。至于改沒改,運行一下上面的例子就能判斷啦。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

  • 這是16年5月份編輯的一份比較雜亂適合自己觀看的學習記錄文檔,今天18年5月份再次想寫文章,發(fā)現(xiàn)簡書還為我保存起的...
    Jenaral閱讀 3,115評論 2 9
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • Lua 5.1 參考手冊 by Roberto Ierusalimschy, Luiz Henrique de F...
    蘇黎九歌閱讀 14,235評論 0 38
  • 第2章 基本語法 2.1 概述 基本句法和變量 語句 JavaScript程序的執(zhí)行單位為行(line),也就是一...
    悟名先生閱讀 4,506評論 0 13
  • HTTPS SSL 第一步,愛麗絲給出協(xié)議版本號、一個客戶端生成的隨機數(shù)(Client random),以及客戶端...
    longtolong閱讀 373評論 0 0

友情鏈接更多精彩內(nèi)容