最近有朋友來信說在面試的時候遇到與事件循環(Event Loop)有關的問題,主要就是 Promise
和 setTimeout
等一塊兒出現的時候,它們的執行順序是怎樣的。javascript
有朋友遇到的示例代碼比較複雜、嵌套較深,當被問到原理時沒能正確回答。html
我看了一些網上的文章,彷佛能解釋示例代碼的執行過程,但總以爲有些地方不太對勁,因而花了三天的時間看了一些文章和視頻、重點閱讀了 SPEC 規範中的 Event Loops 章節,但願能有一個更合理的結論,這篇文章是我根據這些資料所作的總結,本人水平有限,所以不會深刻細節,總結也不必定正確。java
想要深刻了解的朋友,能夠研究文末的參考資料。web
在正式進入事件循環前,咱們須要先糾正以往認知中的一些錯誤。面試
不少文章將 JavaScript 中的任務分爲宏任務和微任務,可是在 SPEC 規範中並無宏任務的概念,規範中提到的任務類型是 task
和 microtask
,即任務與微任務,並且微任務也只是相對於任務的一種口語化稱謂,是指經過微任務排隊算法建立的任務。算法
這是第一個認知錯誤。shell
前文已經說過 SPEC 規範中並無宏任務的概念,所以也就沒有宏任務隊列的說法,規範中的說法是 task queue
和 microtask queue
,即任務隊列與微任務隊列。api
任務隊列的數據結構並非真的隊列(Queues),而是有序集合(Sets),微任務隊列纔是真的隊列。promise
不少文章將 setTimeout
列爲宏任務,它會將其回調函數在宏任務隊列中進行入隊操做,進而致使代碼的延遲執行。其實這個說法咱們簡單驗證一下就知道有問題,例如:瀏覽器
console.log(`I will execute immediately!`);
setTimeout(function () {
console.log(`I will execute after 6 seconds!`)
}, 6000);
setTimeout(function () {
console.log(`I will execute after 4 seconds!`)
}, 4000);
setTimeout(function () {
console.log(`I will execute after console.log!`)
}, 0)
複製代碼
獲得的結果以下:
I will execute immediately!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!
複製代碼
咱們知道,對於隊列來講,正常的執行順序應當是先進先出,而從打印結果獲得的是回調函數按序入隊,卻沒有按序出隊。
所以,要記住任務隊列不是隊列,是有序集合,這是第二個認知錯誤。
SPEC 規範的內容很是多,本文只簡要的講解一些重點內容。
SPEC 規範中將事件循環分爲三類,分別是 window event loop
、worker event loop
和 worklet event loop
,其中 worker event loop
是與 web worker
相關的,而 worklet event loop
我暫時也不知道是什麼,感興趣的朋友能夠研究一下,本文主要講解 window event loop
。
每一個事件循環都有一個或多個任務隊列,一個任務隊列是一些任務的集合;每一個事件循環都只有一個微任務隊列。
要理解事件循環,首先須要知道什麼是任務,什麼是任務隊列,什麼是微任務,以及什麼是微任務隊列。
SPEC 規範中對任務的定義是擁有下列內容的一個結構。
其中的 source
很重要,它標明瞭某個任務的來源,即任務源 task source
,用戶代理(user agent)用它來區分不一樣類型的任務,進而選擇將其加入哪一個任務隊列中,稍後將詳細講解。
steps
指明瞭該任務中的每一步執行什麼。
任務封裝了負責如下工做的算法:
能夠看到,回調函數、異步獲取資源等操做如何處理是任務算法已經預設好了的,並非由「出入隊」決定的。
SPEC 規範中的任務源有六種(目前我只發現了六種),以下表所示:
任務源 | 描述 |
---|---|
timer task source | 與定時器相關的任務,如 setTimeout() |
DOM manipulation task source | 與 DOM 操做相關的任務,如以非阻塞方式將元素插入到文檔中 |
user interaction task source | 與用戶交互相關的任務,如 onclick() |
networking task source | 與網絡活動相關的任務,如 new XMLHttpRequest() |
history traversal task source | 與瀏覽器歷史相關的任務,如 history.back() |
microtask task source | 微任務,如 Promise.then() |
任務源是任務排隊的依據。
事件循環會根據任務源將不一樣類型的任務加入(append
)到對應的任務隊列中,以後從這些任務隊列中選取任務進行處理。
既然規範中說的是每一個事件循環中有一個或多個任務隊列,說明各類任務隊列一開始並不必定所有存在,應該是在須要時建立。
前文已經講過微任務與微任務隊列的概念,那麼什麼樣的任務是微任務呢?根據 SPEC 規範與 ECMA 規範綜合得出,MutationObserver()
、Promise.then()
和 Promise.catch()
是微任務,它們將被加入(enqueue
)微任務隊列,在處理後從微任務隊列出隊(dequeue
)。
微任務是有可能被移動到常規任務隊列的,詳情能夠查閱規範。
SPEC 規範中在 queue tasks 部分還提到了一個 element task
,即元素任務,這類任務都是 DOM 元素上的,它與任務略有不一樣。
好比在 textarea
元素中選擇文本時任務源是 user interaction task source
;若是 iframe
元素沒有指定 src
屬性,而用戶代理剛好是首次處理 iframe
元素的屬性時,任務源是 DOM manipulation task source
。
至此,咱們能夠知道,一個事件循環中的隊列「結構」可能以下圖所示:
再次強調,任務隊列不是隊列,是有序集合,必定要牢記。
若是想了解這些任務具體是怎樣進行排隊的,能夠查閱規範,本文將不展開。
接下來咱們講解事件循環的處理模型(processing model),也就是處理任務的流程。
SPEC 規範中對處理流程講述得很是詳細,咱們這裏將其簡化一下,如圖所示:
須要注意的是,規範中對於以什麼順序來選擇任務隊列並無明確規定,而是以實現定義(implementation-defined)的方式進行的,即由用戶代理自行決定實現細節,這是產生瀏覽器差別的緣由之一。
事件循環中每運行一個任務,就會將其從對應的任務隊列中移除(remove
),每運行一個微任務,就會將其從微任務隊列中出隊(dequeue
)。
worker event loop
的處理流程會略有不一樣,詳情能夠查閱規範。
其中還有一個細節,那就是 IndexedDB 的事務是在微任務隊列的最後清理的。
規範中還有一個重要的東西,叫 spin the event loop
,即旋轉事件循環,原文是這樣寫的:
好吧,我並非很明白到底在講什麼,只是感受可能和算法是如何處理 setTimeout
等的回調函數的細節有關。
理論講得再多,仍是以爲空洞,接下來咱們結合示例來分析。
在文章開頭的例子上稍微修改一下,特別留意 script
標籤:
<script> console.log(`I will execute immediately!`); setTimeout(function () { console.log(`I will execute after 6 seconds!`) }, 6000); setTimeout(function () { console.log(`I will execute after 4 seconds!`) }, 4000); setTimeout(function () { console.log(`I will execute after console.log!`) }, 0) </script>
複製代碼
前文說過事件循環是以選取任務隊列開始的,而任務隊列也是建立的,那麼何時建立的任務隊列呢?是在第一次運行 JavaScript 代碼的時候。
規範中對於 script
有很大篇幅的講解,這裏簡述一下流程,即:
scripts
scripts
scripts
所以,此例中第一個 console.log
是在運行 scripts
時輸出的,以後的 setTimeout
所有按序進入了 timer task queue
(應該是在遇到第一個 setTimeout
時建立的任務隊列),而後進行事件循環,依次輸出。
輸出結果以下:
I will execute immediately!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!
複製代碼
這個示例主要是爲了讓你們可以明白事件循環的開始時機。
讓咱們再對上面的例子作一些修改,加入微任務:
<script> console.log(`I will execute immediately!`); setTimeout(function () { console.log(`I will execute after 6 seconds!`) }, 6000); setTimeout(function () { console.log(`I will execute after 4 seconds!`) }, 4000); setTimeout(function () { console.log(`I will execute after console.log!`) }, 0); const promise = new Promise((resolve, reject) => { console.log(1); resolve('success'); console.log(2); }); promise.then(() => { console.log(3); }); </script>
複製代碼
根據以往經驗,相信你們都能說出答案,以下:
I will execute immediately!
1
2
3
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!
複製代碼
但這並非此例要表達的重點。
若是按照前文所說的事件循環流程:
console.log()
setTimeout
依次進入 timer task queue
new Promise()
中的 console.log()
promise.then()
進入微任務隊列timer task queue
中的第一個任務,打印 I will execute after console.log!
3
3
應該在 I will execute after console.log!
以後輸出,由於它是微任務,應當在第一個任務運行後運行,而它卻出如今了前面。
這是由於在運行 scripts
後會進行清理(clean up after running script),清理過程當中的重要一步就是運行一次微任務隊列,而後才進行事件循環,因此 3
會在 I will execute after console.log!
前輸出。
讓咱們再對上面的例子作一些修改:
<script> console.log(`I will execute immediately!`); setTimeout(function () { console.log(`I will execute after 6 seconds!`) }, 6000); setTimeout(function () { console.log(`I will execute after 4 seconds!`) }, 4000); setTimeout(function () { console.log(`I will execute after console.log!`) }, 0); const promise = new Promise((resolve, reject) => { console.log(1); resolve('success'); console.log(2); }); promise.then(() => { console.log(3); }); </script>
<script> console.log(`I will execute immediately!`); setTimeout(function () { console.log(`I will execute after 6 seconds!`) }, 6000); setTimeout(function () { console.log(`I will execute after 4 seconds!`) }, 4000); setTimeout(function () { console.log(`I will execute after console.log!`) }, 0); const promise2 = new Promise((resolve, reject) => { console.log(1); resolve('success'); console.log(2); }); promise2.then(() => { console.log(3); }); </script>
複製代碼
有多個 script
標籤的狀況,會怎樣執行呢?結果以下:
I will execute immediately!
1
2
3
I will execute immediately!
1
2
3
I will execute after console.log!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 4 seconds!
I will execute after 6 seconds!
I will execute after 6 seconds!
複製代碼
能夠看到,JavaScript 會將全部 script
標籤所有運行後才進行事件循環。
事件循環是一個很是複雜的東西,本文的內容也只是粗淺地帶你們過了一下,若是真想吃透事件循環,至少要完整閱讀 SPEC 規範,由於裏面的內容是先後關聯的。
另外還有一個重要的點,規範也是在不斷變化的。
歡迎感興趣的朋友對文中內容進行探討、勘誤,畢竟不少細節我也仍是沒有徹底搞清楚。
最後,要感謝參考資料的做者們,否則,單純地閱讀規範我可能會精神崩潰 :joy:。
參考資料:
Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018
What the heck is the event loop anyway? | Philip Roberts | JSConf EU
首圖由Free-Photos在Pixabay上發佈