瀏覽器的 Event Loop

小蘑菇哥哥發表於2019-01-07

本文的內容是瀏覽器的事件迴圈,並不是 nodejs 的事件迴圈,不要將兩者混淆。

文章原始內容來自 Google Developer Day China 2018 的一個講座,作者 Jake Archibald,我只是記錄並翻譯一下而已。其實這不是他首次分享這個內容,因此在 youtube 上搜他的名字和 Event Loop 能搜到講座錄影,有條件的開發者可以聽聽原版

我們先從一段程式碼開始

document.body.appendChild(el)
el.style.display = 'none'
複製程式碼

這兩句程式碼先把一個元素新增到 body,然後隱藏它。從直觀上來理解,可能大部分人覺得如此操作會導致頁面閃動,因此編碼時經常會交換兩句的順序:先隱藏再新增。

但實際上兩者寫法都不會造成閃動,因為他們都是同步程式碼。瀏覽器會把同步程式碼捆綁在一起執行,然後以執行結果為當前狀態進行渲染。因此無論兩句是什麼順序,瀏覽器都會執行完成後再一起渲染,因此結果是相同的。(除非同步程式碼中有獲取當前計算樣式的程式碼,後面會提到)

從本質上看,JS 是單程式的,也就是一次只能執行一個任務(或者說方法)。與之相對人不是單程式的,我們可以一邊動手一邊動腳;一邊跑步一邊說話,因此我們很難體會“阻塞”的概念。在 JS 中,阻塞值得就是因為某個任務(方法)執行時間太長,導致其他任務難以被執行的情況。

單程式

非同步佇列

但事實上有些任務的確是需要等待一會兒再處理的,例如 setTimeout,或者非同步請求等。因此把主程式卡住等待返回會嚴重影響效率和體驗,所以 JS 還增加了非同步佇列 (task queue) 來解決這個問題。

每次碰到非同步操作,就把操作新增到非同步佇列中。等待主程式為空(即沒有同步程式碼需要執行了),就去執行非同步佇列。執行完成後再回到主程式。

setTimeout(callback, ms) 為例:

setTimeout

初始狀態:非同步開關關閉(因為非同步佇列為空)。然後 ms 毫秒後新增一個任務 T 到佇列中

setTimeout2

現在非同步佇列不為空了,非同步開關開啟,然後主程式(白色方塊)進入到非同步佇列,準備去執行黃色的 timeout 任務。

渲染過程

頁面並不是時時刻刻被渲染的,瀏覽器會有固定的節奏去渲染頁面,稱為 render steps。它內部分為 3 個小步驟,分別是

  • Structure - 構建 DOM 樹的結構
  • Layout - 確認每個 DOM 的大致位置(排版)
  • Paint - 繪製每個 DOM 具體的內容(繪製)

我們考慮如下的程式碼:

button.addEventListener('click', () => {
  while(true);
})
複製程式碼

點選後會導致非同步佇列永遠執行,因此不單單主程式,渲染過程也同樣被阻塞而無法執行,因此頁面無法再選中(因為選中時頁面表現有所變化,文字有背景色,滑鼠也變成 text),也無法再更換內容。(但滑鼠卻可以動!)

非同步佇列阻塞

如果我們把程式碼改成這樣

function loop() {
  setTimeout(loop, 0)
}
loop()
複製程式碼

每個非同步任務的執行效果都是加入一個新的非同步任務,新的非同步任務將在下一次被執行,因此就不會存在阻塞。主程式和渲染過程都能正常進行。

requestAnimationFrame

是一個特別的非同步任務,只是註冊的方法不加入非同步佇列,而是加入渲染這一邊的佇列中,它在渲染的三個步驟之前被執行。通常用來處理渲染相關的工作。

raf

我們來看一下 setTimeoutrequestAnimationFrame 的差別。假設我們有一個元素 box,並且有一個 moveBoxForwardOnePixel 方法,作用是讓這個元素向右移動 1 畫素。

// 方法 1
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback)
}
callback()

// 方法 2
function callback() {
  moveBoxForwardOnePixel();
  setTimeout(callback, 0)
}
callback()
複製程式碼

有這樣兩種方法來讓 box 移動起來。但實際測試發現,使用 setTimeout 移動的 box 要比 requestAnimationFrame 速度快得多。這表明單位時間內 callback 被呼叫的次數是不一樣的。

這是因為 setTimeout 在每次執行結束時都把自己新增到非同步佇列。等渲染過程的時候(不是每次執行非同步佇列都會進到渲染迴圈)非同步佇列已經執行過很多次了,所以渲染部分會一下會更新很多畫素,而不是 1 畫素。requestAnimationFrame 只在渲染過程之前執行,因此嚴格遵守“執行一次渲染一次”,所以一次只移動 1 畫素,是我們預期的方式。

如果在低端環境相容,常規也會寫作 setTimeout(callback, 1000 / 60) 來大致模擬 60 fps 的情況,但本質上 setTimeout 並不適合用來處理渲染相關的工作。因此和渲染動畫相關的,多用 requestAnimationFrame,不會有掉幀的問題(即某一幀沒有渲染,下一幀把兩次的結果一起渲染了)

同步程式碼的合併

開頭說過,一段同步程式碼修改同一個元素的屬性,瀏覽器會直接優化到最後一個。例如

box.style.display = 'none'
box.style.display = 'block'
box.style.display = 'none'
複製程式碼

瀏覽器會直接隱藏元素,相當於只執行了最後一句。這是一種優化策略。

但有時候也會給我們造成困擾。例如如下程式碼:

box.style.transform = 'translateX(1000px)'
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
複製程式碼

我們的本意是從讓 box 元素的位置從 0 一下子 移動到 1000,然後 動畫移動 到 500。

但實際情況是從 0 動畫移動 到 500。這也是由於瀏覽器的合併優化造成的。第一句設定位置到 1000 的程式碼被忽略了。

解決方法有 2 個:

  1. 我們剛才提過的 requestAnimationFrame。思路是讓設定 box 的初始位置(第一句程式碼)在同步程式碼執行;讓設定 box 的動畫效果(第二句程式碼)和設定 box 的重點位置(第三句程式碼)放到下一幀執行。

    但要注意,requestAnimationFrame 是在渲染過程 之前 執行的,因此直接寫成

    box.style.transform = 'translateX(1000px)'
    requestAnimationFrame(() => {
      box.style.tranition = 'transform 1s ease'
      box.style.transform = 'translateX(500px)'
    })
    複製程式碼

    是無效的,因為這樣這三句程式碼依然是在同一幀中出現。那如何讓後兩句程式碼放到下一幀呢?這時候我們想到一句話:沒有什麼問題是一個 requestAnimationFrame 解決不了的,如果有,那就用兩個:

    box.style.transform = 'translateX(1000px)'
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        box.style.transition = 'transform 1s ease'
        box.style.transform = 'translateX(500px)'
      })
    })
    複製程式碼

    在渲染過程之前,再一次註冊 requestAnimationFrame,這就能夠讓後兩句程式碼放到下一幀去執行了,問題解決。(當然程式碼看上去有點奇怪)

  2. 你之所以沒有在平時的程式碼中看到這樣奇葩的巢狀用法,是因為還有更簡單的實現方式,並且同樣能夠解決問題。這個問題的根源在於瀏覽器的合併優化,那麼打斷它的優化,就能解決問題。

    box.style.transform = 'translateX(1000px)'
    getComputedStyle(box) // 虛擬碼,只要獲取一下當前的計算樣式即可
    box.style.transition = 'transform 1s ease'
    box.style.transform = 'translateX(500px)'
    複製程式碼

Microtasks

現在我們要引入“第三個”非同步佇列,叫做 Microtasks (規範中也稱為 Jobs)。

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.

簡單來說, Microtasks 就是在 當次 事件迴圈的 結尾 立刻執行 的任務。Promise.then() 內部的程式碼就屬於 microtasks。相對而言,之前的非同步佇列 (Task queue) 也叫做 macrotasks,不過一般還是簡稱為 tasks。

function callback() {
  Promise.resolve().then(callback)
}
callback()
複製程式碼

這段程式碼是在執行 microtasks 的時候,又把自己新增到了 microtasks 中,看上去是和那個 setTimeout 內部繼續 setTimeout 類似。但實際效果卻和第一段 addEventListener 內部 while(true) 一樣,是會阻塞主程式的。這和 microtasks 內部的執行機制有關。

我們現在已經有了 3 個非同步佇列了,它們是

  • Tasks (in setTimeout)
  • Animation callbacks (in requestAnimationFrame)
  • Microtasks (in Promise.then)

他們的執行特點是:

  • Tasks 只執行一個。執行完了就進入主程式,主程式可能決定進入其他兩個非同步佇列,也可能自己執行到空了再回來。

    補充:對於“只執行一個”的理解,可以考慮設定 2 個相同時間的 timeout,兩個並不會一起執行,而依然是分批的。

  • Animation callbacks 執行佇列裡的全部任務,但如果任務本身又新增 Animation callback 就不會當場執行了,因為那是下一個迴圈

    補充:同 Tasks,可以考慮連續呼叫兩句 requestAnimationFrame,它們會在同一次事件迴圈內執行,有別於 Tasks

  • Microtasks 直接執行到空佇列才繼續。因此如果任務本身又新增 Microtasks,也會一直執行下去。所以上面的例子才會產生阻塞。

    補充:因為是當次執行,因此如果既設定了 setTimeout(0) 又設定了 Promise.then(),優先執行 Microtasks。

一段神奇的程式碼

考慮如下的程式碼:

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 1'))
  console.log('listener 1')
})

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 2'))
  console.log('listener 2')
})
複製程式碼

在瀏覽器上執行後點選按鈕,會按順序列印

listener 1
microtask 1
listener 2
microtask 2
複製程式碼

但如果在上面程式碼的最後加上 button.click() 列印順序會 有所區別

listener 1
listener 2
microtask 1
microtask 2
複製程式碼

主要是 listener 2microtask 1 次序的問題,原因如下:

  • 使用者直接點選的時候,瀏覽器先後觸發 2 個 listener。第一個 listener 觸發完成 (listener 1) 之後,佇列空了,就先列印了 microtask 1。然後再執行下一個 listener。重點在於瀏覽器並不實現知道有幾個 listener,因此它發現一個執行一個,執行完了再看後面還有沒有。

  • 而使用 button.click() 時,瀏覽器的內部實現是把 2 個 listener 都同步執行。因此 listener 1 之後,執行佇列還沒空,還要繼續執行 listener 2 之後才行。所以 listener 2 會早於 microtask 1重點在於瀏覽器的內部實現,click 方法會先採集有哪些 listener,再依次觸發。

這個差別最大的應用在於自動化測試指令碼。在這裡可以看出,使用自動化指令碼測試和真正的使用者操作還是有細微的差別。如果程式碼中有類似的情況,要格外注意了。

針對其他瀏覽器如何表現這個問題,在原作者的一篇 2015 年的部落格中有所提及。其中設計的 case 更加完整,但當時各種瀏覽器給出了不一樣的輸出結果,因此他還在部落格中分析了一波誰對誰錯。直到今天雖然沒有標準指明應該怎樣,但所有瀏覽器都以如上分析的方式執行。

再來兩個測試題

第一題:

console.log('Start')

setTimeout(() => console.log('Timeout 1'), 0)
setTimeout(() => console.log('Timeout 2'), 0)

Promise.resolve().then(() => {
  for(let i=0; i<100000; i++) {}
  console.log('Promise 1')
})
Promise.resolve().then(() => console.log('Promise 2'))

console.log('End');
複製程式碼

第二題:(在瀏覽器上點選按鈕)

let button = document.querySelector('#button');

button.addEventListener('click', function CB1() {
  console.log('Listener 1');

  setTimeout(() => console.log('Timeout 1'))

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

button.addEventListener('click', function CB1() {
  console.log('Listener 2');

  setTimeout(() => console.log('Timeout 2'))

  Promise.resolve().then(() => console.log('Promise 2'))
});
複製程式碼

公佈答案:

  • 第一題: Start, End, Promise 1, Promise 2, Timeout 1, Timeout 2
  • 第二題: Listener 1, Promise 1, Listener 2, Promise 2, Timeout 1, Timeout 2

這兩個題目來自一篇相關文章(連結在最後),其中還有詳細的分析,我這裡就不重複了。

相關文章

JavaScript: How is callback execution strategy for promises different than DOM events callback?

相關文章