阮老師在其推特上放了一道題:
new Promise(resolve => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4)
}).then(t => console.log(t));
console.log(3);
看到此處的你可以先猜測(cè)下其答案,然后再在瀏覽器的控制臺(tái)運(yùn)行這段代碼,看看運(yùn)行結(jié)果是否和你的猜測(cè)一致。
事件循環(huán)
眾所周知,JavaScript 語(yǔ)言的一大特點(diǎn)就是單線程,也就是說(shuō),同一個(gè)時(shí)間只能做一件事。根據(jù) HTML 規(guī)范:
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.
為了協(xié)調(diào)事件、用戶交互、腳本、UI 渲染和網(wǎng)絡(luò)處理等行為,防止主線程的不阻塞,Event Loop 的方案應(yīng)用而生。Event Loop 包含兩類:一類是基于 Browsing Context,一種是基于 Worker。二者的運(yùn)行是獨(dú)立的,也就是說(shuō),每一個(gè) JavaScript 運(yùn)行的"線程環(huán)境"都有一個(gè)獨(dú)立的 Event Loop,每一個(gè) Web Worker 也有一個(gè)獨(dú)立的 Event Loop。
本文所涉及到的事件循環(huán)是基于 Browsing Context。
那么在事件循環(huán)機(jī)制中,又通過(guò)什么方式進(jìn)行函數(shù)調(diào)用或者任務(wù)的調(diào)度呢?
任務(wù)隊(duì)列
根據(jù)規(guī)范,事件循環(huán)是通過(guò)任務(wù)隊(duì)列的機(jī)制來(lái)進(jìn)行協(xié)調(diào)的。一個(gè) Event Loop 中,可以有一個(gè)或者多個(gè)任務(wù)隊(duì)列(task queue),一個(gè)任務(wù)隊(duì)列便是一系列有序任務(wù)(task)的集合;每個(gè)任務(wù)都有一個(gè)任務(wù)源(task source),源自同一個(gè)任務(wù)源的 task 必須放到同一個(gè)任務(wù)隊(duì)列,從不同源來(lái)的則被添加到不同隊(duì)列。
在事件循環(huán)中,每進(jìn)行一次循環(huán)操作稱為 tick,每一次 tick 的任務(wù)處理模型是比較復(fù)雜的,但關(guān)鍵步驟如下:
- 在此次 tick 中選擇最先進(jìn)入隊(duì)列的任務(wù)(oldest task),如果有則執(zhí)行(一次)
- 檢查是否存在 Microtasks,如果存在則不停地執(zhí)行,直至清空 Microtasks Queue
- 更新 render
- 主線程重復(fù)執(zhí)行上述步驟
仔細(xì)查閱規(guī)范可知,異步任務(wù)可分為task 和 microtask 兩類(requestAnimationFrame 既不屬于 macrotask, 也不屬于 microtask),不同的API注冊(cè)的異步任務(wù)會(huì)依次進(jìn)入自身對(duì)應(yīng)的隊(duì)列中,然后等待 Event Loop 將它們依次壓入執(zhí)行棧中執(zhí)行。
查閱了網(wǎng)上比較多關(guān)于事件循環(huán)介紹的文章,均會(huì)提到 macrotask(宏任務(wù)) 和 microtask(微任務(wù)) 兩個(gè)概念,但規(guī)范中并沒有提到 macrotask,因而一個(gè)比較合理的解釋是 task 即為其它文章中的 macrotask。另外在 ES2015 規(guī)范中稱為 microtask 又被稱為 Job。
(macro)task主要包含:script(整體代碼)、setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 環(huán)境)
microtask主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 環(huán)境)
在 Node 中,會(huì)優(yōu)先清空 next tick queue,即通過(guò)process.nextTick 注冊(cè)的函數(shù),再清空 other queue,常見的如Promise;此外,timers(setTimeout/setInterval) 會(huì)優(yōu)先于 setImmediate 執(zhí)行,因?yàn)榍罢咴?timer 階段執(zhí)行,后者在 check 階段執(zhí)行。
setTimeout/Promise 等API便是任務(wù)源,而進(jìn)入任務(wù)隊(duì)列的是他們指定的具體執(zhí)行任務(wù)。來(lái)自不同任務(wù)源的任務(wù)會(huì)進(jìn)入到不同的任務(wù)隊(duì)列。其中setTimeout與setInterval是同源的。

示例
純文字表述確實(shí)有點(diǎn)干澀,這一節(jié)通過(guò)一個(gè)示例來(lái)逐步理解:
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');
首先,事件循環(huán)從宏任務(wù)(macrotask)隊(duì)列開始,這個(gè)時(shí)候,宏任務(wù)隊(duì)列中,只有一個(gè)script(整體代碼)任務(wù);當(dāng)遇到任務(wù)源(task source)時(shí),則會(huì)先分發(fā)任務(wù)到對(duì)應(yīng)的任務(wù)隊(duì)列中去。所以,上面例子的第一步執(zhí)行如下圖所示:

然后遇到了 console 語(yǔ)句,直接輸出 script start。輸出之后,script 任務(wù)繼續(xù)往下執(zhí)行,遇到 setTimeout,其作為一個(gè)宏任務(wù)源,則會(huì)先將其任務(wù)分發(fā)到對(duì)應(yīng)的隊(duì)列中:

script 任務(wù)繼續(xù)往下執(zhí)行,遇到 Promise 實(shí)例。Promise 構(gòu)造函數(shù)中的第一個(gè)參數(shù),是在 new 的時(shí)候執(zhí)行,構(gòu)造函數(shù)執(zhí)行時(shí),里面的參數(shù)進(jìn)入執(zhí)行棧執(zhí)行;而后續(xù)的 .then 則會(huì)被分發(fā)到 microtask 的 Promise 隊(duì)列中去。所以會(huì)先輸出 promise1,然后執(zhí)行 resolve,將 then1 分配到對(duì)應(yīng)隊(duì)列。
構(gòu)造函數(shù)繼續(xù)往下執(zhí)行,又碰到 setTimeout,然后將對(duì)應(yīng)的任務(wù)分配到對(duì)應(yīng)隊(duì)列:

script任務(wù)繼續(xù)往下執(zhí)行,最后只有一句輸出了 script end,至此,全局任務(wù)就執(zhí)行完畢了。
根據(jù)上述,每次執(zhí)行完一個(gè)宏任務(wù)之后,會(huì)去檢查是否存在 Microtasks;如果有,則執(zhí)行 Microtasks 直至清空 Microtask Queue。
因而在script任務(wù)執(zhí)行完畢之后,開始查找清空微任務(wù)隊(duì)列。此時(shí),微任務(wù)中,只有 Promise 隊(duì)列中的一個(gè)任務(wù) then1,因此直接執(zhí)行就行了,執(zhí)行結(jié)果輸出 then1。當(dāng)所有的 microtast 執(zhí)行完畢之后,表示第一輪的循環(huán)就結(jié)束了。

這個(gè)時(shí)候就得開始第二輪的循環(huán)。第二輪循環(huán)仍然從宏任務(wù) macrotask開始。此時(shí),有兩個(gè)宏任務(wù):timeout1 和 timeout2。
取出 timeout1 執(zhí)行,輸出 timeout1。此時(shí)微任務(wù)隊(duì)列中已經(jīng)沒有可執(zhí)行的任務(wù)了,直接開始第三輪循環(huán):

第三輪循環(huán)依舊從宏任務(wù)隊(duì)列開始。此時(shí)宏任務(wù)中只有一個(gè) timeout2,取出直接輸出即可。
這個(gè)時(shí)候宏任務(wù)隊(duì)列與微任務(wù)隊(duì)列中都沒有任務(wù)了,所以代碼就不會(huì)再輸出其他東西了。那么例子的輸出結(jié)果就顯而易見:
script start
promise1
script end
then1
timeout1
timeout2
總結(jié)
在回頭看本文最初的題目:
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 任務(wù)先運(yùn)行。首先遇到 Promise 實(shí)例,構(gòu)造函數(shù)首先執(zhí)行,所以首先輸出了 4。此時(shí) microtask 的任務(wù)有 t2 和 t1
- script 任務(wù)繼續(xù)運(yùn)行,輸出 3。至此,第一個(gè)宏任務(wù)執(zhí)行完成。
- 執(zhí)行所有的微任務(wù),先后取出 t2 和 t1,分別輸出 2 和 1
- 代碼執(zhí)行完畢
綜上,上述代碼的輸出是:4321
為什么 t2 會(huì)先執(zhí)行呢?理由如下:
- 根據(jù) Promises/A+規(guī)范:
實(shí)踐中要確保 onFulfilled 和 onRejected 方法異步執(zhí)行,且應(yīng)該在 then 方法被調(diào)用的那一輪事件循環(huán)之后的新執(zhí)行棧中執(zhí)行
- Promise.resolve 方法允許調(diào)用時(shí)不帶參數(shù),直接返回一個(gè)resolved 狀態(tài)的 Promise 對(duì)象。立即 resolved 的 Promise 對(duì)象,是在本輪“事件循環(huán)”(event loop)的結(jié)束時(shí),而不是在下一輪“事件循環(huán)”的開始時(shí)。
http://es6.ruanyifeng.com/#docs/promise#Promise-resolve
所以,t2 比 t1 會(huì)先進(jìn)入 microtask 的 Promise 隊(duì)列。
這段解釋更清晰:
一旦一個(gè)pormise有了結(jié)果,或者早已有了結(jié)果,他就會(huì)為它的回調(diào)產(chǎn)生一個(gè)微任務(wù)。如果在微任務(wù)執(zhí)行期間微任務(wù)隊(duì)列加入了新的微任務(wù),會(huì)將新的微任務(wù)加入隊(duì)列尾部,之后也會(huì)被執(zhí)行。
當(dāng)執(zhí)行resolve(1)的時(shí)候,代碼還沒運(yùn)行到then(t => {console.log(t)}),這時(shí)候是沒有回調(diào)的,所以這時(shí)候還是沒有添加任何微任務(wù)的。
接下來(lái)執(zhí)行Promise.resolve().then(t => {console.log(2)}),為已有結(jié)果的內(nèi)層Promise添加一個(gè)微任務(wù),然后外層Promise執(zhí)行.then(t => {console.log(t)}),這時(shí)候外層Promise是屬于早已有了結(jié)果,所以為這個(gè)回調(diào)添加一個(gè)微任務(wù)。
輸出2的微任務(wù)在輸出1的微任務(wù)前面,所以是先輸出 2 再輸出 1
看看你掌握了沒
再來(lái)一個(gè)題目,來(lái)做個(gè)練習(xí):
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');
這個(gè)題目就稍微有點(diǎn)復(fù)雜了,我們?cè)俜治鱿拢?/p>
首先,事件循環(huán)從宏任務(wù)(macrotask)隊(duì)列開始,最初始,宏任務(wù)隊(duì)列中,只有一個(gè)script(整體代碼)任務(wù);當(dāng)遇到任務(wù)源(task source)時(shí),則會(huì)先分發(fā)任務(wù)到對(duì)應(yīng)的任務(wù)隊(duì)列中去。所以,就和上面例子類似,首先遇到了console.log,輸出script start;
接著往下走,遇到setTimeout任務(wù)源,將其分發(fā)到任務(wù)隊(duì)列中去,記為timeout1;
接著遇到promise,new promise中的代碼立即執(zhí)行,輸出promise1,然后執(zhí)行resolve,遇到setTimeout,將其分發(fā)到任務(wù)隊(duì)列中去,記為timemout2,將其then分發(fā)到微任務(wù)隊(duì)列中去,記為then1;
接著遇到console.log代碼,直接輸出script end
接著檢查微任務(wù)隊(duì)列,發(fā)現(xiàn)有個(gè)then1微任務(wù),執(zhí)行,輸出then1
再檢查微任務(wù)隊(duì)列,發(fā)現(xiàn)已經(jīng)清空,則開始檢查宏任務(wù)隊(duì)列,執(zhí)行timeout1,輸出timeout1;
接著執(zhí)行timeout2,輸出timeout2
至此,所有的都隊(duì)列都已清空,執(zhí)行完畢。其輸出的順序依次是:script start, promise1, script end, then1, timeout1, timeout2