感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
希望我們是帶著對作用域工作方式的健全,堅實的理解來到這里的。
我們將我們的注意力轉向這個語言中一個重要到不可思議,但是一直難以捉摸的,幾乎是神話般的 部分:閉包。如果你至此一直跟隨著我們關于詞法作用域的討論,那么你會感覺閉包將在很大程度上沒那么令人激動,幾乎是顯而易見的。有一個魔法師坐在幕后,現在我們即將見到他。不,他的名字不是Crockford!
如果你還對詞法作用域感到不安,那么現在就是在繼續之前回過頭去再復習一下第二章的好時機。
啟示
對于那些對JavaScript有些經驗,但是也許從沒全面掌握閉包概念的人來說,理解閉包 看起來就像是必須努力并作出犧牲才能到達的涅槃狀態。
回想幾年前我對JavaScript有了牢固的掌握,但是不知道閉包是什么。它暗示著這種語言有著另外的一面,它許諾了甚至比我已經擁有的還多的力量,它取笑并嘲弄我。我記得我通讀早期框架的源代碼試圖搞懂它到底是如何工作的。我記得第一次“模塊模式”的某些東西融入我的大腦。我記得那依然栩栩如生的 啊哈! 一刻。
那時我不明白的東西,那個花了我好幾年時間才搞懂的東西,那個我即將傳授給你的東西,是這個秘密:在JavaScript中閉包無所不在,你只是必須認出它并接納它。閉包不是你必須學習新的語法和模式才能使用的特殊的可選的工具。不,閉包甚至不是你必須像盧克在原力中修煉那樣,一定要學會使用并掌握的武器。
閉包是依賴于詞法作用域編寫代碼而產生的結果。它們就這么發生了。要利用它們你甚至不需要有意地創建閉包。閉包在你的代碼中一直在被創建和使用。你 缺少 的是恰當的思維環境,來識別,接納,并以自己的意志利用閉包。
啟蒙的時刻應該是:哦,閉包已經在我的代碼中到處發生了,現在我終于 看到 它們了。理解閉包就像是尼歐第一次見到母體。
事實真相
好了,夸張和對電影的無恥引用夠多了。
為了理解和識別閉包,這里有一個你需要知道的簡單粗暴的定義:
閉包就是函數能夠記住并訪問它的詞法作用域,即使當這個函數在它的詞法作用域之外執行時。
讓我們跳進代碼來說明這個定義:
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();
根據我們對嵌套作用域的討論,這段代碼應當看起來很熟悉。由于詞法作用域查詢規則(在這個例子中,是一個RHS引用查詢),函數bar()
可以 訪問 外圍作用域的變量a
。
這是“閉包”嗎?
好吧,技術上……也許是。但是根據我們上面的“你需要知道”的定義……不確切。我認為解釋bar()
引用a
的最準確的方式是根據詞法作用域查詢規則,但是那些規則 僅僅 是閉包的(一個很重要的?。?strong>一部分。
從純粹的學院派角度講,上面的代碼段被認為是函數bar()
在函數foo()
的作用域上有一個 閉包(而且實際上,它甚至對其他的作用域也可以訪問,比如這個例子中的全局作用域)。換一種略有不同的說法是,bar()
閉住了foo()
的作用域。為什么?因為bar()
嵌套地出現在foo()
內部。簡單直白。
但是,這樣一來閉包的定義就是不能直接 觀察到 的了,我們也不能看到閉包在這個代碼段中 被行使。我們清楚地看到詞法作用域,但是閉包仍然像代碼后面謎一般的模糊陰影。
讓我們考慮這段將閉包完全照亮的代碼:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 -- 哇噢,看到閉包了,伙計。
函數bar()
對于foo()
內的作用域擁有詞法作用域訪問權。但是之后,我們拿起bar()
,這個函數本身,將它像 值 一樣傳遞。在這個例子中,我們return``bar
引用的函數對象本身。
在執行foo()
之后,我們將它返回的值(我們里面的bar()
函數)賦予一個稱為baz
的變量,然后我們實際地調用baz()
,這將理所當然地調用我們內部的函數bar()
,只不過是通過一個不同的標識符引用。
bar()
被執行了,必然的。但是在這個例子中,它是在它被聲明的詞法作用域 外部 被執行的。
foo()
被執行之后,一般說來我們會期望foo()
的整個內部作用域都將消失,因為我們知道 引擎 啟用了 垃圾回收器 在內存不再被使用時來回收它們。因為很顯然foo()
的內容不再被使用了,所以看起來它們很自然地應該被認為是 消失了。
但是閉包的“魔法”不會讓這發生。內部的作用域實際上 依然 “在使用”,因此將不會消失。誰在使用它?函數bar()
本身。
有賴于它被聲明的位置,bar()
擁有一個詞法作用域閉包覆蓋著foo()
的內部作用域,閉包為了能使bar()
在以后任意的時刻可以引用這個作用域而保持它的存在。
bar()
依然擁有對那個作用域的引用,而這個引用稱為閉包。
所以,在幾微秒之后,當變量baz
被調用時(調用我們最開始標記為bar
的內部函數),它理所應當地對編寫時的詞法作用域擁有 訪問 權,所以它可以如我們所愿地訪問變量a
。
這個函數在它被編寫時的詞法作用域之外被調用。閉包 使這個函數可以繼續訪問它在編寫時被定義的詞法作用域。
當然,函數可以被作為值傳遞,而且實際上在其他位置被調用的所有各種方式,都是觀察/行使閉包的例子。
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 看媽媽,我看到閉包了!
}
我們將內部函數baz
傳遞給bar
,并調用這個內部函數(現在被標記為fn
),當我們這么做時,它覆蓋在foo()
內部作用域的閉包就可以通過a
的訪問觀察到。
這樣的函數傳遞也可以是間接的。
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 將`baz`賦值給一個全局變量
}
function bar() {
fn(); // 看媽媽,我看到閉包了!
}
foo();
bar(); // 2
無論我們使用什么方法將內部函數 傳送 到它的詞法作用域之外,它都將維護一個指向它最開始被聲明時的作用域的引用,而且無論我們什么時候執行它,這個閉包就會被行使。
現在我能看到了
前面的代碼段有些學術化,而且是人工構建來說明 閉包的使用 的。但我保證過給你的東西不止是一個新的酷玩具。我保證過閉包是在你的現存代碼中無處不在的東西。現在讓我們 看看 真相。
function wait(message) {
setTimeout( function timer(){
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
我們拿來一個內部函數(名為timer
)將它傳遞給setTimeout(..)
。但是timer
擁有覆蓋wait(..)
的作用域的閉包,實際上保持并使用著對變量message
的引用。
在我們執行wait(..)
一千毫秒之后,要不是內部函數timer
依然擁有覆蓋著wait()
內部作用域的閉包,它早就會消失了。
在 引擎 的內臟深處,內建的工具setTimeout(..)
擁有一些參數的引用,可能稱為fn
或者func
或者其他諸如此類的東西。引擎 去調用這個函數,它調用我們的內部timer
函數,而詞法作用域依然完好無損。
閉包。
或者,如果你信仰jQuery(或者就此而言,其他的任何JS框架):
function setupBot(name,selector) {
$( selector ).click( function activator(){
console.log( "Activating: " + name );
} );
}
setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );
我不確定你寫的是什么代碼,但我通常寫一些代碼來負責控制全球的閉包無人機軍團,所以這完全是真實的!
把玩笑放在一邊,實質上 無論何時何地 只要你將函數作為頭等的值看待并將它們傳來傳去的話,你就可能看到這些函數行使閉包。計時器,事件處理器,Ajax請求,跨窗口消息,web worker,或者任何其他的異步(或同步?。┤蝿?,當你傳入一個 回調函數,你就在它周圍懸掛了一些閉包!
注意: 第三章介紹了IIFE模式。雖然人們常說IIFE(獨自)是一個可以觀察到閉包的例子,但是根據我們上面的定義,我有些不同意。
var a = 2;
(function IIFE(){
console.log( a );
})();
這段代碼“好用”,但嚴格來說它不是在觀察閉包。為什么?因為這個函數(就是我們這里命名為“IIFE”的那個)沒有在它的詞法作用域之外執行。它仍然在它被聲明的相同作用域中(那個同時持有a
的外圍/全局作用域)被調用。a
是通過普通的詞法作用域查詢找到的,不是通過真正的閉包。
雖說技術上閉包可能發生在聲明時,但它 不是 嚴格地可以觀察到的,因此,就像人們說的,它是一顆在森林中倒掉的樹,但沒人聽得到它。
雖然IIFE 本身 不是一個閉包的例子,但是它絕對創建了作用域,而且它是我們用來創建可以被閉包的最常見的工具之一。所以IIFE確實與閉包有強烈的關聯,即便它們本身不行使閉包。
親愛的讀者,現在把這本書放下。我有一個任務給你。去打開一些你最近的JavaScript代碼。尋找那些被你作為值的函數,并識別你已經在那里使用了閉包,而你以前甚至可能不知道它。
我會等你。
現在……你看到了!
循環 + 閉包
用來展示閉包最常見最權威的例子是老實巴交的for循環。
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
注意: 當你將函數放在循環內部時Linter經常會抱怨,因為不理解閉包的錯誤 在開發者中太常見了。我們在這里講解如何正確地利用閉包的全部力量。但是Linter通常不理解這樣的微妙之處,所以它們不管怎樣都將抱怨,認為你 實際上 不知道你在做什么。
這段代碼的精神是,我們一般將期待它的行為是分別打印數字“1”,“2”,……“5”,一次一個,一秒一個。
實際上,如果你運行這段代碼,你會得到“6”被打印5次,在一秒的間隔內。
?。?/strong>
首先,讓我們解釋一下“6”是從哪兒來的。循環的終結條件是i
不 <=5
。第一次滿足這個條件時i
是6。所以,輸出的結果反映的是i
在循環終結后的最終值。
如果多看兩眼的話這其實很明顯。超時的回調函數都將在循環的完成之后立即運行。實際上,就計時器而言,即便在每次迭代中它是setTimeout(.., 0)
,所有這些回調函數也都仍然是嚴格地在循環之后運行的,因此每次都打印6
。
但是這里有個更深刻的問題。要是想讓它實際上如我們在語義上暗示的那樣動作,我們的代碼缺少了什么?
缺少的東西是,我們試圖 暗示 在迭代期間,循環的每次迭代都“捕捉”一份對i
的拷貝。但是,雖然所有這5個函數在每次循環迭代中分離地定義,由于作用域的工作方式,它們 都閉包在同一個共享的全局作用域上,而它事實上只有一個i
。
這么說來,所有函數共享一個指向相同的i
的引用是 理所當然 的。循環結構的某些東西往往迷惑我們,使我們認為這里有其他更精巧的東西在工作。但是這里沒有。這與根本沒有循環,5個超時回調僅僅一個接一個地被聲明沒有區別。
好了,那么,回到我們火燒眉毛的問題。缺少了什么?我們需要更多 鈴聲 被閉包的作用域。明確地說,我們需要為循環的每次迭代都準備一個新的被閉包的作用域。
我們在第三章中學到,IIFE通過聲明并立即執行一個函數來創建作用域。
讓我們試試:
for (var i=1; i<=5; i++) {
(function(){
setTimeout( function timer(){
console.log( i );
}, i*1000 );
})();
}
這好用嗎?試試。我還會等你。
我來為你終結懸念。不好用。 但是為什么?很明顯我們現在有了更多的詞法作用域。每個超時回調函數確實閉包在每次迭代時分別被每個IIFE創建的作用域中。
擁有一個被閉包的空的作用域是不夠的。仔細觀察。我們的IIFE只是一個空的什么也不做的作用域。它內部需要 一些東西 才能變得對我們有用。
它需要它自己的變量,在每次迭代時持有值i
的一個拷貝。
for (var i=1; i<=5; i++) {
(function(){
var j = i;
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})();
}
萬歲!它好用了!
有些人偏好一種稍稍變形的形式:
for (var i=1; i<=5; i++) {
(function(j){
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})( i );
}
當然,因為這些IIFE只是函數,我們可以傳入i
,如果我們樂意的話可以稱它為為j
,或者我們甚至可以再次稱它為i
。不管哪種方式,這段代碼都能工作。
在每次迭代內部使用的IIFE為每次迭代創建了新的作用域,這給了我們的超時回調函數一個機會在每次迭代時閉包一個新的作用域,這些作用域中的每一個都擁有一個持有正確的迭代值的變量給我們訪問。
問題解決了!
重溫塊兒作用域
仔細觀察我們前一個解決方案的分析。我們使用了一個IIFE來在每一次迭代中創建新的作用域。換句話說,我們實際上每次迭代都 需要 一個 塊兒作用域。我們在第三章展示了let
聲明,它劫持一個塊兒并且就在這個塊兒中聲明一個變量。
這實質上將塊兒變成了一個我們可以閉包的作用域。所以接下來的牛逼代碼“就是好用”:
for (var i=1; i<=5; i++) {
let j = i; // 呀,給閉包的塊兒作用域!
setTimeout( function timer(){
console.log( j );
}, j*1000 );
}
但是,這還不是全部!(用我最棒的Bob Barker嗓音)在用于for循環頭部的let
聲明被定義了一種特殊行為。這種行為說,這個變量將不是只為循環聲明一次,而是為每次迭代聲明一次。并且,它將在每次后續的迭代中被上一次迭代末尾的值初始化。
for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}
這有多酷?塊兒作用域和閉包攜手工作,解決世界上所有的問題。我不知道你怎么樣,但這使我成了一個快樂的JavaScript開發者。
模塊
還有其他的代碼模式利用了閉包的力量,但是它們都不像回調那樣浮于表面。讓我們來檢視它們中最強大的一種:模塊。
function foo() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
}
就現在這段代碼來說,沒有發生明顯的閉包。我們只是擁有一些私有數據變量something
和another
,和幾個內部函數doSomething()
和doAnother()
,它們都擁有覆蓋在foo()
內部作用域上的詞法作用域(因此是閉包?。?。
但是現在考慮這段代碼:
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
在JavaScript中我們稱這種模式為 模塊。實現模塊模式的最常見方法經常被稱為“揭示模塊”,它是我們在這里展示的方式的變種。
讓我們檢視關于這段代碼的一些事情。
首先,CoolModule()
只是一個函數,但它 必須被調用 才能成為一個被創建的模塊實例。沒有外部函數的執行,內部作用域的創建和閉包都不會發生。
第二,CoolModule()
函數返回一個對象,通過對象字面量語法{ key: value, ... }
標記。這個我們返回的對象擁有指向我們內部函數的引用,但是 沒有 指向我們內部數據變量的引用。我們可以將它們保持為隱藏和私有的??梢院芮‘數卣J為這個返回值對象實質上是一個 我們模塊的公有API。
這個返回值對象最終被賦值給外部變量foo
,然后我們可以在這個API上訪問那些屬性,比如foo.doSomething()
。
注意: 從我們的模塊中返回一個實際的對象(字面量)不是必須的。我們可以僅僅直接返回一個內部函數。jQuery就是一個很好地例子。jQuery
和$
標識符是jQuery“模塊”的公有API,但是它們本身只是一個函數(這個函數本身可以有屬性,因為所有的函數都是對象)。
doSomething()
和doAnother()
函數擁有模塊“實例”內部作用域的閉包(通過實際調用CoolModule()
得到的)。當我們通過返回值對象的屬性引用,將這些函數傳送到詞法作用域外部時,我們就建立好了可以觀察和行使閉包的條件。
更簡單地說,行使模塊模式有兩個“必要條件”:
必須有一個外部的外圍函數,而且它必須至少被調用一次(每次創建一個新的模塊實例)。
外圍的函數必須至少返回一個內部函數,這樣這個內部函數才擁有私有作用域的閉包,并且可以訪問和/或修改這個私有狀態。
一個僅帶有一個函數屬性的對象不是 真正 的模塊。從可觀察的角度來說,一個從函數調用中返回的對象,僅帶有數據屬性而沒有閉包的函數,也不是 真正 的模塊。
上面的代碼段展示了一個稱為CoolModule()
獨立的模塊創建器,它可以被調用任意多次,每次創建一個新的模塊實例。這種模式的一個稍稍的變化是當你只想要一個實例的時候,某種“單例”:
var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
這里,我們將模塊放進一個IIFE(見第三章)中,而且我們 立即 調用它,并把它的返回值直接賦值給我們單獨的模塊實例標識符foo
。
模塊只是函數,所以它們可以接收參數:
function CoolModule(id) {
function identify() {
console.log( id );
}
return {
identify: identify
};
}
var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"
另一種在模塊模式上微小但是強大的變化是,為你作為公有API返回的對象命名:
var foo = (function CoolModule(id) {
function change() {
// 修改公有 API
publicAPI.identify = identify2;
}
function identify1() {
console.log( id );
}
function identify2() {
console.log( id.toUpperCase() );
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
通過在模塊實例內部持有一個指向公有API對象的內部引用,你可以 從內部 修改這個模塊,包括添加和刪除方法,屬性,和 改變它們的值。
現代的模塊
各種模塊依賴加載器/消息機制實質上都是將這種模塊定義包裝進一個友好的API。與其檢視任意一個特定的庫,不如讓我 (僅)為了說明的目的 展示一個 非常簡單 的概念證明:
var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get
};
})();
這段代碼的關鍵部分是modules[name] = impl.apply(impl, deps)
。這為一個模塊調用了它的定義的包裝函數(傳入所有依賴),并將返回值,也就是模塊的API,存儲到一個用名稱追蹤的內部模塊列表中。
這里是我可能如何使用它來定義一個模塊:
MyModules.define( "bar", [], function(){
function hello(who) {
return "Let me introduce: " + who;
}
return {
hello: hello
};
} );
MyModules.define( "foo", ["bar"], function(bar){
var hungry = "hippo";
function awesome() {
console.log( bar.hello( hungry ).toUpperCase() );
}
return {
awesome: awesome
};
} );
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(
bar.hello( "hippo" )
); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO
模塊“foo”和“bar”都使用一個返回公有API的函數來定義?!癴oo”甚至接收一個“bar”的實例作為依賴參數,并且可以因此使用它。
花些時間檢視這些代碼段,來完全理解將閉包的力量付諸實踐給我們帶來的好處。關鍵之處在于,對于模塊管理器來說真的沒有什么特殊的“魔法”。它們只是滿足了我在上面列出的模塊模式的兩個性質:調用一個函數定義包裝器,并將它的返回值作為這個模塊的API保存下來。
換句話說,模塊就是模塊,即便你在它們上面放了一個友好的包裝工具。
未來的模塊
ES6為模塊的概念增加了頭等的語法支持。當通過模塊系統加載時,ES6將一個文件視為一個獨立的模塊。每個模塊可以導入其他的模塊或者特定的API成員,也可以導出它們自己的公有API成員。
注意: 基于函數的模塊不是一個可以被靜態識別的模式(編譯器可以知道的東西),所以它們的API語義直到運行時才會被考慮。也就是,你實際上可以在運行時期間修改模塊的API(參見早先publicAPI
的討論)。
相比之下,ES6模塊API是靜態的(這些API不會在運行時改變)。因為編譯器知道它,它可以(也確實在作?。┰冢ㄎ募虞d和)編譯期間檢查一個指向被導入模塊的成員的引用是否 實際存在。如果API引用不存在,編譯器就會在編譯時拋出一個“早期”錯誤,而不是等待傳統的動態運行時解決方案(和錯誤,如果有的話)。
ES6模塊 沒有 “內聯”格式,它們必須被定義在一個分離的文件中(每個模塊一個)。瀏覽器/引擎擁有一個默認的“模塊加載器”(它是可以被覆蓋的,但是這超出我們在此討論的范圍),它在模塊被導入時同步地加載模塊文件。
考慮這段代碼:
bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
foo.js
// 僅僅從“bar”模塊中導入`hello()`
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
// 導入`foo`和`bar`整個模塊
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
注意: 需要使用前兩個代碼片段中的內容分別創建兩個分離的文件 “foo.js” 和 “bar.js”。然后,你的程序將加載/導入這些模塊來使用它們,就像第三個片段那樣。
import
在當前的作用域中導入一個模塊的API的一個或多個成員,每個都綁定到一個變量(這個例子中是hello
)。module
將整個模塊的API導入到一個被綁定的變量(這個例子中是foo
,bar
)。export
為當前模塊的公有API導出一個標識符(變量,函數)。在一個模塊的定義中,這些操作符可以根據需要使用任意多次。
在 模塊文件 內部的內容被視為像是包圍在一個作用域閉包中,就像早先看到的使用函數閉包的模塊那樣。
復習
閉包就像在JavaScript內部被隔離開的魔法世界,看起來少為人知,只有很少一些最勇敢的靈魂才能到達。但是它實際上只是一個標準的,而且幾乎明顯的事實 —— 我們如何在函數即是值,而且可以被隨意傳遞的詞法作用域環境中編寫代碼,
閉包就是當一個函數即使是在它的詞法作用域之外被調用時,也可以記住并訪問它的詞法作用域。
如果我們不能小心地識別它們和它們的工作方式,閉包可能會絆住我們,例如在循環中。但它們也是一種極其強大的工具,以各種形式開啟了像 模塊 這樣的模式。
模塊要求兩個關鍵性質:1)一個被調用的外部包裝函數,來創建外圍作用域。2)這個包裝函數的返回值必須包含至少一個內部函數的引用,這個函數才擁有包裝函數內部作用域的閉包。
現在我們看到了閉包在我們的代碼中無處不在,而且我們有能力識別它們,并為了我們自己的利益利用它們!