前言
繼上一篇《理解 JavaScript 中的作用域》後,我又立刻寫下了這篇文章,因為這兩者是存在關聯的,在理解閉包前,你需要知道作用域。
而對於那些有一點 JavaScript 使用經驗的人來說,理解閉包可以看做是某種意義上的重生,但這並不簡單,你需要付出非常多的努力和犧牲才能理解這個概念。
如果你理解了閉包,你會發現即便是沒理解閉包之前,你也用到了閉包,但我們要做的就是根據自己的意願正確地識別、使用閉包。
什麼是閉包
閉包的定義,你需要掌握它才能理解和識別閉包:
當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即便函式是在當前詞法作用域之外執行。
下面用一些程式碼來解釋這個定義:
function foo(){
var a = 2;
function bar(){
console.log(a); // 2
}
bar();
}
foo();
複製程式碼
很明顯這是一個巢狀作用域,而bar
的作用域也確實能夠訪問外部作用域,但這就是閉包嗎?
不,不完全是,但它是閉包中很重要的一部分:根據詞法作用域的查詢規則,它能夠訪問外部作用域。
下面再來看這段程式碼,它清晰地使用了閉包:
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2 —— 這就是閉包
複製程式碼
由於bar
的詞法作用域能夠訪問foo
的內部作用域,然後我們把bar
這個函式本身當作返回值,然後在呼叫foo
時把bar
引用的函式賦值給baz
(其實是兩個識別符號引用同一個函式),所以baz
能夠訪問foo
的內部作用域。
而這裡正是印證前面的定義:函式是在當前詞法作用域之外執行。
其實按正常情況下,引擎有垃圾回收器用來釋放不再使用的記憶體空間,當foo
執行完畢時,自然會將其回收,但閉包的神奇之處正是可以阻止這件事情的發生,因為內部作用域依然存在,bar
在使用它。
由於bar
宣告位置的原因,它涵蓋了foo
內部作用域的閉包,使得該作用域能夠一直存活,以供bar
在之後任何時間進行引用。
bar
依然有對該作用域的引用,而這個引用就叫做閉包。
因此,當baz
在呼叫時,它自然能夠訪問到foo
的內部作用域。
當然,無論使用何種方式對函式型別的值進行傳遞,當函式在別處被呼叫時都可以觀察到閉包的存在:
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
fn(); // 2 —— 這也是閉包
}
複製程式碼
把內部函式baz
作為fn
引數傳遞給bar
,當呼叫fn
時,它能夠訪問到foo
的內部作用域。
傳遞函式也可以是間接的:
var fn;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
fn = baz;
}
foo();
fn(); // 2 —— 這也是閉包
複製程式碼
所以:
無論通過何種方式將內部函式傳遞到所在的詞法作用於之外,它都會持有對原始定義作用域的引用,無論在何處執行這個函式都會使用閉包。
閉包的使用
既然前面說閉包無處不在,那不妨看看幾個平時經常看到的片段,看看閉包的妙用。
function wait(message){
setTimeout(function timer(){
console.log(message);
},1000);
}
wait("Hello, closure!");
複製程式碼
將一個內部函式(這裡叫做timer
)作為引數傳遞給setTimeout
,而timer
能夠訪問wait
的內部作用域。
如果你使用過jQuery
,不難發現下面程式碼中也使用了閉包:
function setupBot(name,selector){
$(selector).click(function activator(){
console.log("Activating:" + name);
})
}
setupBot("Closure Bot 1","#btn_1");
setupBot("Closure Bot 2","#btn_2");
複製程式碼
本質上無論何時何地,如果將函式( 訪問它們各自的詞法作用域)當作第一級的值型別併到處傳遞, 你就會看到閉包在這些函式中的應用。 在定時器、 事件監聽器、Ajax請求、 跨視窗通訊、Web Workers或者任何其他的非同步( 或者同步)任務中, 只要使用了回撥函式,實際上就是在使用閉包!
再來看一個很經典的閉包面試題:
for (var i=1; i<=5; i++){
setTimeout(function(){
console.log(i);
},i*1000);
}
複製程式碼
正常情況下,我們對這段程式碼行為的預期是每秒一次輸出1~5。
但實際上,這段程式碼在執行時會以每秒一次的頻率輸出五次6。
為什麼?
首先解釋6是從哪裡來的,這個迴圈的終止條件是i
不再<=5
,所以當條件成立時,i
等於6。因此,輸出顯示的是迴圈結束時i
的最終值。
也就是我們陷入了一個這樣的誤區:以為迴圈中每個迭代在執行時都會複製一個i
的副本,但根據作用域的工作原理,它們都共享同一個全域性作用域,因此實際上只有一個i
。
要使這段程式碼的執行與我們預期一致,解決方法如下:
for (var i=1; i<=5; i++){
(function(j){
setTimeout(function(){
console.log(j);
},j*1000);
})(i)
}
複製程式碼
在這段程式碼中我們使用了IIFE
,將i
作為引數j
傳遞進去,在每個迭代IIFE
會生成一個自己的作用域,它們接受引數j
不一樣,所以這段程式碼能夠符合我們預期地執行。
還有別的解決方案嗎?
是的,使用 ES6 新出的let
可以解決這個問題:
for (let i=1; i<=5; i++){
setTimeout(function(){
console.log(i);
},i*1000);
}
複製程式碼
我們僅僅把var
替換為let
就輕鬆地解決了該問題,原因如下:
for
中有自己的塊作用域(()
是父級作用域,{}
是子級作用域)。- 使用
let
能夠建立塊作用域的變數。
好了,到現在你應該能夠很容易地識別閉包,那麼接下來,我們繼續介紹閉包更高階的用法。
假設我們有這樣一個物件:
var box = {
age : 18,
}
console.log(box.age); // 18
複製程式碼
然而這裡有一個問題,那就是屬性age
可以隨意改變,如果我們使用閉包,就可以實現私有化,將age
屬性保護起來,只做允許的修改。
var box = (function (){
var age = 18;
return {
birthday : function(){
age++;
},
sayAge : function(){
console.log(age);
}
}
})();
box.birthday();
box.sayAge(); // 19
複製程式碼
這樣我們就保證age
屬性只能增加,而不能減少,畢竟沒有人能夠越活越年輕。
注意:
- 其實物件也有方法可以控制屬性的修改,但這裡主要講述閉包,就不過多贅述。
- 使用閉包能夠輕鬆實現原本在 JavaScript 較複雜的設計。
後記
其實當你理解了閉包之後,你就會發現一切都是那麼的理所當然,就彷彿它本該如此。
最後,如果你已經理解了閉包並且想練習一下,那麼我可以出一道題目給你:
實現一個
add
函式,功能:add(1)(2)(3); // 6
難一點的:
實現一個
add
函式,功能:add(3)(‘*’)(3); // 9
有幾點:
add
函式可以被無限呼叫。- 呼叫完畢後將結果輸出到控制檯。
感謝觀看!
注:此文為原創文章,如需轉載,請註明出處。