阮老師在其推特上放了一道題:
new Promise(resolve => { resolve(1); Promise.resolve().then(() => console.log(2)); console.log(4) }).then(t => console.log(t)); console.log(3);
看到此處的你可以先猜測下其答案,然後再在瀏覽器的控制檯運行這段代碼,看看運行結果是否和你的猜測一致。
事件循環
衆所周知,JavaScript 語言的一大特點就是單線程,也就是說,同一個時間只能做一件事。根據 HTML 規範:
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.
爲了協調事件、用戶交互、腳本、UI 渲染和網絡處理等行爲,防止主線程的不阻塞,Event Loop 的方案應用而生。Event Loop 包含兩類:一類是基於 Browsing Context,一種是基於 Worker。二者的運行是獨立的,也就是說,每一個 JavaScript 運行的"線程環境"都有一個獨立的 Event Loop,每一個 Web Worker 也有一個獨立的 Event Loop。
本文所涉及到的事件循環是基於 Browsing Context。
那麼在事件循環機制中,又通過什麼方式進行函數調用或者任務的調度呢?
任務隊列
根據規範,事件循環是通過任務隊列的機制來進行協調的。一個 Event Loop 中,可以有一個或者多個任務隊列(task queue),一個任務隊列便是一系列有序任務(task)的集合;每個任務都有一個任務源(task source),源自同一個任務源的 task 必須放到同一個任務隊列,從不同源來的則被添加到不同隊列。
在事件循環中,每進行一次循環操作稱爲 tick,每一次 tick 的任務處理模型是比較複雜的,但關鍵步驟如下:
在此次 tick 中選擇最先進入隊列的任務(oldest task),如果有則執行(一次)
檢查是否存在 Microtasks,如果存在則不停地執行,直至清空 Microtasks Queue
更新 render
主線程重複執行上述步驟
仔細查閱規範可知,異步任務可分爲 task
和 microtask
兩類,不同的API註冊的異步任務會依次進入自身對應的隊列中,然後等待 Event Loop 將它們依次壓入執行棧中執行。
查閱了網上比較多關於事件循環介紹的文章,均會提到 macrotask(宏任務) 和 microtask(微任務) 兩個概念,但規範中並沒有提到 macrotask,因而一個比較合理的解釋是 task 即爲其它文章中的 macrotask。另外在 ES2015 規範中稱爲 microtask 又被稱爲 Job。
(macro)task主要包含:script(整體代碼)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js 環境)
microtask主要包含:Promise、MutaionObserver、process.nextTick(Node.js 環境)
在 Node 中,會優先清空 next tick queue,即通過process.nextTick 註冊的函數,再清空 other queue,常見的如Promise
setTimeout/Promise 等API便是任務源,而進入任務隊列的是他們指定的具體執行任務。來自不同任務源的任務會進入到不同的任務隊列。其中setTimeout與setInterval是同源的。
示例
純文字表述確實有點乾澀,這一節通過一個示例來逐步理解:
console.log('script start'); setTimeout(function() { console.log('timeout1'); }, 10); new Promise(resolve => { console.log('promise1'); resolve(); setTimeout(() => console.log('timeout2'), 10); }).then(function() { console.log('then1') }) console.log('script end');
首先,事件循環從宏任務(macrotask)隊列開始,這個時候,宏任務隊列中,只有一個script(整體代碼)任務;當遇到任務源(task source)時,則會先分發任務到對應的任務隊列中去。所以,上面例子的第一步執行如下圖所示:
然後遇到了 console
語句,直接輸出 script start
。輸出之後,script 任務繼續往下執行,遇到 setTimeout
,其作爲一個宏任務源,則會先將其任務分發到對應的隊列中:
script 任務繼續往下執行,遇到 Promise
實例。Promise 構造函數中的第一個參數,是在 new
的時候執行,構造函數執行時,裏面的參數進入執行棧執行;而後續的 .then
則會被分發到 microtask 的 Promise
隊列中去。所以會先輸出 promise1
,然後執行 resolve
,將 then1
分配到對應隊列。
構造函數繼續往下執行,又碰到 setTimeout
,然後將對應的任務分配到對應隊列:
script任務繼續往下執行,最後只有一句輸出了 script end
,至此,全局任務就執行完畢了。
根據上述,每次執行完一個宏任務之後,會去檢查是否存在 Microtasks;如果有,則執行 Microtasks 直至清空 Microtask Queue。
因而在script任務執行完畢之後,開始查找清空微任務隊列。此時,微任務中,只有 Promise
隊列中的一個任務 then1
,因此直接執行就行了,執行結果輸出 then1
。當所有的 microtast
執行完畢之後,表示第一輪的循環就結束了。
這個時候就得開始第二輪的循環。第二輪循環仍然從宏任務 macrotask
開始。此時,有兩個宏任務:timeout1
和 timeout2
。
取出 timeout1
執行,輸出 timeout1
。此時微任務隊列中已經沒有可執行的任務了,直接開始第三輪循環:
第三輪循環依舊從宏任務隊列開始。此時宏任務中只有一個 timeout2
,取出直接輸出即可。
這個時候宏任務隊列與微任務隊列中都沒有任務了,所以代碼就不會再輸出其他東西了。那麼例子的輸出結果就顯而易見:
script start promise1 script end then1 timeout1 timeout2
總結
在回頭看本文最初的題目:
new Promise(resolve => { resolve(1); Promise.resolve().then(() => { // t2 console.log(2) }); console.log(4) }).then(t => { // t1 console.log(t) }); console.log(3);
這段代碼的流程大致如下:
script 任務先運行。首先遇到
Promise
實例,構造函數首先執行,所以首先輸出了 4。此時 microtask 的任務有t2
和t1
script 任務繼續運行,輸出 3。至此,第一個宏任務執行完成。
執行所有的微任務,先後取出
t2
和t1
,分別輸出 2 和 1代碼執行完畢
綜上,上述代碼的輸出是:4321
爲什麼 t2
會先執行呢?理由如下:
根據 Promises/A+規範:
實踐中要確保 onFulfilled 和 onRejected 方法異步執行,且應該在
then
方法被調用的那一輪事件循環之後的新執行棧中執行
Promise.resolve
方法允許調用時不帶參數,直接返回一個resolved
狀態的Promise
對象。立即resolved
的Promise
對象,是在本輪“事件循環”(event loop)的結束時,而不是在下一輪“事件循環”的開始時。
所以,t2
比 t1
會先進入 microtask 的 Promise
隊列。