瀏覽器的 Event Loop

本文的內容是瀏覽器的事件循環,並非 nodejs 的事件循環,不要將二者混淆。javascript

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

咱們先從一段代碼開始node

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

這兩句代碼先把一個元素添加到 body,而後隱藏它。從直觀上來理解,可能大部分人以爲如此操做會致使頁面閃動,所以編碼時常常會交換兩句的順序:先隱藏再添加。react

但實際上二者寫法都不會形成閃動,由於他們都是同步代碼。瀏覽器會把同步代碼捆綁在一塊兒執行,而後以執行結果爲當前狀態進行渲染。所以不管兩句是什麼順序,瀏覽器都會執行完成後再一塊兒渲染,所以結果是相同的。(除非同步代碼中有獲取當前計算樣式的代碼,後面會提到)promise

從本質上看,JS 是單進程的,也就是一次只能執行一個任務(或者說方法)。與之相對人不是單進程的,咱們能夠一邊動手一邊動腳;一邊跑步一邊說話,所以咱們很難體會「阻塞」的概念。在 JS 中,阻塞值得就是由於某個任務(方法)執行時間太長,致使其餘任務難以被執行的狀況。瀏覽器

單進程

異步隊列

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

每次碰到異步操做,就把操做添加到異步隊列中。等待主進程爲空(即沒有同步代碼須要執行了),就去執行異步隊列。執行完成後再回到主進程。app

setTimeout(callback, ms) 爲例:dom

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?

相關文章
相關標籤/搜索