close
點擊下方「前端技術優選」,選擇「設為星標」
第一時間關注技術乾貨!

很多文章都在討論事件循環 (Event Loop) 是什麼,而幾乎沒有人討論為什麼 JavaScript 中會有事件循環。博主認為這是為什麼很多人都不能很好理解事件循環的一個重要原因 —— 知其然不知其所以然。所以本文試圖拋磚引玉,從一些更溯源的方式來與大家探討 event loop,希望大家能從中有些收穫。

本文從三個角度來研究 JavaScript 的事件循環:

為什麼是事件循環

事件循環是什麼

瀏覽器與 Node.js 的事件循環差異

為什麼是事件循環

JavaScript 是網景 (Netscape) 公司為其旗下的網景瀏覽器提供更複雜網頁交互時所推出的一個動態腳本語言。其創作者 Eich 在 10 天內寫出了 JavaScript 的第一個版本,通過 Eich 在 JavaScript 20 周年的演講回顧中,我們可以發現 JavaScript 在最初設計的時候沒有考慮所謂的事件循環。那麼事件循環到底是怎麼出現的?

首先讓我們來看看引入 JavaScript 到網頁端的經典用例:一個用戶打開一個網頁,填寫完表單提交之後,等待 30s 的白屏之後發現表單中的某個地方填寫錯誤了需要重新填寫。在這個場景中,如果我們有 JavaScript 就可以在用戶提交表單之前先在用戶本地的瀏覽器端做一次校驗,避免用戶每次都通過網絡找服務端來校驗所浪費的時間。

分析一下這個場景,我們就可以發現,最早的 JavaScript 的執行就是用戶通過瀏覽器的事件來觸發的,例如用戶填寫完表單之後點擊提交的時候,瀏覽器觸發一個 DOM 的點擊事件,而點擊事件綁定了對應的 JavaScript 代碼來執行校驗的過程。在這個過程中,JavaScript 的代碼都是被動被調用的。

仔細思考一下就會發現,JavaScript 所謂的事件和觸發本質上都通過瀏覽器中轉,更像是瀏覽器行為而不僅僅是 JavaScript 語言內的一個隊列。順着這個思路我們順藤摸瓜,就會發現 EcmaScript 的標準定義中壓根 就沒有事件循環,反倒是 HTML 的標準中定義了事件循環(目前 HTML 有 whatwg 和 w3c 標準,這裡討論的是 wahtwg 的標準):

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

根據標準中對事件循環的定義描述,我們可以發現事件循環本質上是 user agent (如瀏覽器端) 用於協調用戶交互(鼠標、鍵盤)、腳本(如 JavaScript)、渲染(如 HTML DOM、CSS 樣式)、網絡等行為的一個機制。

了解到這個定義之後,我們就能夠清楚的知道,與其說是 JavaScript 提供了事件循環,不如說是嵌入 JavaScript 的 user agent 需要通過事件循環來與多種事件源交互。

事件循環是什麼

所以說事件循環本質是一個 user agent 上協調各類事件的機制,而這一節我們主要討論一下瀏覽器中的這個機制與 JavaScript 的交互部分。

各種瀏覽器事件同時觸發時,肯定有一個先來後到的排隊問題。決定這些事件如何排隊觸發的機制,就是事件循環。這個排隊行為以 JavaScript 開發者的角度來看,主要是分成兩個隊列:

一個是 JavaScript 外部的隊列。外部的隊列主要是瀏覽器協調的各類事件的隊列,標準文件中稱之為Task Queue。下文中為了方便理解統一稱為外部隊列。

另一個是 JavaScript 內部的隊列。這部分主要是 JavaScript 內部執行的任務隊列,標準中稱之為Microtask Queue。下文中為了方便理解統一稱為內部隊列。

值得注意的是,雖然為了好理解我們管這個叫隊列 (Queue),但是本質上是有序集合 (Set),因為傳統的隊列都是先進先出(FIFO)的,而這裡的隊列則不然,排到最前面但是沒有滿足條件也是不會執行的(比如外部隊列里只有一個 setTimeout 的定時任務,但是時間還沒有到,沒有滿足條件也不會把他出列來執行)。

外部隊列

外部隊列(Task Queue [1]),顧名思義就是 JavaScript 外部的事件的隊列,這裡我們可以先列舉一下瀏覽器中這些外部事件源(Task Source),他們主要有:

DOM 操作 (頁面渲染)

用戶交互 (鼠標、鍵盤)

網絡請求 (Ajax 等)

History API 操作

定時器 (setTimeout 等) [2]

可以觀察到,這些外部的事件源可能很多,為了方便瀏覽器廠商優化,HTML 標準中明確指出一個事件循環由一個或多個外部隊列,而每一個外部事件源都有一個對應的外部隊列。不同事件源的隊列可以有不同的優先級(例如在網絡事件和用戶交互之間,瀏覽器可以優先處理鼠標行為,從而讓用戶感覺更加流程)。

內部隊列

內部隊列(Microtask Queue),即 JavaScript 語言內部的事件隊列,在 HTML 標準中,並沒有明確規定這個隊列的事件源,通常認為有以下幾種:

Promise的成功 (.then) 與失敗 (.catch)

MutationObserver

Object.observe(已廢棄)

處理模型

在標準定義中事件循環的步驟比較複雜,這裡我們簡單描述一下這個處理過程:

從外部隊列中取出一個可執行任務,如果有則執行,沒有下一步。

挨個取出內部隊列中的所有任務執行,執行完畢或沒有則下一步。

瀏覽器渲染。

案例分析

根據上述的處理模型,我們可以來看以下例子:

console.log('script start');setTimeout(function() { console.log('setTimeout');}, 0);Promise.resolve().then(function() { console.log('promise1');}).then(function() { console.log('promise2');});console.log('script end');

輸出結果:

script startscript endpromise1promise2setTimeout

對應的處理過程則是:

執行 console.log (輸出 script start)

遇到 setTimeout 加入外部隊列

遇到兩個 Promise 的 then 加入內部隊列

遇到 console.log 直接執行(輸出 script end)

內部隊列中的任務挨個執行完 (輸出 promise1 和 promise2)

外部隊列中的任務執行 (輸出 setTimeout)

只要理解了外部隊列與內部隊列的概念,再看這類問題就會變得很簡單,我們再簡單擴展看看:

setTimeout(() => { console.log('setTimeout1')})Promise.resolve().then(() => { console.log('promise1')})setTimeout(() => { console.log('setTimeout2')})Promise.resolve().then(() => { console.log('promise2')})Promise.resolve().then(() => { console.log('promise3')})console.log('script end');

結果輸出

script endpromise1promise2promise3setTimeout1setTimeout2

可以發現加入內部隊列的順序和時間雖然後差異,但是輪到內部隊列執行的時候,一定會先全部執行完內部隊列才會繼續往下走去執行外部隊列的任務。

最後我們再看一個引入了 HTML 渲染的例子:

<html> <body> <pre id="main"></pre> </body> <script> const main = document.querySelector('#main'); const callback = (i, fn) => () => { console.log(i) main.innerText += fn(i); }; let i = 1; while(i++ < 5000) { setTimeout(callback(i, (i) => '\n' + i + '<')) } while(i++ < 10000) { Promise.resolve().then(callback(i, (i) => i +',')) } console.log(i) main.innerText += '[end ' + i + ' ]\n' </script></html>

通過這個例子,我們就可以發現,渲染過程很明顯分成三個階段:

JavaScript 執行完畢 innerText 首先加上 [end 10001]

內部隊列:Promise 的 then 全部任務執行完畢,往 innerText 上追加了很長一段字符串

HTML 渲染:1 和 2 追加到 innerText 上的內容同時渲染

外部隊列:挨個執行 setTimeout 中追加到 innerText 的內容

HTML 渲染:將 4 中的內容渲染。

回到第 4 步走外部隊列的流程(內部隊列已清空)

script 事件是外部隊列

有的同學看完上面的幾個例子之後可能有個問題,為什麼 JavaScript 代碼執行到 script end 之後,是先執行內部隊列然後再執行外部隊列的任務?

這裡不得不把上文總出現過的 HTML 事件循環標準再拉出來一遍:

To coordinate events, user interaction,scripts, rendering, networking, and so forth, user agents must use event loops as described in this section...

看到這裡,大家可能就反應過來了,scripts 執行也是一個事件,我們只要歸類一下就會發現 JavaScript 的執行也是一個瀏覽器發起的外部事件。所以本質的執行順序還是:

一次外部事件

所有內部事件

HTML 渲染

回到到 1

瀏覽器與 Node.js 的事件循環差異

根據本文開頭我們討論的事件循環起源,很容易理解為什麼瀏覽器與 Node.js 的事件循環會存在差異。如果說瀏覽端是將 JavaScript 集成到 HTML 的事件循環之中,那麼 Node.js 則是將 JavaScript 集成到 libuv 的 I/O 循環之中。

簡而言之,二者都是把 JavaScript 集成到他們各自的環境中,但是 HTML (瀏覽器端) 與 libuv (服務端) 面對的場景有很大的差異。首先能直觀感受到的區別是:

事件循環的過程沒有 HTML 渲染。只剩下了外部隊列和內部隊列這兩個部分。

外部隊列的事件源不同。Node.js 端沒有了鼠標等外設但是新增了文件等 IO。

內部隊列的事件僅剩下 Promise 的 then 和 catch。

至於內在的差異,有一個很重要的地方是 Node.js (libuv)在最初設計的時候是允許執行多次外部的事件再切換到內部隊列的,而瀏覽器端一次事件循環只允許執行一次外部事件。這個經典的內在差異,可以通過以下例子來觀察:

setTimeout(()=>{ console.log('timer1'); Promise.resolve().then(function() { console.log('promise1'); });});setTimeout(()=>{ console.log('timer2'); Promise.resolve().then(function() { console.log('promise2'); });});

這個例子在瀏覽器端執行的結果是timer1->promise1->timer2->promise2,而在 Node.js 早期版本(11 之前)執行的結果卻是timer1->timer2->promise1->promise2。

究其原因,主要是因為瀏覽器端有外部隊列一次事件循環只能執行一個的限制,而在 Node.js 中則放開了這個限制,允許外部隊列中所有任務都執行完再切換到內部隊列。所以他們的情況對應為:

瀏覽器端

外部隊列:代碼執行,兩個 timeout 加入外部隊列

內部隊列:空

外部隊列:第一個 timeout 執行,promise 加入內部隊列

內部隊列:執行第一個 promise

外部隊列:第二個 timeout 執行,promise 加入內部隊列

內部隊列:執行第二個 promise

Node.js 服務端

外部隊列:代碼執行,兩個 timeout 加入外部隊列

內部隊列:空

外部隊列:兩個 timeout 都執行完

內部隊列:兩個 promise 都執行完

雖然 Node.js 的這個問題在 11 之後的版本里修復了,但是為了繼續探究這個影響,我們引入一個新的外部事件 setImmediate。這個方法目前是 Node.js 獨有的,瀏覽器端沒有。

setImmediate 的引入是為了解決 setTimeout 的精度問題,由於 setTimeout 指定的延遲時間是毫秒(ms)但實際一次時間循環的時間可能是納秒級的,所以在一次事件循環的多個外部隊列中,找到某一個隊列直接執行其中的 callback 可以得到比 setTimeout 更早執行的效果。我們繼續以開始的場景構造一個例子,並在 Node.js 10.x 的版本上執行(存在一次事件循環執行多次外部事件):

setTimeout(()=>{ console.log('setTimeout1'); Promise.resolve().then(() => console.log('promise1'));});setTimeout(()=>{ console.log('setTimeout2'); Promise.resolve().then(() => console.log('promise2'));});setImmediate(() => { console.log('setImmediate1'); Promise.resolve().then(() => console.log('promise3'));});setImmediate(() => { console.log('setImmediate2'); Promise.resolve().then(() => console.log('promise4'));});

輸出結果:

setImmediate1setImmediate2promise3promise4setTimeout1setTimeout2promise1promise2

根據這個執行結果 [3],我們可以推測出 Node.js 中的事件循環與瀏覽器類似,也是外部隊列與內部隊列的循環,而 setImmediate 在另外一個外部隊列中。

接下來,我們再來看一下當 Node.js 在與瀏覽器端對齊了事件循環的事件之後,這個例子的執行結果為:

setImmediate1promise3setImmediate2promise4setTimeout1promise1setTimeout2promise2

其中主要有兩點需要關注,一是外部列隊在每次事件循環只執行了一個,另一個是 Node.js 的固定了多個外部隊列的優先級。setImmediate 的外部隊列沒有執行完的時候,是不會執行 timeout 的外部隊列的。了解了這個點之後,Node.js 的事件循環就變得很簡單了,我們可以看下 Node.js 官方文檔中對於事件循環順序的展示:

其中 check 階段是用於執行 setImmediate 事件的。結合本文上面的推論我們可以知道,Node.js 官方這個所謂事件循環過程,其實只是完整的事件循環中 Node.js 的多個外部隊列相互之間的優先級順序。

我們可以在加入一個 poll 階段的例子來看這個循環:

const fs = require('fs');setImmediate(() => { console.log('setImmediate');});fs.readdir(__dirname, () => { console.log('fs.readdir');});setTimeout(()=>{ console.log('setTimeout');});Promise.resolve().then(() => { console.log('promise');});

輸出結果(v12.x):

promisesetTimeoutfs.readdirsetImmediate

根據輸出結果,我們可以知道梳理出來:

外部隊列:執行當前 script

內部隊列:執行 promise

外部隊列:執行 setTimeout

內部隊列:空

外部隊列:執行 fs.readdir

內部隊列:空

外部隊列:執行 check (setImmediate)

這個順序符合 Node.js 對其外部隊列的優先級定義:

timer(setTimeout)是第一階段的原因在 libuv 的文檔中有描述 —— 為了減少時間相關的系統調用(System Call)。setImmediate 出現在 check 階段是蹭了 libuv 中 poll 階段之後的檢查過程(這個過程放在 poll 中也很奇怪,放在 poll 之後感覺比較合適)。

idle, prepare對應的是 libuv 中的兩個叫做 idle 和 prepare 的句柄。由於 I/O 的 poll 過程可能阻塞住事件循環,所以這兩個句柄主要是用來觸發 poll (阻塞)之前需要觸發的回調:

由於 poll 可能 block 住事件循環,所以應當有一個外部隊列專門用於執行 I/O 的 callback ,並且優先級在 poll 以及 prepare to poll 之前。

另外我們知道網絡 IO 可能有非常多的請求同時進來,如果該階段如果無限制的執行這些 callback,可能導致 Node.js 的進程卡死該階段,其他外部隊列的代碼都沒發執行了。所以當前外部隊列在執行一定數量的 callback 之後會截斷。由於截斷的這個特性,這個專門執行 I/O callbacks 的外部隊列也叫pengding callbacks:

至此 Node.js 多個外部隊列的優先級已經演化到類似原版的程度。最後剩下的 socket close 為什麼是在 check 和 timers 之間,這個具體的權衡留待大家一起探討。

關於瀏覽器與 Node.js 的事件循環,如果你要問我那邊更加簡單,那麼我肯定會說是 Node.js 的事件循環更加簡單,因為它的多個外部隊列是可枚舉的並且優先級是固定的。但是瀏覽器端在對它的多個外部隊列做優先級排列的時候,我們一沒法枚舉,二不清楚其優先級策略,甚至瀏覽器端的事件循環可能是基於多線程或者多進程的(HTML 的標準中並沒有規定一定要使用單線程來實現事件循環)。

小結

我們都知道瀏覽器端是直面用戶的,這也意味着瀏覽器端會更加注重用戶的體驗(如可見性、可交互性),如果有一個優化效果是能夠極大的減少 JavaScript 的執行時間,但要消耗更多 HTML 渲染的時間的話,通常來說我們都不會做這個優化。通過這個例子來觀察,可以發現我們在瀏覽器並不是主要關注某件事整體所消耗的時間是否更少,而是用戶是否能快的體驗到交互(感受到 HTML 渲染)。而到了 Node.js 這個服務端 JavaScript 的場景下,這一點是明確不一樣的。在服務端為了保持應用的流暢,早期甚至出現了一次事件循環執行多個外部事件的優化方式。

很多同學在理解事件循環時感到隔靴搔癢的一個重要原因,便是把事件循環與 JavaScript 的關係弄錯了。JavaScript 的事件循環與其說是 JavaScript 的語言特性,更準確的理解應該是某個設備/端(如瀏覽器)的事件循環中與 JavaScript 交互的部分。

造成瀏覽器端與 Node.js 端事件循環的差異的一個很大的原因在於 。事件循環的設計初衷更多的是方便 JavaScript 與其嵌入環境的交互,所以事件循環如何運作,也更多的會受到 JavaScript 嵌入環境的影響,不同的設備、嵌入式環境甚至是不同的瀏覽器都會有各自的想法。

注 [1]: 關於 Task,常有人稱它為 Marcotask (宏任務),但 HTML 標準中沒有這種說法。注 [2]: 定時器操作主要依賴 JavaScript 外部的 agent 實現。所以歸類為外部事件。注 [3]: 這裡 setTimeout 在 setImmediate 後面執行的原因是因為 ms 精度的問題,想要手動 fix 這個精度可以插入一段const now = Date.now(); wihle (Date.now() < now + 1) {}即可看到 setTimeout 在 setImmediate 之前執行了。

參考文獻:

https://html.spec.whatwg.org/multipage/webappapis.html#event-loopshttps://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#what-is-the-event-loophttps://zhuanlan.zhihu.com/p/34229323https://juejin.im/post/5c337ae06fb9a049bc4cd218


在看點這裡
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()