阮老師在其推特上放了一道題:javascript
new Promise(resolve => { resolve(1); Promise.resolve().then(() => console.log(2)); console.log(4) }).then(t => console.log(t)); console.log(3);
看到此處的你能夠先猜想下其答案,而後再在瀏覽器的控制檯運行這段代碼,看看運行結果是否和你的猜想一致。html
衆所周知,JavaScript 語言的一大特色就是單線程,也就是說,同一個時間只能作一件事。根據 HTML 規範:html5
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.java
爲了協調事件、用戶交互、腳本、UI 渲染和網絡處理等行爲,防止主線程的不阻塞,Event Loop 的方案應用而生。Event Loop 包含兩類:一類是基於 Browsing Context,一種是基於 Worker。兩者的運行是獨立的,也就是說,每個 JavaScript 運行的"線程環境"都有一個獨立的 Event Loop,每個 Web Worker 也有一個獨立的 Event Loop。git
本文所涉及到的事件循環是基於 Browsing Context。es6
那麼在事件循環機制中,又經過什麼方式進行函數調用或者任務的調度呢?github
根據規範,事件循環是經過任務隊列的機制來進行協調的。一個 Event Loop 中,能夠有一個或者多個任務隊列(task queue),一個任務隊列即是一系列有序任務(task)的集合;每一個任務都有一個任務源(task source),源自同一個任務源的 task 必須放到同一個任務隊列,從不一樣源來的則被添加到不一樣隊列。web
在事件循環中,每進行一次循環操做稱爲 tick,每一次 tick 的任務處理模型是比較複雜的,但關鍵步驟以下:segmentfault
在這次 tick 中選擇最早進入隊列的任務(oldest task),若是有則執行(一次)api
檢查是否存在 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
隊列。