來源於 現代JavaScript教程
閉包章節
中文翻譯計劃
本文很清晰地解釋了閉包是什麼,以及閉包如何產生,相信你看完也會有所收穫
關鍵字 Closure 閉包 Lexical Environment 詞法環境 Environment Record 環境記錄
閉包(Closure)
JavaScript 是一個 function-oriented 的語言。這帶來了很大的操作自由。函式只需建立一次,可以拷貝到另一個變數,或者作為一個引數傳入另一個函式然後在一個全新的環境呼叫。
我們知道函式可以訪問它外部的變數,這個 feature 十分常用。
但是當外部變數改變時會發生什麼?函式時獲取最新的值,還是函式建立當時的值?
還有一個問題,當函式被送到其他地方再呼叫……他能訪問那個地方的外部變數嗎?
不同語言的表現有所不同,下面我們研究一下 JavaScript 中的表現。
兩個問題
我們先思考下面兩種情況,看完這篇文章你就可以回答這兩個問題,更復雜的問題也不在話下。
-
sayHi
函式使用了外部變數name
。函式執行時,會使用兩個值中的哪個?let name = "John"; function sayHi() { alert("Hi, " + name); } name = "Pete"; sayHi(); // "John" 還是 "Pete"? 複製程式碼
這個情況不論是瀏覽器端還是伺服器端都很常見。函式很可能在它建立一段時間後才執行,例如等待使用者操作或者網路請求。
問題是:函式是否會選擇變數最新的值呢?
-
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 物件包含兩個部分:(譯者:這裡是重點)
- Environment Record (環境記錄)是一個擁有全部區域性變數作為屬性的物件(以及其他如
this
值的資訊)。 - *outer lexical environment (外部詞法環境)*的引用,通常詞法關聯外面一層程式碼(花括號外一層)。
所以,“變數”就是內部物件 Environment Record 的一個屬性。要改變一個物件,意味著改變 Lexical Environment 的屬性。
例如在這段簡單的程式碼中,只有一個 Lexical Environment:
這就是所謂 global Lexical Environment (全域性語法環境),對應整個 script。對於瀏覽端,整個 <script>
標籤共享一個全域性環境。
(譯者:這裡是重點)
上圖中,正方形代表 Environment Record (變數儲存),箭頭代表 outer reference (外部引用)。global Lexical Environment 沒有外部引用,所以指向 null
。
下圖展示 let
變數的工作機制:
右邊的正方形描述 global Lexical Environment 在執行中如何改變:
- 指令碼開始執行,Lexical Environment 空。
let phrase
定義出現了。因為沒有賦值所以儲存為undefined
。phrase
被賦值。phrase
被賦新值。
看起來很簡單對不對?
總結:
- 變數是一個特殊內部物件的屬性,關聯於執行時的塊、函式、 script 。
- 對變數的操作實際上是對這個物件屬性的操作。
Function Declaration (函式宣告)
Function Declaration 並非處理於被執行的時候,而是 Lexical Environment 建立的時候。對於 global Lexical Environment ,這意味著 script 開始執行的時候。
這就是函式可以在定義前呼叫的原因。
以下程式碼 Lexical Environment 開始時非空。因為有 say
函式宣告,之後又有了 let
宣告的 phrase
:
Inner and outer Lexical Environment (內部詞法環境和外部詞法環境)
呼叫 say()
的過程中,它使用了外部變數,一起看看這裡面發生了什麼。
(譯者:這裡是重點) 函式執行時會自動建立一個新的函式 Lexical Environment 。這是所有函式的通用規則。這個新的 Lexical Environment 用於當前執行函式的存放區域性變數和形參。
箭頭標記的是執行 say("John")
時的 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 的當前值。
所以第一個問題的答案是 Pete
:
let name = "John";
function sayHi() {
alert("Hi, " + name);
}
name = "Pete"; // (*)
sayHi(); // Pete
複製程式碼
上述程式碼的執行流:
- global Lexical Environment 存在
name: "John"
。 (*)
行中,全域性變數修改了,現在成了這樣name: "Pete"
。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++
中的變數由內到外搜尋:
- 巢狀函式區域性變數……
- 外層函式……
- 直到全域性變數。
第二步我們找到了 count
。當外部變數被修改,它所在的地方就被修改。所以 count++
檢索外部變數並對其加一是操作於該變數自己的 Lexical Environment 。就像操作了 let count = 1
一樣。
這裡需要思考兩個問題:
- 我們能通過
makeCounter
以外的方法重置counter
嗎? - 如果我們可以多次呼叫
makeCounter()
,返回了很多counter
函式,他們的count
是獨立的還是共享的?
繼續閱讀前可以先嚐試思考一下。
...
ok ?
那我們開始揭曉謎底:
- 沒門。
counter
是區域性變數,不可能在外部直接訪問。 - 每次呼叫
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]]
屬性我們之前還未介紹。
-
指令碼開始執行,此時只存在 global Lexical Environment :
這時候只有
makeCounter
一個函式,這是函式宣告,還未被呼叫。所有函式都帶著一個隱藏屬性
[[Environment]]
“誕生”。[[Environment]]
指向它們建立的 Lexical Environment 。是[[Environment]]
讓函式知道它“誕生”於什麼環境。makeCounter
建立於 global Lexical Environment ,所以[[Environment]]
指向它。換句話說,Lexical Environment 在函式誕生時就“銘刻”在這個函式中。
[[Environment]]
是指向 Lexical Environment 的隱藏函式屬性。 -
程式碼繼續走,
makeCounter()
登上舞臺。這是程式碼執行到makeCounter()
瞬間的快照:makeCounter()
呼叫時,儲存當前變數和實參的 Lexical Environment 已經被建立。Lexical Environment 儲存 2 個東西:
- 帶有區域性變數的 Environment Record 。例子中
count
是唯一的區域性變數(let count
被執行的時候記錄)。 - 被繫結到函式
[[Environment]]
的外部詞法引用。例子裡makeCounter
的[[Environment]]
引用了 global Lexical Environment 。
所以這裡有兩個 Lexical Environments :全域性,和
makeCounter
(outer 引用全域性)。 - 帶有區域性變數的 Environment Record 。例子中
-
在
makeCounter()
執行的過程中,建立了一個巢狀函式。這無關於函式建立使用的是 Function Declaration (函式宣告)還是 Function Expression (函式表示式)。所有函式都會得到引用他們被建立時 Lexical Environment 的
[[Environment]]
屬性。這個巢狀函式的
[[Environment]]
是makeCounter()
(它的誕生地)的 Lexical Environment:同樣注意,這一步是函式宣告而非呼叫。
-
程式碼繼續執行,
makeCounter()
呼叫結束,內嵌函式被賦值到全域性變數counter
:這個函式只有一行:
return count++
。 -
counter()
被呼叫,自動建立一個 “空” Lexical Environment 。 此函式無區域性變數,但是[[Environment]]
引用了外面一層,所以它可以訪問makeCounter()
的變數。要訪問變數,先檢索自己的 Lexical Environment (empty),然後是
makeCounter()
的,最後是全域性的。例子中在最近的外層 Lexical EnvironmentmakeCounter
中發現了count
。重點來了,記憶體在這裡是怎麼管理的?儘管
makeCounter()
呼叫結束了,它的 Lexical Environment 依然儲存在記憶體中,這是因為巢狀函式的[[Environment]]
引用了它。通常, Lexical Environment 物件隨著使用它的函式的存在而存在。沒有函式引用它的時候,它才會被清除。
-
counter()
函式不只是返回count
,還會對其 +1 操作。這個修改已經在“適當的位置”完成了。count
的值在它被找到的環境中被修改。這一步出了返回了新的
count
,其他完全相同。(譯者:總結一下,宣告時記錄環境 [[Environment]](函式所在環境),執行時建立詞法環境(區域性+outer 就是引用 [[Environment]] ),而閉包就是函式 + 它的詞法環境,所以定義上來說所有函式都是閉包,但是之後被返回出來可以使用的閉包才是“實用意義”上的閉包)
-
下一個
counter()
呼叫操作同上。
本章開頭第二個問題的答案現在顯而易見了。
以下程式碼的 work()
函式通過外層 lexical environment 引用了它原地點的 name
:
所以這裡的答案是 "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 :
與函式同樣原理,塊內可以找到 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,或許將來會被修改。至於改沒改,執行一下上面的例子就能判斷啦。