聲明:以下內容摘自《你不知道的javascript》上卷一書的第5章的片段。
1.循環和閉包
要說明閉包,for循環是最常見的例子。
for (var i=1; i <= 5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000);
}
正常情況下,我們隊這段代碼行為的預期是分別輸出數字1~5,每秒一次,每次一個。
但實際上,這段代碼在運行時會 以每秒一次的頻率輸出五次6。
為什么???
首先解釋6是從哪里來的。這個循環的終止條件是i不再<=5。條件首次成立時,i的值是6。因此,輸出顯示的是循環結束時i的最終值。
延遲函數的回調會在循環結束時才執行。
事實上,當定時器運行時,即使每個迭代中執行的是setTimeout(..., 0),所有的回調函數依然是在循環結束后才會被執行,因為會每次輸出一個6。
這里引申出一個更深入的問題,代碼中到底有什么 缺陷 導致它的 行為同語義 所暗示的不一致?
缺陷是我們試圖假設循環中的每個迭代在運行時都會給自己“捕獲”一個i的副本。
但是根據作用域的工作原理,實際情況是盡管循環中的五個函數是在各個迭代中分別定義的,但它們都 被封閉在一個共享的全局作用域中,因此實際上只有一個i(i在全局作用域中)。
即所有函數共享一個 i 的引用。
應對缺陷,我們需要更多的閉包作用域,特別是在循環的過程中每個迭代都需要一個閉包作用域。
上一節介紹過,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在每次迭代時都創建一個新的作用域。換句話說,每次迭代我們都需要一個塊作用域。
ES6中,let聲明,可以用來劫持塊作用域,并且在這個塊作用域中聲明一個變量。
本質上這是將一個塊轉換成一個可以被關閉的作用域。
cool codes!
for (var i=1; i <= 5; i++) {
let j = i; // ding! 閉包的塊作用域
setTimeout( function timer() {
console.log( j );
}, j*1000);
}
但是,這還不是全部!
for 循環頭部的 let 聲明還會有一個特殊的行為。這個行為指出變量在循環過程中不僅僅被聲明一次,每次迭代都會聲明!隨后的每個迭代都會使用上一個迭代結束時的值來初始化這個變量。
for (let i=1; i <= 5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000);
}
Cool, right???
塊作用域和閉包聯手便所向披靡。反正這個功能讓我成為了一名快樂的javascript程序員。