跟着whatwg看一遍事件循環

前言

對於單線程來說,事件循環可以說是重中之重了,它為任務分配不同的優先級,井然有序的調度。讓js解析,用戶交互,頁面渲染等互不衝突,各司其職。

我們書寫的代碼無時無刻都在和事件循環打交道,要想寫出更流暢,我們就必須深入了解事件循環,下面我們將從規範中翻譯和解讀整個流程。

以下內容來自whatwg文檔,均為個人理解,若有不對,煩請指出,我會第一時間修改,避免誤導他人!

正文

為了協調用戶操作,js執行,頁面渲染,網絡請求等事件,每個宿主中,存在事件循環這樣的角色,並且該角色在當前宿主中是唯一的。

簡單解釋一下宿主:宿主是一個ECMAScript執行上下文,一般包含執行上下文棧,運行時執行環境,宿主記錄和一個執行線程,除了這個執行線程外,其他的專屬於當前宿主。例如,某些瀏覽器在不同的tabs使用同一個執行線程。

不僅如此,事件循環又存於在各個不同場景,有瀏覽器環境下的,worker環境下的和Worklet環境下的。

Worklet是一個輕量級的web worker,可以讓開發者訪問更底層的渲染工作線,也就是說你可以通過Worklet去干預瀏覽器的渲染環境。

提到了worklet,那就順便看一個例子(需開啟服務,不要以file協議運行),通過這個例子,可以看到事件循環不同階段觸發了什麼鈎子函數:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
        <style>
            .fancy {
                background-image: paint(headerHighlight);
                display: layout(sample-layout);
                background-color: green;
            }
        </style>
    </head>
    <body>
        <h1 class="fancy">My Cool Header</h1>
        <script>
            console.log('開始');
            CSS.paintWorklet.addModule('./paint.js');
            CSS.layoutWorklet.addModule('./layout.js');

            requestAnimationFrame(() => {
                console.log('requestAnimationFrame');
            });
            Promise.resolve().then(() => {
                console.log('微任務');
            });
            setTimeout(function () {
                document.querySelector('.fancy').style.height = '150px';
                ('translateZ(0)');

                Promise.resolve().then(() => {
                    console.log('新一輪的微任務');
                });
                requestAnimationFrame(() => {
                    console.log('新一輪的requestAnimationFrame');
                });
            }, 2000);
            console.log(2);
        </script>
    </body>
</html>

// paint.js
registerPaint(
    'headerHighlight',
    class {
        static get contextOptions() {
            console.log('contextOptions');
            return {alpha: true};
        }

        paint(ctx) {
            console.log('paint函數');
        }
    }
);

// ==========================分割線

// layout.js
registerLayout(
    'sample-layout',
    class {
        async intrinsicSizes(children, edges, styleMap) {}

        async layout(children, edges, constraints, styleMap, breakToken) {
            console.log('layout階段');
        }
    }
);

事件循環有一個或多個Task隊列,每個Task隊列都是Task的一個集合。其中Task不是指我們的某個函數,而是一個上下文環境,結構如下:

  • step:一系列任務將要執行的步驟
  • source:任務來源,常用來對相關任務進行分組和系列化
  • document:與當前任務相關的document對象,如果是非window環境則為null
  • 環境配置對象:在任務期間追蹤記錄任務狀態

這裏的Task隊列不是Task,是一個集合,因為取出一個Task隊列中的Task是選擇一個可執行的Task,而不是出隊操作。

微任務隊列是一個入對出對的隊列。

這裏說明一下,Task隊列為什麼有多個,因為不同的Task隊列有不同的優先級,進而進行次序排列和調用,有沒有感覺react的fiber和這個有點類似?

舉個例子,Task隊列可以是專門負責鼠標和鍵盤事件的,並且賦予鼠標鍵盤隊列較高的優先級,以便及時響應用戶操作。另一個Task隊列負責其他任務源。不過也不要餓死任何一個task,這個後續處理模型中會介紹。

Task封裝了負責以下任務的算法:

  • Events: 由專門的Task在特定的EventTarget(一個具有監聽訂閱模式列表的對象)上分發事件對象
  • Parsing: html解析器標記一個或多個字節,並處理所有生成的結果token
  • Callbacks: 由專門的Task觸發回調函數
  • Using a resource: 當該算法獲取資源的時候,如果該階段是以非阻塞方式發生,那麼一旦部分或者全部資源可用,則由Task進行後續處理
  • Reacting to DOM manipulation: 通過dom操作觸發的任務,例如插入一個節點到document

事件循環有一個當前運行中的Task,可以為null,如果是null的話,代表着可以接受一個新的Task(新一輪的步驟)。

事件循環有微任務隊列,默認為空,其中的任務由微任務排隊算法創建。

事件循環有一個執行微任務檢查點,默認為false,用來防止微任務死循環。

微任務排隊算法:

  1. 如果未提供event loop,設置一個隱式event loop。
  2. 如果未提供document,設置一個隱式document.
  3. 創建一個Task作為新的微任務
  4. 設置setp、source、document到新的Task上
  5. 設置Task的環境配置對象為空集
  6. 添加到event loop的微任務隊列中

微任務檢查算法:

  1. 如果微任務檢查標誌為true,直接return
  2. 設置微任務檢查標誌為true
  3. 如果微任務隊里不為空(也就是說微任務添加的微任務也會在這個循環中出現,直到微任務隊列為空):
    1. 從微任務隊列中找出最老的任務(防餓死)
    2. 設置當前執行任務為這個最老的任務
    3. 執行
    4. 重置當前執行任務為null
  4. 通知環境配置對象的promise進行reject操作
  5. 清理indexdb事務(不太明白這一步,如果有讀者了解,煩請點撥一下)
  6. 設置微任務檢查標誌為false

處理模型

event loop會按照下面這些步驟進行調度:

  1. 找到一個可執行的Task隊列,如果沒有則跳轉到下面的微任務步驟
  2. 讓最老的Task作為Task隊列中第一個可執行的Task,並將其移除
  3. 將最老的Task作為event loop的可執行Task
  4. 記錄任務開始時間點
  5. 執行Task中的setp對應的步驟(上文中Task結構中的step)
  6. 設置event loop的可執行任務為null
  7. 執行微任務檢查算法
  8. 設置hasARenderingOpportunity(是否可以渲染的flag)為false
  9. 記住當前時間點
  10. 通過下面步驟記錄任務持續時間
    1. 設置頂層瀏覽器環境為空
    2. 對於每個最老Task的腳本執行環境配置對象,設置當前的頂級瀏覽器上下文到其上
    3. 報告消耗過長的任務,並附帶開始時間,結束時間,頂級瀏覽器上下文和當前Task
  11. 如果在window環境下,會根據硬件條件決定是否渲染,比如刷新率,頁面性能,頁面是否在後台,不過渲染會定期出現,避免頁面卡頓。值得注意的是,正常的刷新率為60hz,大概是每秒60幀,大約16.7ms每幀,如果當前瀏覽器環境不支持這個刷新率的話,會自動降為30hz,而不是丟幀。而李蘭其在後台的時候,聰明的瀏覽器會將這個渲染時機降為每秒4幀甚至更低,事件循環也會減少(這就是為什麼我們可以用setInterval來判斷時候能打開其他app的判斷依據的原因)。如果能渲染的話會設置hasARenderingOpportunity為true。

除此之外,還會在觸發resize、scroll、建立媒體查詢、運行css動畫等,也就是說瀏覽器幾乎大部分用戶操作都發生在事件循環中,更具體點是事件循環中的ui render部分。之後會進行requestAnimationFrame和IntersectionObserver的觸發,再之後是ui渲染

  1. 如果下麵條件都成立,那麼執行空閑階段算法,對於開發者來說就是調用window.requestIdleCallback方法
    1. 在window環境下
    2. event loop中沒有活躍的Task
    3. 微任務隊列為空
    4. hasARenderingOpportunity為false

借鑒網上的一張圖來粗略表示下整個流程

小結

上面就是整個事件循環的流程,瀏覽器就是按照這個規則一遍遍的執行,而我們要做的就是了解並適應這個規則,讓瀏覽器渲染出性能更高的頁面。

比如:

  1. 非首屏相關性能打點可以放到idle callback中執行,減少對頁面性能的損耗
  2. 微任務中遞歸添加微任務會導致頁面卡死,而不是隨着事件循環一輪輪的執行
  3. 更新元素布局的最好時機是在requestAnimateFrame中
  4. 盡量避免頻繁獲取元素布局信息,因為這會觸發強制layout(哪些屬性會導致強制layout?),影響頁面性能
  5. 事件循環有多個任務隊列,他們互不衝突,但是用戶交互相關的優先級更高
  6. resize、scroll等會伴隨事件循環中ui渲染觸發,而不是根據我們的滾動觸發,換句話說,這些操作自帶節流
  7. 等等,歡迎補充

最後感謝大家閱讀,歡迎一起探討!

提前祝大家端午節nb

參考

composite

深入探究 eventloop 與瀏覽器渲染的時序問題

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※Google地圖已可更新顯示潭子電動車充電站設置地點!!

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※別再煩惱如何寫文案,掌握八大原則!

您可能也會喜歡…