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

阮老師在其推特上放了一道題: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是同源的。

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 

相關文章
相關標籤/搜索