JavaScript 異步編程指南 - 探索瀏覽器中的事件循環機制
當我了解事件循環時,嘗試去找一些規范來學習,但是查遍 EcmaScript 或 V8 發現它們沒有這個東西的定義,例如,在 v8 里有的是執行棧、堆這些信息。確實,事件循環不在這里。
后來才逐漸的了解到,當在瀏覽器環境中,關于事件循環相關定義是在 HTML 標準中,之前 HTML 規范由 whatwg 和 w3c 制定,兩個組織都有自己的不同,2019 年時兩個組織簽署了一項協議 就 HTML 和 DOM 的單一版本進行合作,最終,HTML、DOM 標準最終由 whatwg 維護。
本文的講解主要也是以 whatwg 標準為主,在 HTML Living Standard Event loops 中,這個規范定義了瀏覽器內核該如何的去實現它。
瀏覽器規范中的事件循環
事件循環定義
為了協調事件、用戶交互、腳本、渲染、網絡等,用戶代理必須使用本節描述的事件循環。每個代理有一個關聯的事件循環,它對每個代理是唯一的。
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.
從這個定義也可看出,事件循環主要是用來協調事件、網絡、JavaScript 等之間的一個運行機制,我們以 JavaScript 為出發點來看下它們之間是如何交互的。
事件循環中有一個重要的概念任務隊列,它決定了任務的執行順序。
事件循環的處理模式
規范 8.1.6.3 處理模型 定義了事件循環的處理模式,當一個事件循環存在,它就會不斷的執行以下步驟:
這些概念很晦澀難懂,簡單總結下:
- 執行 Task:任務隊列有多個任務源(DOM、UI、網絡等)隊列,從中至少選出一個可運行的任務,放到 taskQueue 中。
- 如果沒有直接跳到微任務隊列,就不會經過 2 ~ 5。
- 否則從 taskQueue 中取出第一個可執行任務做為 oldestTask 執行,對應 2 ~ 5。
- 注意,微任務不會在這里被選中,但是當一個任務隊列里含有微任務,會將該微任務加入微任務隊列。
- 執行 Microtask:執行微任務隊列,直到微任務隊列為空,這里如果調度太多的微任務也會導致阻塞。
- 更新渲染。
看到一個圖,描述一次事件循環的過程,差不多就是這個意思,主要呢,還是這三個階段:Task、Microtask、Render 下文會展開的討論。
圖片來源:https://pic2.zhimg.com/80/v2-38e53b9df2d13e9470c31101bb82dbb1_1440w.jpg
Task(Macrotask)
之前也看過很多文章關于事件循環的介紹,**大多會把 “Task” 當作 “Marcotask” 也就是宏任務來介紹,但是在規范中沒有所謂的 “Marcotask”,**因為規范里沒有這個名詞,所以我在這個標題上特意加了個括號,有很多的叫法,也有稱為外部隊列的,這其實是一個意思,如果你是學習事件循環的新朋友可能就會有疑問,為什么我搜索不到關于這個的解釋。
下文我會繼續使用規范中的名詞 “任務隊列” 來表達。
任務隊列是一個任務的集合。事件循環有一個或多個任務隊列,事件循環做的第一步是從選擇的隊列中獲取第一個可運行的任務,而不是出列第一個任務。
傳統的隊列(Queue)是一個先進先出的數據結構,總是排在第一個的先執行,而這里的隊列里面會包含一些類似于 setTimeout 這樣延遲執行的任務,所以,在規范中有這樣一句話:“Task queues are sets, not queues(翻譯為任務隊列是一個集合,不是隊列)”。
任務隊列的 任務源 主要包括以下這些:
- DOM 操作:對 DOM 操作產生的任務,例如,將元素插入文檔時以非阻塞方式發生的事情 document.body = aNewBodyElement;。
- 用戶交互:用戶交互產生的任務,例如鼠標點擊、移動產生的 Callback 任務。
- 網絡:網絡請求產生的任務,例如 fetch()。
- 歷史遍歷:此任務源用于對 history.back() 和類似 API 的調用進行排隊。
- **setTimeout、setInterval:**定時器相關任務。
例如,當 User agent 有一個管理鼠標和鍵盤事件的任務隊列和另一個其它任務源相關的任務隊列,在事件循環中相比其它任務,它會多出四分之三的時間來優先執行鼠標和鍵盤事件的任務隊列,這樣使得其它任務源的任務隊列在能夠得到處理的情況下用戶交互相關的任務可以得到更高優先級的處理,這也是提高了用戶的體驗。
Microtask
每個事件循環有一個微任務隊列,它不是一個 task queue,兩者是獨立的隊列。
什么是 Microtask(微任務)
微任務是一個簡短的函數,當創建該函數的函數執行后,并且 JavaScript 執行上下文棧為空,而控制權尚未交還給事件循環之前觸發。
當我們在一個微任務里通過 queueMicrotask(callback) 繼續向微任務隊列中創建更多的任務,對于事件循環來說,它仍會持續調用微任務直至隊列為空。
- const log = console.log;
- let i = 0;
- log('sync run start');
- runMicrotask();
- log('sync run end');
- function runMicrotask() {
- queueMicrotask(() => {
- log("microtask run, i = ", i++);
- if (i > 10) return;
- runMicrotask();
- });
- }
上面這段代碼很簡單,在主線程調用了 runMicrotask() 函數,該函數內部使用 queueMicrotask() 創建了微任務并且遞歸調用,微任務的觸發是在執行棧為空時才執行,因為里面遞歸調用每次都會生成新的微任務,事件循環也是在微任務執行完畢才執行 Task Queue 里面的 setTimeout 回調。
- sync run start
- sync run end
- microtask run, i = 0
- microtask run, i = 1
- microtask run, i = 2
- microtask run, i = 3
- microtask run, i = 4
- microtask run, i = 5
- microtask run, i = 6
- microtask run, i = 7
- microtask run, i = 8
- microtask run, i = 9
- microtask run, i = 10
通過這個示例,也可看到當調度大量的微任務也會導致和同步任務相同的性能缺陷,后面的任務得不到執行,瀏覽器的渲染工作也會被阻止。微任務這里的隊列才是真正的隊列。
創建一個 Microtask(Promise VS queueMicrotask)
在以往我們創建一個微任務很簡單,可以創建一個立即 resolve 的 Promise,每次都需要創建一個 Promise 實例,同時也帶來了額外的內存開銷,另外 Promise 中拋出的錯誤是一個非標準的 Error,如果未正常捕獲通常會得到這樣一個錯誤 UnhandledPromiseRejectionWarning:。
使用 Promise 創建一個微任務。
- const p = new Promise((resolve, reject) => {
- // reject('err')
- resolve(1);
- });
- p.then(() => {
- log('Promise microtask.')
- });
現在 Window 對象上提供了 queueMicrotask() 方法以一種標準的方式,可以安全的引入微任務,而無需使用額外的技巧,它提供了一種標準的異常。
使用 queueMicrotask() 創建一個微任務。
- queueMicrotask(() => {
- log('queueMicrotask.');
- });
在我們寫業務功能時,一個功能或方法內涉及多個異步調度的任務也是很常見的,基于 Promise 我們很熟悉,還可以使用 Async/Await 以一種同步線性的思維來書寫代碼。而 queueMicrotask 需要傳遞一個回調函數,當層級多了很容易出現嵌套。
重點是大多數情況下我們也不需要去創建微任務,過多的濫用也會造成性能問題,也許在做一些類似創建框架或庫時可能需要借助微任務來達到某些功能。這里我想到了一個經常問的面試題 “實現一個 Promise” 這個在實現時也許可以采用 queueMicrotask(),在《JavaScript 異步編程》的源碼系列,會再看到這個問題。
Microtask 總結
Microtask 總結一句話來講就是:“它是在當前執行棧尾部下一次事件循環前執行”,需要注意的是,事件循環在處理微任務時,如果微任務隊列不為空,就會繼續執行微任務,例如,使用遞歸不停的增加新的微任務,這就很糟糕了。
微任務所包含的任務源沒有明確的定義,通常包括這幾個:Promise.then()、Object.observe(已廢棄)、MutaionObserver、queueMicrotask。
更新渲染
渲染是事件循環中另一個很重要的階段,這里有一個關于 瀏覽器工作原理 的講解很好,整個渲染過程,理解下來主要是下面幾個步驟,其中 Layout、 **Paint **這些詞在下面的示例還會再次看到。
- 解析 HTML 文檔轉化為 DOM Tree,同時也會解析外部 CSS 文件及內嵌的 CSS 樣式為 CSSOM Tree。
- DOM Tree、CSSOM Tree 兩者的結合創建出另外一個樹結構 Render Tree。
- Render Tree 完畢之后進入布局(Layout)階段,為每個節點分配一個在屏幕上的坐標位置。
- 接下來根據節點坐標位置對整個頁面繪制(Paint)。
- 當我們對 DOM 元素修改之后,例如元素顏色改變、添加 DOM 節點,這時也還會觸發布局和重繪(Repaint)。
圖片來源:https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/webkitflow.png
結合 Task 與 Microtask 看渲染過程
做一個測試,使用 queueMicrotask 創建一個微任務,在自定義的 runMicrotask() 函數內部遞歸調用了 10 次,每一次里我都希望來回變換 container 這個 div 的背景色,另外還放置了一個 setTimeout 屬于 Task queue 這個是讓大家順便看下 Task queue 在事件循環中的執行順序
- <div id="container" style="width: 200px; height: 200px; background-color: red; font-size: 100px; color: #fff;">
- 0
- </div>
- <script>
- let i = 0;
- const container = document.getElementById('container');
- setTimeout(() => {});
- runMicrotask();
- function runMicrotask() {
- queueMicrotask(() => {
- if (i > 10) return;
- container.innerText = i;
- container.style.backgroundColor = i % 2 === 0 ? 'blue' : 'red';
- runMicrotask();
- });
- }
- </script>
通過 Chrome 的 Performance 記錄,運行過程,首先看下 Frame 只有一個,直接渲染出了最后的結果,如果按照上例,我們可能會覺得應該是在每個微任務執行時都會有一次渲染 blue -> red -> blue -> ...
再看一個更詳細的執行過程,可以看到在執行腳步執行后,首先運行的是微任務,對應的是我們代碼 runMicrotask() 函數,下圖紫色的是 Layout,Paint 是渲染繪制能夠看到就是在運行完所有的微任務之后執行的,在之后是下一次事件循環最后執行了 Task Queue Timer。
根據事件循環處理模式規范中的描述,渲染是在一次事件循環的微任務結束之后運行,上例差不多驗證了這個結果,這個時候有個疑問:“為什么不是在每一次微任務結束之后執行,當你把 queueMicrotask 替換成 setTimeout 也是一樣的,不會在每次事件中都去執行”。
Render 在事件循環中什么時候執行?
規范中還有這樣一段描述,得到一個信息是:在每一次的事件循環結束后不一定會執行渲染。
每一輪的事件循環如果沒有阻塞操作,這個時間是很快的,考慮到硬件刷新頻率限制和性能原因的 user agent 節流,瀏覽器的更新渲染不會在每次事件循環中被觸發。如果瀏覽器試圖達到每秒 60Hz 的刷新率,也簡稱 60fps(60 frame per second),這時繪制一個 Frame 的間隔為 16.67ms(1000/60)。如果在 16ms 內有多次 DOM 操作,也是不會渲染多次的。
如果瀏覽器無法維持 60fps 就會降低到 30fps、4fps 甚至更低。
如果想在每次事件循環中或微任務之后執行一次繪制,可以通過 requestAnimationFrame 重新渲染。
結合 requestAnimationFrame 再看渲染過程
requestAnimationFrame 是瀏覽器 window 對象下提供的一個 API,它的應用場景是告訴瀏覽器,我需要運行一個動畫。該方法會要求瀏覽器在下次重繪之前調用指定的回調函數更新動畫。
修改上述示例,加上 requestAnimationFrame() 方法。
- function runMicrotask() {
- queueMicrotask(() => {
- requestAnimationFrame(() => {
- if (i > 10) return;
- container.innerText = i;
- container.style.backgroundColor = i % 2 === 0 ? 'blue' : 'red';
- i++;
- runMicrotask();
- });
- });
- }
運行之后如下所示,每一次的元素改變都得到了重新繪制。
放大其中一個看看任務的執行情況,requestAnimationFrame 也可以看作一個任務,可以看到它在運行之后執行微任務。
Render 總結
事件循環中 Render 階段可能在一次事件循環中運行,也可能在多次事件循環后運行。它會受到瀏覽器的刷新頻率影響,如果是 60fps 那就是每間隔 16.67ms 執行一次,另一方面當瀏覽器認為更新渲染對用戶沒有影響的情況下,也會認為這不是一次必要的渲染。
總的來說它的機制和瀏覽器是相關的,了解即可,不用特別的糾結。
總結
瀏覽器中事件循環主要由 Task、Microtask、Render 三個階段組成,Task、Microtask 是我們會用到的比較多的,無論是網絡請求、還是 DOM 操作、Promise 這些大致都劃分為這兩類任務,每一輪的事件循環都會檢查這兩個任務隊列里是否有要執行的任務,等 JavaScript 上下文棧空后,先情況微任務隊列里的所有任務,之后在執行宏任務,而 Render 則不是必須的,它受瀏覽器的一些因素影響,并不一定在每次事件循環中執行。
Reference
https://yu-jack.github.io/2020/02/03/javascript-runtime-event-loop-browser/
https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
https://zhuanlan.zhihu.com/p/34229323