關於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事件迴圈併發模型,我們瞭解了setTimeout
和Promise
呼叫的都是非同步任務,這一點是它們共同之處,也即都是通過任務佇列進行管理/排程。那麼它們有什麼區別嗎?下文繼續介紹。
任務佇列
前文已經介紹了任務佇列的基礎內容和機制,可選擇檢視,本文對任務佇列進行擴充介紹。JavaScript通過任務佇列管理所有非同步任務,而任務佇列還可以細分為MacroTask Queue和MicoTask Queue兩類。
MacroTask Queue
MacroTask Queue(巨集任務佇列)主要包括setTimeout
, setInterval
, setImmediate
, requestAnimationFrame
, UI rendeing
, NodeJS中的`I/O等。
MicroTask Queue
MicroTask Queue(微任務佇列)主要包括兩類:
- 獨立回撥microTask:如Promise,其成功/失敗回撥函式相互獨立;
- 複合回撥microTask:如
Object.observe
,MutationObserver
和NodeJs中的process.nextTick
,不同狀態回撥在同一函式體;
MacroTask和MicroTask
JavaScript將非同步任務分為MacroTask和MicroTask,那麼它們區別何在呢?
- 依次執行同步程式碼直至執行完畢;
- 檢查MacroTask 佇列,若有觸發的非同步任務,則取第一個並呼叫其事件處理函式,然後跳至第三步,若沒有需處理的非同步任務,則直接跳至第三步;
- 檢查MicroTask佇列,然後執行所有已觸發的非同步任務,依次執行事件處理函式,直至執行完畢,然後跳至第二步,若沒有需處理的非同步任務中,則直接返回第二步,依次執行後續步驟;
- 最後返回第二步,繼續檢查MacroTask佇列,依次執行後續步驟;
- 如此往復,若所有非同步任務處理完成,則結束;
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佇列。
回顧
本文內容介紹基本結束,那麼前文第一個題目輸出順序是為什麼呢?簡單解釋一下:
- 開始執行JavaScript指令碼,將任務
JavaScript Run
入棧macroTask佇列; - 同步resolvePromise後;
- 入棧第一個setTimeout任務進入macroTask佇列
- 入棧Proimse.then任務進入microTask佇列;
- 入棧第二個setTimeout任務進入macroTask佇列;
- 同步執行程式碼完畢,退出第一個macroTask,即
JavaScript Run
; - 執行清空microTask;
- 執行下一個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
複製程式碼
- 首先同步執行完所有程式碼,其間註冊了三個setTimeout非同步任務,100個Promise非同步任務;
- 然後檢查MacroTask佇列,取第一個到期的MacroTask,執行輸出
will be executed at the top of the next Event Loop
; - 然後檢查MicroTask佇列,發現沒有到期的MicroTask,進入第4步;
- 再次檢查MacroTask,執行第二個setTimeout處理函式,resolve Promise;
- 然後檢查MicroTask佇列,發現Promise已解決,其非同步處理函式均可執行,依次執行,輸出
promise then - 0
至promise then - 99
; - 最後再次檢查MacroTask佇列,執行輸出
will be executed at the bottom of the next Event Loop
- 交替往復檢查兩個非同步任務佇列,直至執行完畢;