bcjohn's blog
Javascript 中的 event loop 及瀏覽器渲染機制
發布於: 2024-01-20 更新於: 2024-02-18 分類於: Javascript 閱讀次數: 

此為 event loop 系列文章 - 第 1 篇:

  1. Javascript 中的 event loop 及瀏覽器渲染機制
  2. 從程式碼角度來看 event loop
  3. 使用原生的 queueMicrotask 處理微任務
  4. Vue.nextTick() 中的 event loop

前言

event loopjs 中一個蠻重要的概念,雖然以前知道 宏任務 (macrotask)微任務 (microtask) 優先級上的差別,但似乎一直不知道 event loop 與瀏覽器渲染間的關係,而大部分的文章都只單獨介紹 event loop 或是 瀏覽器渲染流程,所以寫了這篇文章統整 event loop 與 瀏覽器渲染 間的關聯性

event loop 的作用

以前端來說,在瀏覽器中使用者的畫面點擊、呼叫後端 api、window.addEventListener,這些全部都由 js 執行,但 js 是單線程的程式語言,同一時間就是只能做一件事,所以當事情同時發生時,需要讓 js 知道哪行程式碼是先被執行的、而接下來又該執行哪段程式碼,這個負責安排執行順序的東西,基本上就叫做 event loop

event loop 的宿主環境

嚴格來說 event loop 並不是定義在 js 上的東西,而是根據不同環境有不同的 event loop 規則,以瀏覽器來說就會有瀏覽器的 event loop 規則,而 nodejs 的話又會有另一套 nodejsevent loop 規則,今天這篇文章只探討瀏覽器的 event loop

Javascript 中的程式執行

js 中的程式,按照執行時機區分的話可以分為 同步執行 (sync) 以及 異步執行 (async)同步執行 (sync) 的程式碼會被丟到 js 主線程(main thread) 中,從頭到尾不間斷的執行完,而 異步執行 (async) 的程式則是指那些之後才會執行的程式 (ex. 呼叫 api 會等伺服器資料回應後才執行後續的操作),但特別需要強調的一點是,js 是單線程的程式,不論是 同步執行 (sync) 或是 **異步執行 (async)**,程式碼一定會在某一刻交由 js 主線程(main thread) 執行。

同步執行 (sync)

同步執行的程式會將每一段程式碼依序放入到 堆疊(stack) 裡面,再從最上層的 堆疊(stack) 挑出要運行的任務放入 js 主線程(main thread) 中執行

範例:

1
2
3
4
5
6
function A() {
console.log('A');
}

console.log('main script');
A();
  1. 首先整個 main script 放入 堆疊(stack) 的最底層
  2. 堆疊(stack) 最上層需執行的任務 main script 抓出,放入 js 主線程(main thread) 執行
  3. main script 執行到第 5 行,印出 main script
  4. main script 執行到第 6 行遇到 函式A,將 函式A 放入 堆疊(stack)
  5. 將第 1-3 行的 函式A 放入 js 主線程(main thread) 執行
  6. 函式A 執行完畢,印出 A,將 函式 A 移出 堆疊(stack)
  7. 接續執行 main script
  8. 第 6 行後整個 main script 執行完成,將 main script 移出 堆疊(stack)

圖片來源: [學習筆記] JavaScript 的 Event Loop, Stack, Queue

異步執行 (async)

除了一氣呵成的同步執行程式以外,js 也有所謂的異步執行程式 (ex. call apisetTimeout),以下面的例子來看,假設我們寫的程式有很多這種異步邏輯,每一個區塊看起來都很像馬上就要執行,那麼瀏覽器要怎麼判斷每一段程式碼的執行順序呢? 此時需要的就是 event loop 來安排這些異步任務程式碼的執行順序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
setTimeout(() => {
console.log('timeout');
}, 0)

Promise.resolve().then(() => {
console.log('promise');
})

async function async() {
console.log("async");
}
await async1();

console.log('main script');

異步執行的任務

這些異步執行的程式,可以分為兩種: 宏任務 (macrotask)微任務 (microtask)

- 宏任務 (macrotask)

在 HTML spec 的規範中,宏任務 (macrotask) 其實叫做 任務(task) ,但為了跟 微任務 (microtask) 有一個相對應的區分所以大部分的教學文章都加上了 宏(macro) 這個字,以下幾種方法都屬於 宏任務 (macrotask)

  • <script src="..."> 方式載入的程式碼
  • setTimeout, setInterval
  • 使用者交互事件 (ex. 滑鼠 click, 鍵盤 keydown 事件)
- 微任務 (microtask)

目前有的 微任務 (microtask)

  • promise
  • DOM mutations (MutaionObserver)

而不論是 宏任務 (macrotask) 或是 微任務 (microtask) 它們都是以 佇列(queue) 的資料結構存放,存放這兩種任務的 佇列(queue) 名稱,後面我分別稱呼它們為 宏任務佇列 (macrotask queue)微任務佇列 (microtask queue)

圖片來源: Simple Explanation of Stack and Queue with JavaScript

範例:

1
2
3
4
5
6
7
setTimeout(function timeoutCallback() {
console.log('timeout')
}, 0)

Promise.resolve().then(function promiseCallback() {
console.log('promise');
});
  1. 第 1 行的 setTimeout 屬於 宏任務 (macrotask) ,所以 timeoutCallback 會被放入 宏任務佇列 (macrotask queue) 中等待之後執行
  2. 第 5 行的 Promise.resolve() 屬於 微任務 (microtask) ,所以 promiseCallback 會被放入 微任務佇列 (microtask queue) 中等待之後執行

好的,目前為止我們知道了這些異步執行的程式們可以分為兩類 宏任務 (macrotask) 以及 微任務 (microtask) ,並且會被加入到各自的 佇列(queue) ,下一節我們會再進一步瞭解這些被加入到 佇列(queue) 中的任務們如何藉由 event loop 的安排最終丟入到 js 主線程(main thread) 執行

event loop 如何安排任務的執行順序


圖片來源:

事件迴圈的基本概念
Javascript MicroTask vs MacroTask Queue - Visually Explained Through An Animation

搭配以上這兩張圖,我們可以看到 宏任務 (macrotask)微任務 (microtask) 實際上用兩個不同的 queue 控制著任務的先後執行

  • 宏任務 (macrotask) 會被放到 宏任務佇列 (macrotask queue)
  • 微任務 (microtask) 會被放到 微任務佇列 (microtask queue)

宏任務佇列 (macrotask queue)微任務佇列 (microtask queue) 會藉由 event loop 的安排,將任務依序丟往 堆疊(stack) 中,最後 js 主線程(main thread) 再以 同步執行 (sync) 的方式將 堆疊(stack) 中的任務執行完成

而從 event loop 的角度來看,是以下面的這個邏輯決定怎麼把任務丟到 堆疊(stack) 中執行:

  1. 檢查 宏任務佇列 (macrotask queue) 裡是否有 宏任務 (macrotask) 需要被執行:
    1-1. 如果有的話就將最前面的 宏任務 (macrotask) 拿出來丟到 堆疊(stack) 執行,並前往 步驟 2.
    1-2. 如果沒有的話,直接前往 步驟 2.
  2. 檢查 微任務佇列 (microtask queue) 裡是否有 微任務 (microtask) 需要被執行:
    2-1. 如果有的話將 目前所有微任務 (microtask) 依序拿出來丟到 堆疊(stack) 執行,並前往 步驟 3.
    2-2. 如果沒有的話,直接前往 步驟 3.
  3. 是否需要渲染 UI 畫面:
    3-1. 需要渲染的話,進行渲染畫面並回到 步驟 1.
    3-2. 不需要渲染的話,直接回到 步驟 1.

下面我們直接用程式碼來看 event loop 運行的邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
setTimeout(() => {
console.log('timeout1')
}, 0)
setTimeout(() => {
console.log('timeout2')
}, 0)

Promise.resolve().then(() => {
console.log('promise1');
});
Promise.resolve().then(() => {
console.log('promise2');
});
Promise.resolve().then(() => {
console.log('promise3');
});

分析:

  1. 第 1 行的 setTimeout 後的 callback function 最初被加入到 宏任務佇列 (macrotask queue) 裡,是第一個 宏任務 (macrotask) ,等待之後執行
  2. 第 4 行的 setTimeout 後的 callback function 會接續加入到 宏任務佇列 (macrotask queue) 中,是第二個 宏任務 (macrotask) ,等待之後執行
  3. 第 8 行的 promise.resolve 後的 callback function 會加入到 微任務佇列 (microtask queue) 裡,是第一個 微任務 (microtask) ,等待之後執行
  4. 第 11 行的 promise.resolve 後的 callback function 會加入到 微任務佇列 (microtask queue) 裡,是第二個 微任務 (microtask) ,等待之後執行
  5. 第 14 行的 promise.resolve 後的 callback function 會加入到 微任務佇列 (microtask queue) 裡,是第三個 微任務 (microtask) ,等待之後執行

結果:
如右上角的 .gif 動圖所示,即使兩個 setTimeout 在程式上的順序是先寫在前面的,但由於 微任務 (microtask) 的優先級高於 宏任務 (macrotask) ,所以 Promise.resolve() 後的結果會先被執行,最後才執行 setTimeout宏任務 (macrotask)

1
2
3
4
5
'promise1'
'promise2'
'promise3'
'timeout1'
'timeout2'

event loop 之後 - 渲染畫面

接著讓我們來看看 event loop 之後,瀏覽器怎麼進行畫面渲染:

圖片來源: CSS runtime performance

上圖左側的 JavaScript 代表了我們上面所討論的 宏任務 (macrotask)微任務 (microtask) ,當 JavaScript 執行完成後,瀏覽器開始準備渲染畫面,畫面的渲染大致上切分成四個部分:

  1. Style
    計算每個 DOM element 的樣式 (color, margin 等)

  2. Layout
    上面的 Style 計算完後,瀏覽器會知道每個 DOM element 該有的樣式,接著就會開始計算這些 element 佔用的空間,這包含位置、寬度等等

  3. Paint
    開始針對網頁中的每個像素點進行繪製,包括顏色、陰影等等

  4. Composite
    最後一步是根據不同的 圖層 來安排各個 element 的位置

  • requestAnimationFrame
    最後特別提到的是 requestAnimationFrame,他的執行時機在 Style 之前,可以保證在每一幀的畫面渲染前執行,常用來做動畫效果或網頁 3D 場景的處理
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const dom = document.querySelector('div');

    function animation() {
    if (i > 200) return;
    dom.style.marginLeft = `${i}px`;
    window.requestAnimationFrame(animation);
    i++;
    }

    window.requestAnimationFrame(animation);
    現在大部分的螢幕通常是以 60Hz 的頻率更新畫面,如此可以讓人眼察覺不到每一幀畫面的差別,從而做到順暢的動畫,因此瀏覽器中在每一幀更新畫面的時間約為 1/60 = 17ms,所以 animation 函式大約會每 17ms 就執行一次

event loop 的全貌

最後讓我們來看完整的 event loop

圖片來源: Browser Event loop: micro and macro tasks, call stack, render queue: layout, paint, composite

整個完整的 event loop + 瀏覽器的畫面渲染流程如上圖所示,在一整圈的執行中,瀏覽器會盡量以 60Hz 的頻率渲染畫面,也就是說每一幀(圈)更新畫面的時間約為 1/60 = 17ms

  1. 最初從圖中左側開始,Javascript 會從 宏任務佇列 (macrotask queue) 挑出第一個 宏任務 (macrotask) 執行,接著執行完 微任務佇列 (microtask queue) 中所有的 微任務 (microtask)

  2. 在準備進行瀏覽器渲染前會執行 requestAnimationFrame

  3. 進入瀏覽器渲染的流程:Style => Layout => Paint => Composite

  4. 渲染畫面後重新回到 步驟 1. 循環執行這一整圈的 event loop

參考資料