setTimeout函數之循環和閉包
前言
之前對于setTimeout的一個經典問題的理解總是感到很迷惑,現在好像清晰一點了,所以把我的理解寫下來,我對js的理解也不深入,如果有錯誤,請務必指出。以免誤導其他看到這篇文章的小白。-.
先來點開胃菜
先看看這種很常見的問題吧:
for (var i = 1; i <= 5; i++) {
setTimeout( function timer(){
console.log(i);
},i*1000);
}
上面這個例子來自《你不知道的JavaScript》,相信這種類似的問題也很常見,我最早見到這個例子是在TypeScript的文檔里面,當時就不是很理解,對于輸出的結果也就是強行記憶為“console.log(i)執行的時候i變為6了”,但對于這中間的大致流程卻是十分模糊,以至于我當時錯誤的以為for循環和同步異步有什么關系。
正篇
先說下上面代碼的運行結果:運行時會以每秒一次的頻率輸出五次6.
先拋開為什么結果是五次6這個問題,為什么這個頻率會是每秒一次呢?可能大家剛開始的時候會有這種想法:“setTimeout函數的作用不是推遲執行里面的回調函數嗎?那結果就應該是for循環第一次時延遲一秒輸出1,然后是for循環第二次,延遲兩秒輸出2然后以此類推或者到最后i的值為6所以應該是以6秒為周期循環打印6?”
這里就遇到了第一個坑,對setTimeout函數理解有偏差。
為什么是每秒一次呢?
SF來幫忙
這是我在segmentfault上看到的一個問題。原問題鏈接。請參考第二個回答。
setTimeout的延遲不是絕對精確的;
setTimeout的意思是傳遞一個函數,延遲一段時候把該函數添加到隊列當中,并不是立即執行;
所以說如果當前正在運行的代碼沒有運行完,即使延遲的時間已經過完,該函數會等待到函數隊列中前面所有的函數運行完畢之后才會運行;也就是說所有傳遞給setTimeout的回調方法都會在整個環境下的所有代碼運行完畢之后執行;
觀察下面的代碼:
setTimeout(function(){
console.log("here");
}, 0);
var i = 0;
//具體數值根據你的計算機CPU來決定,達到延遲效果就好
while (i < 3000000000) {
i ++;
}
console.log("test");
試著將上面的代碼運行了遍下,結果為在過了一段時間之后,先打印了test,然后才是here。而且需要注意的是,上面的代碼寫的是setTimeout(..,0),如果按照之前錯誤地將setTimeout函數理解為延遲一段時間執行,那這里把時間賦為0豈不是馬上執行了?而實驗結論則印證了上面"setTimeout的意思是傳遞一個函數,延遲一段時間把該函數添加到隊列中,并不是立即執行“的結論。(涉及到線程,異步,事件循環的知識我現在理解得還不到位,所以暫且不表)
現在再來想想為什么是每秒一次
再回到最初的那個問題,剛進入for循環的時候,i為1,所以相對于現在延遲一秒將timer函數添加到隊列當中,然后for循環還要繼續啊,并沒有等一秒再繼續循環啊,然后進行第二次循環,這時候i為2,所以相對于現在延遲兩秒將timer函數送進隊列。以此類推。for循環的時間忽略不計的話,timer函數就以每秒一次的頻率執行啦。
為什么每次都顯示6呢?
這個問題我個人覺得與異步和閉包都有關系。
首先和異步的關系上文已經說了。
和閉包的關系
先要清楚,什么是閉包?過去我也把閉包和立即執行函數錯誤的混為一談,看著立即執行函數表達式的括號我就天真地以為:用括號把函數包裹起來,這不就是”閉“包嗎?
《你不知道的JavaScript》書中,對閉包的解釋大概是這樣的:對函數類型的值進行傳遞時,保留對它被聲明的位置所處的作用域的引用。
也許上面這句話我總結得比較晦澀,但原書對這個問題解釋得要清晰一些,可以看看原書47頁。
那timer函數是在setTimeout函數中被聲明的吧?在執行timer函數中的console.log(i)的時候,這個i是多少呢?在timer函數中沒有i的聲明啊。那就繼續向外層的作用域找,終于在全局作用域下找到了i為多少了。
var的疑問
再來看看那個for循環,for(var i = 1; i <= 5; i++){...}
,在這里其實隱含著函數作用域和塊作用域的的陷阱。在這段代碼中用var聲明的變量i的作用域在哪呢?是在當前作用域還是{}所包裹的內部呢?其實我們只要明確剛才這段代碼相當于下面的代碼就清除i的作用域在哪了。
var i;
for(i = 1; i <= 5; i++)
這就是每次的輸出都是6的原因
所以,當timer函數第一次執行的時候,在執行console.log(i)的時候,這個時候的i其實是全局作用域下的i,這個時候循環是已經結束了,這時候i為6.(再次提醒不要錯誤地認為要等timer函數執行之后才會繼續循環,再看看什么是異步);
那么問題來了
那么,怎么改動上面的代碼讓結果依次為1,2,3,4,5呢?最簡單的辦法就是將var改為let,原因是let創建了塊作用域。(具體是怎么回事暫且不表,可以用babel將ES6轉換為ES5查看結果。但是原理和下面要講的類似)
所以,再想想為什么會每次的輸出都是6呢?是因為每次執行到console.log(i)的時候這個i是全局作用域下的i啊,那怎么才能讓這個i為每次循環時的i呢?即怎么才能在每次循環時”捕獲“到i的副本呢
不要急,先來看看為什么可以用立即執行函數表達式。
所以下面的代碼有用嗎?
for (var i = 1; i <= 5; i++) {
(function() {
setTimeout( function timer() {
console.log(i);
},i*1000 );
})();
}
上面這個例子同樣是來自《你不知道的JavaScript》。我以前錯誤地認為,立即執行函數表達式,這是立即執行啊,所以里面的timer也立即執行了,所以就能輸出1,2,3,4,5了。
先說答案,這樣當然是不行的,這里的立即執行也只是立即執行了setTimeout函數,而setTimeout函數的作用也就是將timer函數延遲一段時間添加到隊列,所以這個立即執行表達式在這里有沒有都一樣。我之前錯誤的想法也是受到了”立即執行“這四個字的誤導。先來看看一個正確答案:
for (var i = 1; i <= 5; i++) {
(function() {
var j = i;
setTimeout( function timer() {
console.log(j);
},i*1000 ); //這一行將i*1000改為j*1000也行,并不影響
})();
}
發現這個答案和上面的錯誤答案的區別了嗎?其實我們是用立即執行函數表達式創造了新的函數作用域將timer函數包裹了起來,并用j捕獲了每次循環時的i,這樣在運行到console.log(j)的時候顯示的就是每次循環時的i值啦。
同理還有這樣的寫法:
for (var i = 1; i <= 5; i++) {
let j = i;
setTimeout(function timer() {
console.log(j);
},j*1000);
}
還有一些其他寫法這里就不一一列舉了,原理都是和作用域相關。其實上面這個涉及到let的例子和塊作用域相關,這里就不展開了。
總結
異步決定了這段代碼打印i的頻率,閉包和作用域的知識決定了這個i是多少以及怎樣改寫這段代碼。
總覺得這篇文章還有一些欠缺,希望大家能指正。 uuu