重溫 JavaScript 之 Event Loop

最近有朋友來信說在面試的時候遇到與事件循環(Event Loop)有關的問題,主要就是 PromisesetTimeout 等一塊兒出現的時候,它們的執行順序是怎樣的。javascript

有朋友遇到的示例代碼比較複雜、嵌套較深,當被問到原理時沒能正確回答。html

我看了一些網上的文章,彷佛能解釋示例代碼的執行過程,但總以爲有些地方不太對勁,因而花了三天的時間看了一些文章和視頻、重點閱讀了 SPEC 規範中的 Event Loops 章節,但願能有一個更合理的結論,這篇文章是我根據這些資料所作的總結,本人水平有限,所以不會深刻細節,總結也不必定正確。java

想要深刻了解的朋友,能夠研究文末的參考資料。web

認知錯誤

在正式進入事件循環前,咱們須要先糾正以往認知中的一些錯誤。面試

宏任務與微任務

不少文章將 JavaScript 中的任務分爲宏任務和微任務,可是在 SPEC 規範中並無宏任務的概念,規範中提到的任務類型是 taskmicrotask,即任務微任務,並且微任務也只是相對於任務的一種口語化稱謂,是指經過微任務排隊算法建立的任務。算法

這是第一個認知錯誤。shell

宏任務隊列

前文已經說過 SPEC 規範中並無宏任務的概念,所以也就沒有宏任務隊列的說法,規範中的說法是 task queuemicrotask 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 loopworker event loopworklet event loop,其中 worker event loop 是與 web worker 相關的,而 worklet event loop 我暫時也不知道是什麼,感興趣的朋友能夠研究一下,本文主要講解 window event loop

每一個事件循環都有一個或多個任務隊列,一個任務隊列是一些任務的集合;每一個事件循環都只有一個微任務隊列

要理解事件循環,首先須要知道什麼是任務,什麼是任務隊列,什麼是微任務,以及什麼是微任務隊列。

任務

SPEC 規範中對任務的定義是擁有下列內容的一個結構

task

其中的 source 很重要,它標明瞭某個任務的來源,即任務源 task source,用戶代理(user agent)用它來區分不一樣類型的任務,進而選擇將其加入哪一個任務隊列中,稍後將詳細講解。

steps 指明瞭該任務中的每一步執行什麼。

任務封裝了負責如下工做的算法:

task_algorithms

能夠看到,回調函數、異步獲取資源等操做如何處理是任務算法已經預設好了的,並非由「出入隊」決定的。

任務源

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

至此,咱們能夠知道,一個事件循環中的隊列「結構」可能以下圖所示:

queues

再次強調,任務隊列不是隊列,是有序集合,必定要牢記。

若是想了解這些任務具體是怎樣進行排隊的,能夠查閱規範,本文將不展開。

接下來咱們講解事件循環的處理模型(processing model),也就是處理任務的流程。

處理模型

SPEC 規範中對處理流程講述得很是詳細,咱們這裏將其簡化一下,如圖所示:

processing_model

須要注意的是,規範中對於以什麼順序來選擇任務隊列並無明確規定,而是以實現定義(implementation-defined)的方式進行的,即由用戶代理自行決定實現細節,這是產生瀏覽器差別的緣由之一。

事件循環中每運行一個任務,就會將其從對應的任務隊列中移除(remove),每運行一個微任務,就會將其從微任務隊列中出隊(dequeue)。

worker event loop 的處理流程會略有不一樣,詳情能夠查閱規範。

其中還有一個細節,那就是 IndexedDB 的事務是在微任務隊列的最後清理的。

旋轉事件循環

規範中還有一個重要的東西,叫 spin the event loop ,即旋轉事件循環,原文是這樣寫的:

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 有很大篇幅的講解,這裏簡述一下流程,即:

  1. 獲取 scripts
  2. 建立 scripts
  3. 運行 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!

複製代碼

但這並非此例要表達的重點。

若是按照前文所說的事件循環流程:

  1. 運行 console.log()
  2. setTimeout 依次進入 timer task queue
  3. 運行 new Promise() 中的 console.log()
  4. promise.then() 進入微任務隊列
  5. 運行 timer task queue 中的第一個任務,打印 I will execute after console.log!
  6. 運行微任務隊列,打印 3
  7. 循環

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:。

參考資料:

  1. Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018

  2. What the heck is the event loop anyway? | Philip Roberts | JSConf EU

  3. Tasks, microtasks, queues and schedules

  4. SPEC

首圖由Free-PhotosPixabay上發佈

相關文章
相關標籤/搜索