從for迴圈中的定時器說開去,閉包和非同步的一點事兒

聖槍遊俠發表於2019-03-19

在之前的開發過程中遇到這樣的場景:

頁面中有幾個功能區或者說模組,他們每個都有一個進度條,在頁面載入時會請求資料來渲染這幾個進度條,使之獨立展示不同的工程進度,於是在一個for迴圈中給每個進度條繫結了一個定時器setInterval,期待它可以實現預期的效果。然而實際效果出乎意料,只有最後一個定時器實現了渲染正確資料的功能,前面的進度為0。

說到這裡,很多小夥伴可能已經猜到這裡面大致發生了什麼事情。這就是今天要說的,當for迴圈遇到了定時器,究竟發生了什麼事,又和非同步、閉包有什麼關係。

首先,上述場景,可以簡單抽象為下面的一段程式碼:

for(var i=0;i<4;i++){
    setTimeout(function(){
       console.log(i)
       },1000)
}
//4444複製程式碼

有js基礎的小夥伴一定會一眼看出這段程式碼的執行結果,那就是接連列印4個4。

這裡牽涉到js的執行機制,簡單的做個說明:

1.js是單執行緒(single threaded)語言,瀏覽器只分配給js一個主執行緒,用來執行任務(函式),但一次只能執行一個任務。這就形成了一個執行棧(execution context stack)。

2.js的宿主環境(比如瀏覽器,Node)是多執行緒的,這就使得js具備了非同步(asynchronous)的屬性。為什麼要有非同步屬性?簡單說就是主執行緒任務排隊執行,如果某些事件消耗時間過長,處理效率低下不說,還會導致頁面卡頓,所以要開闢“第二戰線”。

3.哪些事件是非同步的?比如網路請求,定時器和事件監聽,這些都是非常耗時的。他們都被放到了非同步佇列中。這就形成了"任務佇列"(task queue)。我們今天的主角,定時器,就在其中。

由於定時器是非同步任務,按照js的事件執行機制,主執行緒即for迴圈,建立了四個定時器1234,他們所列印的i,由於主執行緒已經結束,i=4,所以自然而然列印了4個4出來。

如果我們希望有序列印0123這種不同的i值怎麼辦呢?

解決辦法是,引入閉包來儲存變數。

閉包小朋友頓時一臉懵逼,for迴圈和定時器好端端的,怎麼就跟我閉包搭上關係了呢?

           從for迴圈中的定時器說開去,閉包和非同步的一點事兒

是的,你沒聽錯,中央欽定了你來處理這件事。。。咳,嚴肅嚴肅,來看看怎麼回事。

我們將程式碼改進如下:

for (var i = 0; i < 4; i++) {
    (function(a) {//閉包
        setTimeout(function() {
            console.log(a);//操縱變數a,和i無關
        }, 3000);
    })(i) 
}
//0123

複製程式碼

上面程式碼將定時器放入一個自呼叫函式中,自呼叫函式傳入了for迴圈的i作為實參賦予形參a,所以定時器列印這個a就拿到了每一個i值0,1,2,3。很多博文也將這種自呼叫函式叫做“立即執行函式”,其實是一回事。

為什麼用一個自呼叫函式就能拿到每個i的值了呢?仔細觀察可以發現,這其實是閉包在發揮作用。

在這裡,for迴圈裡定義的i變數其實暴露在全域性作用域內,於是5個定時器裡的匿名函式它們其實共享了同一個作用域裡的同一個變數。所以如果想要0,1,2,3,4的結果,就要在每次迴圈的時候,把當前的i值單獨存下來,而我們知道,閉包的特點是可以儲存資料,延長作用域鏈,匿名函式生成了閉包的效果,新建了一個作用域,這個作用域接收到每次迴圈的i值儲存了下來,即使迴圈結束,閉包形成的作用域也不會被銷燬。這就是每個i值能被單獨儲存下來的原因。

上面程式碼還可以改寫成下面的模樣,這也是比較常見的一種寫法。

for (var i = 0; i < 4; i++) {
    setTimeout(fn(i), 3000);}

function fn(a){
   return function(){
      console.log(a);
   }
}複製程式碼

到了ES6中,我們就不用這麼麻煩了

使用let關鍵字宣告for迴圈的i變數,不需要藉助閉包,即可實現上述函式效果。

for(let i = 0; i < 4; i++) {
    setTimeout(function () {
        console.log(i);
    });
}
//0,1,2,3複製程式碼

我們知道,let關鍵字是ES6中一個相當大的變化,使用let關鍵字宣告變數,克服了之前使用var宣告的記憶體洩漏、全域性汙染等問題。

更重要的是,let可以和{ }程式碼塊結合形成塊級作用域。在for迴圈中使用let宣告計數變數i,i 只在本輪迴圈有效,相當於每一輪都會重新宣告一個 i。而且JS引擎會記住上一輪的 i,隨後的每個迴圈都會使用上一個迴圈結束時的值來初始化這個變數i。

所以在for迴圈中使用let是相當不錯的選擇。

說完了setTimeout,我們再來說說他的孿生兄弟,setInterval

這倆兄弟其實非常相似,只不過一個是一次性的,一個是迴圈執行。

通常我們使用setInterval時,傳遞的引數都是兩個,一個是回撥函式callback,一個是延遲或者間隔時間delay,事實上,它是可以傳遞多個引數的,舉例如下:

setInterval(function(msg1,msg2,...){},1000,'回撥引數1','回撥引數2',...);複製程式碼

定時器延遲時間delay後面的引數,都會作為前面回撥函式的實參傳入。利用這個特點,我們結合前面的閉包和非同步的話題,稍加延伸。

當在for迴圈中傳入計數引數i給定時器的回撥函式,會發生什麼事情?

下面這段程式碼,列印結果如何呢?

function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i) {
            console.log(i);
        }, 1000, i);
     }
}
//列印結果 012301230123迴圈複製程式碼

為什麼會列印出這個結果呢?

簡單說來,這仍然是一個閉包,這個閉包形成的關鍵,就是for迴圈計數引數i作為定時器定時器的回撥函式實參,傳入了回撥函式。從這個角度看,這仍然是一個典型的閉包,即內部函式拿到了外部函式中定義的變數,並且一旦拿到,這個變數就會被儲存。

for迴圈建立四個定時器後,主執行緒結束,開始處理非同步任務,此時四個定時器1234處於“任務佇列”(task queue)中,主執行緒空閒,非同步任務立即被推入主執行緒開始處理(這只是大致過程,實際過程還可以細分,這裡不再贅述)。此時四個定時器各自儲存了一個i值分別是0123,他們遵循先後順序,每隔一秒各自列印自己儲存的i值。這就是上面結果的由來。

至此,小夥伴們是不是已經明白了for迴圈、定時器相結合時發生了什麼事情呢?相信你已經小雞啄米般點頭,“懂了”“懂了”。好的,那,

來一波騷操作,當我們在列印 i 值後面立即清除定時器,會發生什麼事情?

從for迴圈中的定時器說開去,閉包和非同步的一點事兒

程式碼如下:

function fn2() {
    for (var i = 0; i < 4; i++) {
        var tc = setInterval(function (i,tc) {
            console.log(i);
            clearInterval(tc)
        }, 1000, i,tc);
     }
}
//列印結果:0123333333   3無限迴圈複製程式碼

????發生了什麼事情??剛剛建立的定時器大廈好像出現了一絲顫動。但是,這好像又是常規操作,定時器總是要清的,至於在哪清,那是另一回事了。

很多同學一看,這不和上面剛說的i形成閉包的原理一樣嘛,tc傳入作為實參,所以每個tc都被清除了,但是最後一個沒被清除是怎麼回事??

這裡更多的是考察對js執行機制和運算的理解。

定時器是有id的,在這裡依次為1234 。當第一個定時器 tc=1 開始執行時,列印i為0,接著清除定時器,問題來了,這個tc,是哪個tc?這就是整個問題的關鍵。

事實上,  = 是賦值運算子,先計算右邊,右邊計算時,這個tc值是多少呢?是  =  左邊剛剛新鮮熱辣var出來的tc1嗎?答案顯然是否定的。如果我們此時列印檢視這個tc值,會發現,它是

undefined。

原因很簡單,此時還不存在左邊的tc,在整個作用域內,找不到tc可清理。

接下來就簡單了,當第二個定時器開始執行時,情況有變,此時 i=1,清理定時器tc,這個tc是哪個?很顯然,它是第一個 tc=1

至此,水落石出,當第四個定時器 tc=4 開始工作時, i=3 ,清除的是上一個定時器 tc=3  ,而它本身,沒有下一個定時器清除了,所以它會一直列印3。


本文由一個工程實際問題,抽象出函式模型,目的是探討一些在for迴圈中定時器帶來的一些問題和現象。其中涉及到事件迴圈(Event Loop)的部分簡單帶過,說的可能不夠嚴謹,建議有興趣的童鞋參閱相關文章做更全面的瞭解。文中如有錯誤,懇請指正。




相關文章