深刻解析你不知道的 EventLoop 和瀏覽器渲染、幀動畫、空閒回調(動圖演示)

前言

關於 Event Loop 的文章不少,可是有不少只是在講「宏任務」、「微任務」,我先提出幾個問題:css

  1. 每一輪 Event Loop 都會伴隨着渲染嗎?
  2. requestAnimationFrame 在哪一個階段執行,在渲染前仍是後?在 microTask 的前仍是後?
  3. requestIdleCallback 在哪一個階段執行?如何去執行?在渲染前仍是後?在 microTask 的前仍是後?
  4. resizescroll 這些事件是什麼時候去派發的。

這些問題並非刻意想刁難你,若是你不知道這些,那你可能並不能在遇到一個動畫需求的時候合理的選擇 requestAnimationFrame,你可能在作一些需求的時候想到了 requestIdleCallback,可是你不知道它運行的時機,只是膽戰心驚的去用它,祈禱不要出線上 bug。html

這也是本文想要從規範解讀入手,深挖底層的動機之一。本文會酌情從規範中排除掉一些比較晦澀難懂,或者和主流程不太相關的概念。更詳細的版本也能夠直接去讀這個規範,不過比較費時費力。前端

事件循環

咱們先依據HTML 官方規範從瀏覽器的事件循環講起,由於剩下的 API 都在這個循環中進行,它是瀏覽器調度任務的基礎。vue

定義

爲了協調事件,用戶交互,腳本,渲染,網絡任務等,瀏覽器必須使用本節中描述的事件循環。git

流程

  1. 從任務隊列中取出一個宏任務並執行。
  2. 檢查微任務隊列,執行並清空微任務隊列,若是在微任務的執行中又加入了新的微任務,也會在這一步一塊兒執行。
  3. 進入更新渲染階段,判斷是否須要渲染,這裏有一個 rendering opportunity 的概念,也就是說不必定每一輪 event loop 都會對應一次瀏覽 器渲染,要根據屏幕刷新率、頁面性能、頁面是否在後臺運行來共同決定,一般來講這個渲染間隔是固定的。(因此多個 task 極可能在一次渲染之間執行)github

    • 瀏覽器會盡量的保持幀率穩定,例如頁面性能沒法維持 60fps(每 16.66ms 渲染一次)的話,那麼瀏覽器就會選擇 30fps 的更新速率,而不是偶爾丟幀。
    • 若是瀏覽器上下文不可見,那麼頁面會下降到 4fps 左右甚至更低。
    • 若是知足如下條件,也會跳過渲染:web

      1. 瀏覽器判斷更新渲染不會帶來視覺上的改變。
      2. map of animation frame callbacks 爲空,也就是幀動畫回調爲空,能夠經過 requestAnimationFrame 來請求幀動畫。
  4. 若是上述的判斷決定本輪不須要渲染,那麼下面的幾步也不會繼續運行算法

    This step enables the user agent to prevent the steps below from running for other reasons, for example, to ensure certain tasks are executed immediately after each other, with only microtask checkpoints interleaved (and without, e.g., animation frame callbacks interleaved). Concretely, a user agent might wish to coalesce timer callbacks together, with no intermediate rendering updates.
    有時候瀏覽器但願兩次「定時器任務」是合併的,他們之間只會穿插着 microTask的執行,而不會穿插屏幕渲染相關的流程(好比 requestAnimationFrame,下面會寫一個例子)。
  5. 對於須要渲染的文檔,若是窗口的大小發生了變化,執行監聽的 resize 方法。
  6. 對於須要渲染的文檔,若是頁面發生了滾動,執行 scroll 方法。
  7. 對於須要渲染的文檔,執行幀動畫回調,也就是 requestAnimationFrame 的回調。(後文會詳解)
  8. 對於須要渲染的文檔, 執行 IntersectionObserver 的回調。
  9. 對於須要渲染的文檔,從新渲染繪製用戶界面。
  10. 判斷 task隊列microTask隊列是否都爲空,若是是的話,則進行 Idle 空閒週期的算法,判斷是否要執行 requestIdleCallback 的回調函數。(後文會詳解)

對於resizescroll來講,並非到了這一步纔去執行滾動和縮放,那豈不是要延遲不少?瀏覽器固然會馬上幫你滾動視圖,根據CSSOM 規範所講,瀏覽器會保存一個 pending scroll event targets,等到事件循環中的 scroll這一步,去派發一個事件到對應的目標上,驅動它去執行監聽的回調函數而已。resize也是同理。segmentfault

能夠在這個流程中仔細看一下「宏任務」、「微任務」、「渲染」之間的關係。api

多任務隊列

task 隊列並非咱們想象中的那樣只有一個,根據規範裏的描述:

An event loop has one or more task queues. For example, a user agent could have one task queue for mouse and key events (to which the user interaction task source is associated), and another to which all other task sources are associated. Then, using the freedom granted in the initial step of the event loop processing model, it could give keyboard and mouse events preference over other tasks three-quarters of the time, keeping the interface responsive but not starving other task queues. Note that in this setup, the processing model still enforces that the user agent would never process events from any one task source out of order.

事件循環中可能會有一個或多個任務隊列,這些隊列分別爲了處理:

  1. 鼠標和鍵盤事件
  2. 其餘的一些 Task

瀏覽器會在保持任務順序的前提下,可能分配四分之三的優先權給鼠標和鍵盤事件,保證用戶的輸入獲得最高優先級的響應,而剩下的優先級交給其餘 Task,而且保證不會「餓死」它們。

這個規範也致使 Vue 2.0.0-rc.7 這個版本 nextTick 採用了從微任務 MutationObserver 更換成宏任務 postMessage 而致使了一個 Issue

目前因爲一些「未知」的緣由,jsfiddle 的案例打不開了。簡單描述一下就是採用了 task 實現的 nextTick,在用戶持續滾動的狀況下 nextTick 任務被延後了好久纔去執行,致使動畫跟不上滾動了。

迫於無奈,尤大仍是改回了 microTask 去實現 nextTick,固然目前來講 promise.then 微任務已經比較穩定了,而且 Chrome 也已經實現了 queueMicroTask 這個官方 API。不久的將來,咱們想要調用微任務隊列的話,也能夠節省掉實例化 Promise 在開銷了。

從這個 Issue 的例子中咱們能夠看出,稍微去深刻了解一下規範仍是比較有好處的,以避免在遇到這種比較複雜的 Bug 的時候一臉懵逼。

下面的章節中我們來詳細聊聊 requestIdleCallbackrequestAnimationFrame

requestAnimationFrame

如下內容中 requestAnimationFrame簡稱爲 rAF

在解讀規範的過程當中,咱們發現 requestAnimationFrame 的回調有兩個特徵:

  1. 在從新渲染前調用。
  2. 極可能在宏任務以後不調用。

咱們來分析一下,爲何要在從新渲染前去調用?由於 rAF 是官方推薦的用來作一些流暢動畫所應該使用的 API,作動畫不可避免的會去更改 DOM,而若是在渲染以後再去更改 DOM,那就只能等到下一輪渲染機會的時候才能去繪製出來了,這顯然是不合理的。

rAF在瀏覽器決定渲染以前給你最後一個機會去改變 DOM 屬性,而後很快在接下來的繪製中幫你呈現出來,因此這是作流暢動畫的不二選擇。下面我用一個 setTimeout的例子來對比。

閃爍動畫

假設咱們如今想要快速的讓屏幕上閃爍 兩種顏色,保證用戶能夠觀察到,若是咱們用 setTimeout 來寫,而且帶着咱們長期的誤解「宏任務之間必定會伴隨着瀏覽器繪製」,那麼你會獲得一個預料以外的結果。

setTimeout(() => {
  document.body.style.background = "red"
  setTimeout(() => {
    document.body.style.background = "blue"
  })
})

能夠看出這個結果是很是不可控的,若是這兩個 Task 之間正好遇到了瀏覽器認定的渲染機會,那麼它會重繪,不然就不會。因爲這倆宏任務的間隔週期過短了,因此很大機率是不會的。

若是你把延時調整到 17ms 那麼重繪的機率會大不少,畢竟這個是通常狀況下 60fps 的一個指標。可是也會出現不少不繪製的狀況,因此並不穩定。

若是你依賴這個 API 來作動畫,那麼就極可能會形成「掉幀」。

接下來咱們換成 rAF 試試?咱們用一個遞歸函數來模擬 10 次顏色變化的動畫。

let i = 10
let req = () => {
  i--
  requestAnimationFrame(() => {
    document.body.style.background = "red"
    requestAnimationFrame(() => {
      document.body.style.background = "blue"
      if (i > 0) {
        req()
      }
    })
  })
}

req()

這裏因爲顏色變化太快,gif 錄製軟件沒辦法截出這麼高幀率的顏色變換,因此各位能夠放到瀏覽器中本身執行一下試試,我這邊直接拋結論,瀏覽器會很是規律的把這 10 組也就是 20 次顏色變化繪製出來,能夠看下 performance 面板記錄的表現:

定時器合併

在第一節解讀規範的時候,第 4 點中提到了,定時器宏任務可能會直接跳過渲染。

按照一些常規的理解來講,宏任務之間理應穿插渲染,而定時器任務就是一個典型的宏任務,看一下如下的代碼:

setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})

queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))

從直覺上來看,順序是否是應該是:

mic
mic
sto
rAF
sto
rAF

呢?也就是每個宏任務以後都緊跟着一次渲染。

實際上不會,瀏覽器會合並這兩個定時器任務:

mic
mic
sto
sto
rAF
rAF

requestIdleCallback

草案解讀

如下內容中 requestIdleCallback簡稱爲 rIC

咱們都知道 requestIdleCallback 是瀏覽器提供給咱們的空閒調度算法,關於它的簡介能夠看 MDN 文檔,意圖是讓咱們把一些計算量較大可是又沒那麼緊急的任務放到空閒時間去執行。不要去影響瀏覽器中優先級較高的任務,好比動畫繪製、用戶輸入等等。

React 的時間分片渲染就想要用到這個 API,不過目前瀏覽器支持的不給力,他們是本身去用 postMessage 實現了一套。

渲染有序進行

首先看一張圖,很精確的描述了這個 API 的意圖:

固然,這種有序的 瀏覽器 -> 用戶 -> 瀏覽器 -> 用戶 的調度基於一個前提,就是咱們要把任務切分紅比較小的片,不能說瀏覽器把空閒時間讓給你了,你去執行一個耗時 10s 的任務,那確定也會把瀏覽器給阻塞住的。這就要求咱們去讀取 rIC 提供給你的 deadline 裏的時間,去動態的安排咱們切分的小任務。瀏覽器信任了你,你也不能辜負它呀。

渲染長期空閒


還有一種狀況,也有可能在幾幀的時間內瀏覽器都是空閒的,並無發生任何影響視圖的操做,它也就不須要去繪製頁面:
這種狀況下爲何仍是會有 50msdeadline 呢?是由於瀏覽器爲了提早應對一些可能會突發的用戶交互操做,好比用戶輸入文字。若是給的時間太長了,你的任務把主線程卡住了,那麼用戶的交互就得不到迴應了。50ms 能夠確保用戶在無感知的延遲下獲得迴應。

MDN 文檔中的幕後任務協做調度 API 介紹的比較清楚,來根據裏面的概念作個小實驗:

屏幕中間有個紅色的方塊,把 MDN 文檔中requestAnimationFrame的範例部分的動畫代碼直接複製過來。

草案中還提到:

  1. 當瀏覽器判斷這個頁面對用戶不可見時,這個回調執行的頻率可能被下降到 10 秒執行一次,甚至更低。這點在解讀 EventLoop 中也有說起。
  2. 若是瀏覽器的工做比較繁忙的時候,不能保證它會提供空閒時間去執行 rIC 的回調,並且可能會長期的推遲下去。因此若是你須要保證你的任務在必定時間內必定要執行掉,那麼你能夠給 rIC 傳入第二個參數 timeout
    這會強制瀏覽器無論多忙,都在超過這個時間以後去執行 rIC 的回調函數。因此要謹慎使用,由於它會打斷瀏覽器自己優先級更高的工做。
  3. 最長期限爲 50 毫秒,是根據研究得出的,研究代表,人們一般認爲 100 毫秒內對用戶輸入的響應是瞬時的。 將閒置截止期限設置爲 50ms 意味着即便在閒置任務開始後當即發生用戶輸入,瀏覽器仍然有剩餘的 50ms 能夠在其中響應用戶輸入而不會產生用戶可察覺的滯後。
  4. 每次調用 timeRemaining() 函數判斷是否有剩餘時間的時候,若是瀏覽器判斷此時有優先級更高的任務,那麼會動態的把這個值設置爲 0,不然就是用預先設置好的 deadline - now 去計算。
  5. 這個 timeRemaining() 的計算很是動態,會根據不少因素去決定,因此不要期望這個時間是穩定的。

動畫例子

滾動

若是我鼠標不作任何動做和交互,直接在控制檯經過 rIC 去打印此次空閒任務的剩餘時間,通常都穩定維持在 49.xx ms,由於此時瀏覽器沒有什麼優先級更高的任務要去處理。

而若是我不停的滾動瀏覽器,不斷的觸發瀏覽器的從新繪製的話,這個時間就變的很是不穩定了。

經過這個例子,你能夠更加有體感的感覺到什麼樣叫作「繁忙」,什麼樣叫作「空閒」。

動畫

這個動畫的例子很簡單,就是利用rAF在每幀渲染前的回調中把方塊的位置向右移動 10px。

<!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>
      #SomeElementYouWantToAnimate {
        height: 200px;
        width: 200px;
        background: red;
      }
    </style>
  </head>
  <body>
    <div id="SomeElementYouWantToAnimate"></div>
    <script>
      var start = null
      var element = document.getElementById("SomeElementYouWantToAnimate")
      element.style.position = "absolute"

      function step(timestamp) {
        if (!start) start = timestamp
        var progress = timestamp - start
        element.style.left = Math.min(progress / 10, 200) + "px"
        if (progress < 2000) {
          window.requestAnimationFrame(step)
        }
      }
      // 動畫
      window.requestAnimationFrame(step)

      // 空閒調度
      window.requestIdleCallback(() => {
        alert("rIC")
      })
    </script>
  </body>
</html>

注意在最後我加了一個 requestIdleCallback 的函數,回調裏會 alert('rIC'),來看一下演示效果:

alert 在最開始的時候就執行了,爲何會這樣呢一下,想一下「空閒」的概念,咱們每一幀僅僅是把 left 的值移動了一下,作了這一個簡單的渲染,沒有佔滿空閒時間,因此可能在最開始的時候,瀏覽器就找到機會去調用 rIC 的回調函數了。

咱們簡單的修改一下 step 函數,在裏面加一個很重的任務,1000 次循環打印。

function step(timestamp) {
  if (!start) start = timestamp
  var progress = timestamp - start
  element.style.left = Math.min(progress / 10, 200) + "px"
  let i = 1000
  while (i > 0) {
    console.log("i", i)
    i--
  }
  if (progress < 2000) {
    window.requestAnimationFrame(step)
  }
}

再來看一下它的表現:

其實和咱們預期的同樣,因爲瀏覽器的每一幀都"太忙了",致使它真的就無視咱們的 rIC 函數了。

若是給 rIC 函數加一個 timeout 呢:

// 空閒調度
window.requestIdleCallback(
  () => {
    alert("rID")
  },
  { timeout: 500 },
)

瀏覽器會在大概 500ms 的時候,無論有多忙,都去強制執行 rIC 函數,這個機制能夠防止咱們的空閒任務被「餓死」。

總結

經過本文的學習過程,我本身也打破了不少對於 Event Loop 以及 rAF、rIC 函數的固有錯誤認知,經過本文咱們能夠整理出如下的幾個關鍵點。

  1. 事件循環不必定每輪都伴隨着重渲染,可是若是有微任務,必定會伴隨着微任務執行
  2. 決定瀏覽器視圖是否渲染的因素不少,瀏覽器是很是聰明的。
  3. requestAnimationFrame在從新渲染屏幕以前執行,很是適合用來作動畫。
  4. requestIdleCallback在渲染屏幕以後執行,而且是否有空執行要看瀏覽器的調度,若是你必定要它在某個時間內執行,請使用 timeout參數。
  5. resizescroll事件其實自帶節流,它只在 Event Loop 的渲染階段去派發事件到 EventTarget 上。

另外,本文也是對於規範的解讀,規範裏的一些術語比較晦澀難懂,因此我也結合了一些本身的理解去寫這篇文章,若是有錯誤的地方歡迎各位小夥伴指出。

參考資料

HTML 規範文檔

W3C 標準

Vue 源碼詳解之 nextTick:MutationObserver 只是浮雲,microtask 纔是核心!(強烈推薦這篇文章)

❤️ 感謝你們

1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。

2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。

相關文章
相關標籤/搜索