for循環裏的定時器引起的思考

在學習js的時候,或者面試的時候,會常常碰到這一道經典題目:面試

for(var i = 0; i < 5; i++) {
    setTimeout(function () {
        console.log(i);
    });
}
console.log('a');

熟悉這道題目的人立馬就能夠說出答案:瀏覽器

'a'
5
5
5
5
5

結果是先打印字符串'a',而後再打印5個數字5。網絡

有人會說這個題目並不難,並且只要你遇到過這個題目,下次再見到基本也不會答錯了,但其實這段簡單的代碼裏面包含了不少js知識。多線程

這裏就整理總結一下。閉包

單線程、任務隊列以及事件循環(event loop)異步

第一次看到這段代碼的時候,會給人一種錯覺async

  1. 會先打印for循環裏面的5次i值,而後纔會去打印下面的字符串'a'
  2. for循環裏面的打印結果會是0,1,2,3,4,而不是什麼5個5這種奇怪的結果

可是實際運行結果跟咱們預期的不同,緣由就是由於這裏涉及到了js的運行機制函數

單線程oop

JavaScript語言的一大特色就是單線程,也就是說,同一個時間只能作一件事學習

爲何不容許js能夠實現多線程?由於若是實現了多線程,一個線程建立了一個div元素,而另一個線程刪除了這個div元素,那麼這個時候瀏覽器應該聽誰的?

因此爲了不出現這種互相沖突的操做,js從一開始就是單線程的,這就是它的核心特徵。

任務隊列

單線程就意味着,全部任務須要排隊,前一個任務結束,纔會執行後一個任務。若是前一個任務耗時很長,後一個任務就不得不一直等着。

若是排隊是由於計算量大,CPU忙不過來,倒也算了,可是不少時候CPU是閒着的,由於IO設備(輸入輸出設備)很慢(好比Ajax操做從網絡讀取數據),不得不等着結果出來,再往下執行。

JavaScript語言的設計者意識到,這時主線程徹底能夠無論IO設備,掛起處於等待中的任務,先運行排在後面的任務。等到IO設備返回告終果,再回過頭,把掛起的任務繼續執行下去

因而,全部任務能夠分紅兩種,一種是同步任務(synchronous),另外一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務能夠執行了,該任務纔會進入主線程執行。

  1. 全部同步任務都在主線程上執行,造成一個執行棧(execution context stack)。
  2. 主線程以外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
  3. 一旦"執行棧"中的全部同步任務執行完畢,系統就會讀取"任務隊列",看看裏面有哪些事件。那些對應的異步任務,因而結束等待狀態,進入執行棧,開始執行。
  4. 主線程不斷重複上面的第三步。

只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重複。

"任務隊列"中的事件,除了IO設備的事件之外,還包括一些用戶產生的事件(好比鼠標點擊、頁面滾動等等)。只要指定過回調函數,這些事件發生時就會進入"任務隊列",等待主線程讀取。

事件循環(event loop)

主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,因此整個的這種運行機制又稱爲Event Loop(事件循環)。

主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各類外部API,它們在"任務隊列"中加入各類事件(click,load,done)。只要棧中的代碼執行完畢,主線程就會去讀取"任務隊列",依次執行那些事件所對應的回調函數。

定時器

在瞭解了剛纔那些知識以後,再回過頭來看看這段代碼:

for(var i = 0; i < 5; i++) {
    setTimeout(function () {
        console.log(i);
    });
}
console.log('a');

爲何明明定時器的時間設置爲了0(setTimeout不寫延遲時間參數默認值爲0)?定時器卻在console.log('a')這句代碼運行了以後才運行?

原來在js的任務隊列裏,除了放置異步操做以外,還會放置定時器事件。

當js代碼運行到有定時器的地方的時候,會把定時器操做放在任務隊列尾部,而後跟它說:「你先排隊吧,尚未輪到你,由於同步代碼尚未執行完。」

這裏所說的 同步代碼 就是指下面的console.log('a')。

也就是說,js認爲setTimeout是一個異步操做,必須讓它排隊,它只能在同步代碼執行結束後才能執行

因此這裏的緣由總結就是這樣一句話:

定時器並非同步的,它會自動插入任務隊列,等待當前文件的全部同步代碼當前任務隊列裏的已有事件所有運行完畢後才能執行。

這就是爲何字符串'a'在5個5以前就打印出來的緣由。

那麼爲何是5個5呢?爲何不是0,1,2,3,4?

這是由於在全部同步代碼執行完畢以後,for循環裏的i值早已變成了5,循環已經結束。(注意,for循環的圓括號部分也是同步代碼

這就是爲何打印出來5個5,而不是0,1,2,3,4。

因此這段代碼真實的運行狀況你能夠假想成這樣,便於理解:

for(var i = 0; i < 5; i++) {
    
}
console.log('a');

setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
//先循環,i變成了5,而後打印a,而後再打印5次i
//這裏只是假想,便於理解

做用域和閉包

這道題目還會引伸出來另外一個問題:

若是想要for循環裏的定時器打印出0,1,2,3,4,而不是5個5,該怎麼辦?

答案是:使用當即執行函數

for(var i = 0; i < 5; i++) {
    (function(i) {
        setTimeout(function () {
            console.log(i);
        });
    })(i)
}
console.log('a');

打印結果:

'a'
0
1
2
3
4

這又是爲何?

這是由於for循環裏定義的i變量其實暴露在全局做用域內,因而5個定時器裏的匿名函數它們其實共享了同一個做用域裏的同一個變量。

因此若是想要0,1,2,3,4的結果,就要在每次循環的時候,把當前的i值單獨存下來,怎麼存下當前的循環i值??

利用閉包的原理,閉包使一個函數能夠繼續訪問它定義時的做用域。而這個新生成的做用域將每一次循環的當前i值單獨保存了下來。

for(var i = 0; i < 5; i++) {
    (function(i) {//這個匿名函數生成了閉包的效果,新建了一個做用域,這個做用域接收到每次循環的i值保存了下來,即便循環結束,閉包造成的做用域也不會被銷燬
        setTimeout(function () {
            console.log(i);
        });
    })(i)
}    

let關鍵字、塊做用域以及try...catch語句

若是想實現for循環裏的定時器打印出0,1,2,3,4,除了閉包,還可使用ES6的let關鍵字

for(let i = 0; i < 5; i++) {
    setTimeout(function () {
        console.log(i);
    });
}

注意for循環定義i的時候把var換成了let,打印出的結果就是0,1,2,3,4

這是問什麼呢?

由於let關鍵字劫持了for循環的塊做用域,產生了相似閉包的效果。而且在for循環中使用let來定義循環變量還會有一個特殊效果:每一次循環都會從新聲明變量i,隨後的每一個循環都會使用上一個循環結束時的值來初始化這個變量i

let能夠實現塊做用域的效果,可是它是ES6語法,在低版本語法的時候如何生成塊做用域?

答案是:使用try...catch語句

看下面的效果:

for(var i = 0; i < 5; i++) {
    try {
        throw(i)
    } catch(j) {
        setTimeout(function () {
            console.log(j);
        });
    }
}

//打印結果0,1,2,3,4

神奇的效果出現了!

這是由於try...catch語句的catch後面的花括號是一個塊做用域,和let的效果同樣。因此在try語句塊裏拋出循環變量i,而後在catch的塊做用域裏接收到傳過來的i,就能夠將循環變量保存下來,實現相似閉包和let的效果。

 

好了,這就是關於這道面試題涉及到的知識。

相關文章
相關標籤/搜索