JavaScript執(zhí)行過(guò)程(Event Loop)

阮老師在其推特上放了一道題:

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ù)可分為taskmicrotask 兩類(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);

這段代碼的流程大致如下:

  1. script 任務(wù)先運(yùn)行。首先遇到 Promise 實(shí)例,構(gòu)造函數(shù)首先執(zhí)行,所以首先輸出了 4。此時(shí) microtask 的任務(wù)有 t2 和 t1
  2. script 任務(wù)繼續(xù)運(yùn)行,輸出 3。至此,第一個(gè)宏任務(wù)執(zhí)行完成。
  3. 執(zhí)行所有的微任務(wù),先后取出 t2 和 t1,分別輸出 2 和 1
  4. 代碼執(zhí)行完畢

綜上,上述代碼的輸出是:4321

為什么 t2 會(huì)先執(zhí)行呢?理由如下:

實(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 弄懂js異步 講異步之前,我們必須掌握一個(gè)基礎(chǔ)知識(shí)-event-loop。 我們知道JavaScript的一大特點(diǎn)...
    DCbryant閱讀 2,886評(píng)論 0 5
  • 什么是事件循環(huán)(Event Loop) 事件循環(huán)能讓 Node.js 執(zhí)行非阻塞 I/O 操作,盡管JavaScr...
    假面猿閱讀 827評(píng)論 0 0
  • JS中比較讓人頭疼的問題之一要算異步事件了,比如我們經(jīng)常要等后臺(tái)返回?cái)?shù)據(jù)后進(jìn)行dom操作,又比如我們要設(shè)置一個(gè)定時(shí)...
    si_月閱讀 1,085評(píng)論 0 0
  • 歡迎光臨我的博客拓跋的前端客棧,如果您發(fā)現(xiàn)我文章中存在錯(cuò)誤,請(qǐng)盡情向我吐槽,大家一起學(xué)習(xí)一起進(jìn)步φ(>ω<*) 1...
    zhleven閱讀 5,539評(píng)論 5 12
  • 白玉石欄恢廟宇,梵音始覺靈臺(tái) 真如是?強(qiáng)壘仙崍 朱門隔世閉,都以數(shù)春開 柳毅舊山橋任在,失人故道情懷 晚春幕,雨意...
    孫若蘭閱讀 297評(píng)論 3 6

友情鏈接更多精彩內(nèi)容