從setTimeout理解JS執行機制

番茄沙司發表於2019-03-22

setTimeout()函式:用來指定某個函式或某段程式碼在多少毫秒之後執行。它返回一個整數,表示定時器timer的編號,可以用來取消該定時器。

例子

console.log(1);
setTimeout(function () {
    console.log(2);
}, 0);
console.log(3);
複製程式碼

問:最後的列印順序是什麼?(如果不瞭解js的執行機制就會答錯)

正確答案:1 3 2

解析:無論setTimeout的執行時間是0還是1000,結果都是先輸出3後輸出2,這就是面試官常常考查的js執行機制的問題,接下來我們要引入一個概念,JavaScript 是單執行緒的。

JavaScript 單執行緒

JavasScript引擎是基於事件驅動和單執行緒執行的,JS引擎一直等待著任務佇列中任務的到來,然後加以處理,瀏覽器無論什麼時候都只有一個JS執行緒在執行程式,即主執行緒

通俗的說:JS在同一時間內只能做一件事,這也常被稱為 “阻塞式執行”。

任務佇列

那麼單執行緒的JavasScript是怎麼實現“非阻塞執行”呢?

答:非同步容易實現非阻塞,所以在JavaScript中對於耗時的操作或者時間不確定的操作,使用非同步就成了必然的選擇。

諸如事件點選觸發回撥函式、ajax通訊、計時器這種非同步處理是如何實現的呢?

答:任務佇列

所有任務可以分成兩種,一種是同步任務(synchronous),另一種是非同步任務(asynchronous)。

任務佇列:一個先進先出的佇列,它裡面存放著各種事件和任務。

同步任務

同步任務:在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務。

  • 輸出 如:console.log()
  • 變數的宣告
  • 同步函式:如果在函式返回的時候,呼叫者就能夠拿到預期的返回值或者看到預期的效果,那麼這個函式就是同步的。

非同步任務

  • setTimeout和setInterval
  • DOM事件
  • Promise
  • process.nextTick
  • fs.readFile
  • http.get
  • 非同步函式:如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。

除此之外,任務佇列又分為macro-task(巨集任務)與micro-task(微任務),在ES5標準中,它們被分別稱為task與job。

巨集任務

  1. I/O
  2. setTimeout
  3. setInterval
  4. setImmdiate
  5. requestAnimationFrame

微任務

  1. process.nextTick
  2. Promise
  3. Promise.then
  4. MutationObserver

巨集任務和微任務的執行順序

一次事件迴圈中,先執行巨集任務佇列裡的一個任務,再把微任務佇列裡的所有任務執行完畢,再去巨集任務佇列取下一個巨集任務執行。

注:在當前的微任務沒有執行完成時,是不會執行下一個巨集任務的。

setTimeout執行機制

setTimeout 和 setInterval的執行機制是將指定的程式碼移出本次執行,等到下一輪 Event Loop 時,再檢查是否到了指定時間。如果到了,就執行對應的程式碼;如果不到,就等到再下一輪 Event Loop 時重新判斷。

這意味著,setTimeout指定的程式碼,必須等到本次執行的所有同步程式碼都執行完,才會執行。

優先關係:非同步任務要掛起,先執行同步任務,同步任務執行完畢才會響應非同步任務。

進階題

console.log('A');
setTimeout(function () {
    console.log('B');
}, 0);
while (1) {}
複製程式碼

大家再猜一下這段程式輸出的結果會是什麼?

答:A

注:建議先註釋掉while迴圈程式碼塊的程式碼,執行後強制刪除程式,不然會造成“假死”。

同步佇列輸出A之後,陷入while(true){}的死迴圈中,非同步任務不會被執行。

類似的,有時addEventListener()方法監聽點選事件click,使用者點了某個按鈕會卡死,就是因為當前JS正在處理同步佇列,無法將click觸發事件放入執行棧,不會執行,出現“假死”。

定時獲取介面更新資料

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

輸出結果為,隔1s後一起輸出:4 4 4 4

for迴圈是一個同步任務,為什麼連續輸出四個4?

答:因為有佇列插入的時間,即使執行時間從1000改成0,還是輸出四個4。

那麼這個問題是如何產生和解決的呢?請接著閱讀

非同步佇列執行的時間

執行到非同步任務的時候,會直接放到非同步佇列中嗎? 答案是不一定的。

因為瀏覽器有個定時器(timer)模組,定時器到了執行時間才會把非同步任務放到非同步佇列

for迴圈體執行的過程中並沒有把setTimeout放到非同步佇列中,只是交給定時器模組了。4個迴圈體執行速度非常快(不到1毫秒)。定時器到了設定的時間才會把setTimeout語句放到非同步佇列中。

即使setTimeout設定的執行時間為0毫秒,也按4毫秒算

這就解釋了上題為什麼會連續輸出四個4的原因。

HTML5 標準規定了setTimeout()的第二個引數的最小值,即最短間隔,不得低於4毫秒。如果低於這個值,就會自動增加。在此之前,老版本的瀏覽器都將最短間隔設為10毫秒。

利用閉包實現 setTimeout 間歇呼叫

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

執行後,會隔1s輸出一個值,分別是:0 1 2 3

  • 此方法巧妙利用IIFE宣告即執行的函式表示式來解決閉包造成的問題。
  • 將var改為let,使用了ES6語法。

這裡也可以用setInterval()方法來實現間歇呼叫。

詳見:setTimeout和setInterval的區別

利用JS中基本型別的引數傳遞是按值傳遞的特徵實現

var output = function (i) {
    setTimeout(function () {
        console.log(i);

    }, 1000 * i)
}
for (let i = 0; i < 4; i++) {
    output(i);
}
複製程式碼

執行後,會隔1s輸出一個值,分別是:0 1 2 3

實現原理:傳過去的i值被複制了。

基於Promise的解決方案

const tasks = [];

const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(i);
        resolve();
    }, 1000 * i);

});

//生成全部的非同步操作
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}
//同步操作完成後,輸出最後的i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(i);
    }, 1000)
})
複製程式碼

執行後,會隔1s輸出一個值,分別是:0 1 2 3 4 5

優點:提高了程式碼的可讀性。

注意:如果沒有處理Promise的reject,會導致錯誤被丟進黑洞。

使用ES7中的async await特性的解決方案(推薦)

const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});

(async () => { //宣告即執行的async
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(i);
    }

    await sleep(1000);
    console.log(i);

})();
複製程式碼

執行後,會隔1s輸出一個值,分別是:0 1 2 3 4 5

事件迴圈 Event Loop

圖解Event Loop

主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop

有時候 setTimeout明明寫的延時3秒,實際卻5,6秒才執行函式,這又是因為什麼?

答:setTimeout 並不能保證執行的時間,是否及時執行取決於 JavaScript 執行緒是擁擠還是空閒

瀏覽器的JS引擎遇到setTimeout,拿走之後不會立即放入非同步佇列,同步任務執行之後,timer模組會到設定時間之後放到非同步佇列中。js引擎發現同步佇列中沒有要執行的東西了,即執行棧空了就從非同步佇列中讀取,然後放到執行棧中執行。所以setTimeout可能會多了等待執行緒的時間。

這時setTimeout函式體就變成了執行棧中的執行任務,執行棧空了,再監聽非同步佇列中有沒有要執行的任務,如果有就繼續執行,如此迴圈,就叫Event Loop。

總結

JavaScript通過事件迴圈和瀏覽器各執行緒協調共同實現非同步。同步可以保證順序一致,但是容易導致阻塞;非同步可以解決阻塞問題,但是會改變順序性。

知識點梳理:

  • 理解JS的單執行緒的概念:一段時間內做一件事
  • 理解任務佇列:同步任務、非同步任務
  • 理解 Event Loop
  • 理解哪些語句會放入非同步任務佇列
  • 理解語句放入非同步任務佇列的時機

最後,希望大家閱後有所收穫。?

相關文章