從一道題淺說 JavaScript 的事件循環

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

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是同源的。

event loop

示例

純文字表述確實有點乾澀,這一節通過一個示例來逐步理解:

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)時,則會先分發任務到對應的任務隊列中去。所以,上面例子的第一步執行如下圖所示:

step1

然後遇到了 console 語句,直接輸出 script start。輸出之後,script 任務繼續往下執行,遇到 setTimeout,其作爲一個宏任務源,則會先將其任務分發到對應的隊列中:

step2

script 任務繼續往下執行,遇到 Promise 實例。Promise 構造函數中的第一個參數,是在 new 的時候執行,構造函數執行時,裏面的參數進入執行棧執行;而後續的 .then 則會被分發到 microtask 的 Promise 隊列中去。所以會先輸出 promise1,然後執行 resolve,將 then1 分配到對應隊列。

構造函數繼續往下執行,又碰到 setTimeout,然後將對應的任務分配到對應隊列:

step3

script任務繼續往下執行,最後只有一句輸出了 script end,至此,全局任務就執行完畢了。

根據上述,每次執行完一個宏任務之後,會去檢查是否存在 Microtasks;如果有,則執行 Microtasks 直至清空 Microtask Queue。

因而在script任務執行完畢之後,開始查找清空微任務隊列。此時,微任務中,只有 Promise隊列中的一個任務 then1,因此直接執行就行了,執行結果輸出 then1。當所有的 microtast 執行完畢之後,表示第一輪的循環就結束了。

step4

這個時候就得開始第二輪的循環。第二輪循環仍然從宏任務 macrotask開始。此時,有兩個宏任務:timeout1 和 timeout2

取出 timeout1 執行,輸出 timeout1。此時微任務隊列中已經沒有可執行的任務了,直接開始第三輪循環:

step5

第三輪循環依舊從宏任務隊列開始。此時宏任務中只有一個 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);

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

  1. script 任務先運行。首先遇到 Promise 實例,構造函數首先執行,所以首先輸出了 4。此時 microtask 的任務有 t2 和 t1

  2. script 任務繼續運行,輸出 3。至此,第一個宏任務執行完成。

  3. 執行所有的微任務,先後取出 t2 和 t1,分別輸出 2 和 1

  4. 代碼執行完畢

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

爲什麼 t2 會先執行呢?理由如下:

實踐中要確保 onFulfilled 和 onRejected 方法異步執行,且應該在 then 方法被調用的那一輪事件循環之後的新執行棧中執行

  • Promise.resolve 方法允許調用時不帶參數,直接返回一個resolved 狀態的 Promise 對象。立即 resolved 的 Promise 對象,是在本輪“事件循環”(event loop)的結束時,而不是在下一輪“事件循環”的開始時。

es6.ruanyifeng.com/#docs/promi…

所以,t2 比 t1 會先進入 microtask 的 Promise 隊列。

相關鏈接

原地址 : https://juejin.im/entry/5a8bc3215188257a856f4b2b 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章