來源于 現(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 十分常用搪花。
但是當(dāng)外部變量改變時會發(fā)生什么?函數(shù)時獲取最新的值嘹害,還是函數(shù)創(chuàng)建當(dāng)時的值撮竿?
還有一個問題,當(dāng)函數(shù)被送到其他地方再調(diào)用……他能訪問那個地方的外部變量嗎笔呀?
不同語言的表現(xiàn)有所不同幢踏,下面我們研究一下 JavaScript 中的表現(xiàn)。
兩個問題
我們先思考下面兩種情況许师,看完這篇文章你就可以回答這兩個問題房蝉,更復(fù)雜的問題也不在話下。
-
sayHi
函數(shù)使用了外部變量name
微渠。函數(shù)運行時搭幻,會使用兩個值中的哪個?let name = "John"; function sayHi() { alert("Hi, " + name); } name = "Pete"; sayHi(); // "John" 還是 "Pete"逞盆?
這個情況不論是瀏覽器端還是服務(wù)器端都很常見檀蹋。函數(shù)很可能在它創(chuàng)建一段時間后才執(zhí)行,例如等待用戶操作或者網(wǎng)絡(luò)請求云芦。
問題是:函數(shù)是否會選擇變量最新的值呢俯逾?
-
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 對象包含兩個部分:(譯者:這里是重點)
-
Environment Record (環(huán)境記錄)是一個擁有全部局部變量作為屬性的對象(以及其他如
this
值的信息)换团。 - outer lexical environment (外部詞法環(huán)境)的引用悉稠,通常詞法關(guān)聯(lián)外面一層代碼(花括號外一層)。
所以艘包,“變量”就是內(nèi)部對象 Environment Record 的一個屬性的猛。要改變一個對象耀盗,意味著改變 Lexical Environment 的屬性。
例如在這段簡單的代碼中卦尊,只有一個 Lexical Environment:
這就是所謂 global Lexical Environment (全局語法環(huán)境)叛拷,對應(yīng)整個 script。對于瀏覽端岂却,整個 <script>
標(biāo)簽共享一個全局環(huán)境忿薇。
(譯者:這里是重點)
上圖中,正方形代表 Environment Record (變量儲存)躏哩,箭頭代表 outer reference (外部引用)署浩。global Lexical Environment 沒有外部引用,所以指向 null
扫尺。
下圖展示 let
變量的工作機制:
右邊的正方形描述 global Lexical Environment 在執(zhí)行中如何改變:
- 腳本開始運行筋栋,Lexical Environment 空。
-
let phrase
定義出現(xiàn)了器联。因為沒有賦值所以儲存為undefined
二汛。 -
phrase
被賦值。 -
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
:
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 用于當(dāng)前運行函數(shù)的存放局部變量和形參级零。
箭頭標(biāo)記的是執(zhí)行 say("John")
時的 Lexical Environment :
函數(shù)調(diào)用過程中,可以看到兩個 Lexical Environment :里面的是函數(shù)調(diào)用產(chǎn)生的滞乙,外面的是全局的:
- 內(nèi)層 Lexical Environment 對應(yīng)當(dāng)前執(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
韭寸,立即就能在當(dāng)前函數(shù)的 Lexical Environment 找到春哨。 - 對于
phrase
,局部變量不存在phrase
恩伺,所以要循著outer
在全局變量里找到赴背。
現(xiàn)在我們可以回答本章開頭的第一個問題了。
函數(shù)獲取外部變量當(dāng)前值
舊變量值不儲存在任何地方晶渠,函數(shù)需要他們的時候凰荚,它取得來源于自身或外部 Lexical Environment 的當(dāng)前值。
所以第一個問題的答案是 Pete
:
let name = "John";
function sayHi() {
alert("Hi, " + name);
}
name = "Pete"; // (*)
sayHi(); // Pete
上述代碼的執(zhí)行流:
- global Lexical Environment 存在
name: "John"
褒脯。 -
(*)
行中便瑟,全局變量修改了,現(xiàn)在成了這樣name: "Pete"
番川。 -
say()
執(zhí)行的時候到涂, 取外部name
。此時在 global Lexical Environment 中已經(jīng)是"Pete"
爽彤。
一次調(diào)用养盗,一個 Lexical Environment
請注意,每當(dāng)一個函數(shù)運行适篙,就會創(chuàng)建一個新的 function Lexical Environment往核。
如果一個函數(shù)被多次調(diào)用,那么每次調(diào)用都會生成一個屬于當(dāng)前調(diào)用的全新 Lexical Environment 嚷节,里面裝載著當(dāng)前調(diào)用的變量和實參聂儒。
Lexical Environment 是一個標(biāo)準(zhǔn)對象 (specification object)
"Lexical Environment" 是一個標(biāo)準(zhǔn)對象 (specification object)虎锚。我們不能直接獲取或設(shè)置它,JavaScript 引擎也可能優(yōu)化它衩婚,拋棄未使用的變量來節(jié)省內(nèi)存或者作其他優(yōu)化窜护,但是可見行為應(yīng)該如上面所述。
嵌套函數(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)到外搜索:
- 嵌套函數(shù)局部變量……
- 外層函數(shù)……
- 直到全局變量热幔。
第二步我們找到了 count
乐设。當(dāng)外部變量被修改,它所在的地方就被修改绎巨。所以 count++
檢索外部變量并對其加一是操作于該變量自己的 Lexical Environment 近尚。就像操作了 let count = 1
一樣。
這里需要思考兩個問題:
- 我們能通過
makeCounter
以外的方法重置counter
嗎场勤? - 如果我們可以多次調(diào)用
makeCounter()
戈锻,返回了很多counter
函數(shù),他們的count
是獨立的還是共享的和媳?
繼續(xù)閱讀前可以先嘗試思考一下格遭。
...
ok ?
那我們開始揭曉謎底:
- 沒門留瞳。
counter
是局部變量拒迅,不可能在外部直接訪問。 - 每次調(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)在你能清楚外部變量的使用,但是你仍然需要更深入地理解以面對更復(fù)雜的情況前硫,現(xiàn)在我們進入下一步胞得。
Environment 細(xì)節(jié)
對 closure (閉包)有了初步了解之后,可以開始深入細(xì)節(jié)了屹电。
下面是 makeCounter
例子的動作分解阶剑,跟著看你就能理解一切了。注意危号, [[Environment]]
屬性我們之前還未介紹个扰。
-
腳本開始運行,此時只存在 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ù)屬性。 -
代碼繼續(xù)走盏檐,
makeCounter()
登上舞臺歇式。這是代碼運行到makeCounter()
瞬間的快照:imagemakeCounter()
調(diào)用時,保存當(dāng)前變量和實參的 Lexical Environment 已經(jīng)被創(chuàng)建胡野。Lexical Environment 儲存 2 個東西:
- 帶有局部變量的 Environment Record 材失。例子中
count
是唯一的局部變量(let count
被執(zhí)行的時候記錄)。 - 被綁定到函數(shù)
[[Environment]]
的外部詞法引用硫豆。例子里makeCounter
的[[Environment]]
引用了 global Lexical Environment 龙巨。
所以這里有兩個 Lexical Environments :全局,和
makeCounter
(outer 引用全局)熊响。 - 帶有局部變量的 Environment Record 材失。例子中
-
在
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)用。
-
代碼繼續(xù)執(zhí)行胆屿,
makeCounter()
調(diào)用結(jié)束奥喻,內(nèi)嵌函數(shù)被賦值到全局變量counter
:image這個函數(shù)只有一行:
return count++
。 -
counter()
被調(diào)用非迹,自動創(chuàng)建一個 “空” Lexical Environment 环鲤。 此函數(shù)無局部變量,但是[[Environment]]
引用了外面一層憎兽,所以它可以訪問makeCounter()
的變量冷离。image要訪問變量,先檢索自己的 Lexical Environment (empty)纯命,然后是
makeCounter()
的西剥,最后是全局的。例子中在最近的外層 Lexical EnvironmentmakeCounter
中發(fā)現(xiàn)了count
亿汞。重點來了瞭空,內(nèi)存在這里是怎么管理的?盡管
makeCounter()
調(diào)用結(jié)束了疗我,它的 Lexical Environment 依然保存在內(nèi)存中咆畏,這是因為嵌套函數(shù)的[[Environment]]
引用了它。通常吴裤, Lexical Environment 對象隨著使用它的函數(shù)的存在而存在旧找。沒有函數(shù)引用它的時候,它才會被清除麦牺。
-
counter()
函數(shù)不只是返回count
钮蛛,還會對其 +1 操作。這個修改已經(jīng)在“適當(dā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ù)都是閉包柜候,但是之后被返回出來可以使用的閉包才是“實用意義”上的閉包)
下一個
counter()
調(diào)用操作同上。
本章開頭第二個問題的答案現(xiàn)在顯而易見了躏精。
以下代碼的 work()
函數(shù)通過外層 lexical environment 引用了它原地點的 name
:
所以這里的答案是 "Pete"
渣刷。
但是如果 makeWorker()
沒了 let name
,如我們所見矗烛,作用域搜索會到達外層辅柴,獲取全局變量箩溃。這個情況下答案會是 "John"
。
閉包 (Closure)
開發(fā)者們都應(yīng)該知道編程領(lǐng)域的通用名詞閉包 (closure)碌嘀。
Closure 是一個記錄并可訪問外層變量的函數(shù)涣旨。在一些編程語言中,這是不可能的股冗,或者要以一種特殊的方式書寫以實現(xiàn)這個功能霹陡。但是如上面解釋的, JavaScript 的所有函數(shù)都(很自然地)是個閉包止状。(有一個例外烹棉,詳見info:new-function)
這就是閉包:它們使用[[Environment]]
屬性自動記錄各自的創(chuàng)建地點,然后由此訪問外部變量怯疤。
在前端面試中浆洗,如果面試官問你什么是閉包,正確答案應(yīng)該包括閉包的定義集峦,以及解釋為何 JavaScript 的所有函數(shù)都是閉包伏社,最好可以再簡單說說里面的技術(shù)細(xì)節(jié):[[Environment]]
屬性和 Lexical Environments 的原理。
代碼塊塔淤、循環(huán)洛口、 IIFE
上面的例子都著重于函數(shù),但是 Lexical Environment 也存在于代碼塊 {...}
凯沪。
它們在代碼塊運行時創(chuàng)建,包含塊局部變量买优。這里有一些例子妨马。
If
下例中,當(dāng)執(zhí)行到 if
塊杀赢,會為這個塊創(chuàng)建新的 "if-only" Lexical Environment :
與函數(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)都會帶著當(dāng)前的 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ù)都被保存,相應(yīng)的 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īng)該是可訪問的,但是引擎優(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,或許將來會被修改屈糊。至于改沒改的榛,運行一下上面的例子就能判斷啦。