淺析setTimeout與Promise

前端魔法師發表於2018-08-12

關於JavaScript非同步程式設計,前文解析過了JavaScript併發模型,該併發模型基於事件迴圈。正巧又在Stackoverflow上回答了一個關於setTimeout與Promise執行順序相關的問題,於是總結這一知識點,與更多讀者分享,同時完善JavaScript非同步程式設計系列文章。

我的個人部落格

前言

我們先看一到常見的前端面試題:

var p1 = new Promise(function(resolve, reject){
    resolve(1);
})
setTimeout(function(){
  console.log("will be executed at the top of the next Event Loop");
},0)
p1.then(function(value){
  console.log("p1 fulfilled");
})
setTimeout(function(){
  console.log("will be executed at the bottom of the next Event Loop");
},0)
複製程式碼

上例程式碼執行輸出順序如何?這道題也是本文創作的源泉,其答案是:

p1 fulfilled
will be executed at the top of the next Event Loop
will be executed at the bottom of the next Event Loop
複製程式碼

接下來展開解釋輸出結果原因,看完本文應該能瞭解setTimeout和Promise的區別。

事件迴圈

事件迴圈相關詳細內容在JavaScript非同步程式設計一文已經介紹過,本文不再贅述,進行一些補充和總結:

事件迴圈

可執行程式碼

思考一下,JavaScript程式碼是如何執行的呢?是一行一行程式碼執行的嗎?當然不是,JavaScript 引擎一塊一塊地解析,執行JavaScript程式碼,而非一行一行進行。在解析,執行程式碼塊時,會需要有一個前期工作,如變數/函式提升,定義變數/函式。這裡所說的程式碼塊,通常稱作可執行程式碼(execuable code),通常包括全域性程式碼,函式程式碼,eval執行程式碼。而所做的前期工作就是建立執行上下文(execution context)。

執行上下文棧

每當JavaScript引擎開始執行應用程式時,都會建立一個執行上下文棧(後進先出),用以管理執行上下文。在執行一段可執行程式碼時,會建立一個執行上下文,然後將其壓入棧,執行完畢便將該上下文退棧。

function funA() {
    console.log('funA')
}

function funB() {
    fun3A();
}

function funC() {
    funB();
}

funC();
複製程式碼
ECStack.push(<funC> functionContext);

// funC中呼叫funB,需建立funB執行上下文,入棧
ECStack.push(<funB> functionContext);

// funB內呼叫funA,入棧上下文
ECStack.push(<funA> functionContext);

// funA執行完畢,退棧
ECStack.pop();

// funB執行完畢,退棧
ECStack.pop();

// funC執行完畢,退棧
ECStack.pop();

// javascript繼續執行後續程式碼
複製程式碼

另外,所有的程式碼都是從全域性環境開始執行,所以,必然棧底是全域性執行上下文。

非同步任務

回顧JavaScript事件迴圈併發模型,我們瞭解了setTimeoutPromise呼叫的都是非同步任務,這一點是它們共同之處,也即都是通過任務佇列進行管理/排程。那麼它們有什麼區別嗎?下文繼續介紹。

任務佇列

前文已經介紹了任務佇列的基礎內容和機制,可選擇檢視,本文對任務佇列進行擴充介紹。JavaScript通過任務佇列管理所有非同步任務,而任務佇列還可以細分為MacroTask Queue和MicoTask Queue兩類。

MacroTask Queue

MacroTask Queue(巨集任務佇列)主要包括setTimeout, setInterval, setImmediate, requestAnimationFrame, UI rendeing, NodeJS中的`I/O等。

MicroTask Queue

MicroTask Queue(微任務佇列)主要包括兩類:

  1. 獨立回撥microTask:如Promise,其成功/失敗回撥函式相互獨立;
  2. 複合回撥microTask:如 Object.observe, MutationObserver 和NodeJs中的 process.nextTick ,不同狀態回撥在同一函式體;

MacroTask和MicroTask

JavaScript將非同步任務分為MacroTask和MicroTask,那麼它們區別何在呢?

  1. 依次執行同步程式碼直至執行完畢;
  2. 檢查MacroTask 佇列,若有觸發的非同步任務,則取第一個並呼叫其事件處理函式,然後跳至第三步,若沒有需處理的非同步任務,則直接跳至第三步;
  3. 檢查MicroTask佇列,然後執行所有已觸發的非同步任務,依次執行事件處理函式,直至執行完畢,然後跳至第二步,若沒有需處理的非同步任務中,則直接返回第二步,依次執行後續步驟;
  4. 最後返回第二步,繼續檢查MacroTask佇列,依次執行後續步驟;
  5. 如此往復,若所有非同步任務處理完成,則結束;

task Queue

The microTask queue is processed after callbacks as long as no other JavaScript is mid-execution, and at the end of each task. 只要沒有其他JavaScript程式碼在執行,並且在每個任務結束時,就會開始處理microTask佇列。

需要注意的是,此處說的的每個任務結束時中的任務通常就是指macroTask,有一個比較特殊的任務- 指令碼執行(JavaScript Run,也是一個macroTask,會在JavaScript指令碼執行時,立即將JavaScript Run任務入棧macroTask佇列。

回顧

本文內容介紹基本結束,那麼前文第一個題目輸出順序是為什麼呢?簡單解釋一下:

  1. 開始執行JavaScript指令碼,將任務JavaScript Run入棧macroTask佇列;
  2. 同步resolvePromise後;
  3. 入棧第一個setTimeout任務進入macroTask佇列
  4. 入棧Proimse.then任務進入microTask佇列;
  5. 入棧第二個setTimeout任務進入macroTask佇列;
  6. 同步執行程式碼完畢,退出第一個macroTask,即JavaScript Run;
  7. 執行清空microTask;
  8. 執行下一個macroTask;

最後,我們以一個題目再次回顧一下內容:

setTimeout(function(){
  console.log("will be executed at the top of the next Event Loop")
},0)
var p1 = new Promise(function(resolve, reject){
    setTimeout(() => { resolve(1); }, 0);
});
setTimeout(function(){
    console.log("will be executed at the bottom of the next Event Loop")
},0)
for (var i = 0; i < 100; i++) {
    (function(j){
        p1.then(function(value){
           console.log("promise then - " + j)
        });
    })(i)
}

複製程式碼

程式碼輸出結果是什麼呢?快點確認一下吧:

will be executed at the top of the next Event Loop
promise then - 0
promise then - 1
promise then - 2
...
promise then - 99
will be executed at the bottom of the next Event Loop
複製程式碼
  1. 首先同步執行完所有程式碼,其間註冊了三個setTimeout非同步任務,100個Promise非同步任務;
  2. 然後檢查MacroTask佇列,取第一個到期的MacroTask,執行輸出will be executed at the top of the next Event Loop;
  3. 然後檢查MicroTask佇列,發現沒有到期的MicroTask,進入第4步;
  4. 再次檢查MacroTask,執行第二個setTimeout處理函式,resolve Promise;
  5. 然後檢查MicroTask佇列,發現Promise已解決,其非同步處理函式均可執行,依次執行,輸出promise then - 0promise then - 99
  6. 最後再次檢查MacroTask佇列,執行輸出will be executed at the bottom of the next Event Loop
  7. 交替往復檢查兩個非同步任務佇列,直至執行完畢;

相關文章