2021-11-12 requestAnimationFrame 執行機制探索 requestAnimationFrame 執行機制探索

requestAnimationFrame 執行機制探索

[TOC]

什麼是 requestAnimationFrame

window.requestAnimationFrame() 告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前調用指定的回調函數更新動畫。

該方法需要傳入一個回調函數作爲參數,該回調函數會在瀏覽器下一次重繪之前執行。根據以上 MDN 的定義,requestAnimationFrame 是瀏覽器提供的一個按幀對網頁進行重繪的 API 。

先看下面這個例子,瞭解一下它是如何使用並運行的

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
function animation() {
  if (i > 200) return;
  test.style.marginLeft = `${i}px`;
  window.requestAnimationFrame(animation);
  i++;
}
window.requestAnimationFrame(animation);

上面的代碼 1s 大約執行 60 次,因爲一般的屏幕硬件設備的刷新頻率都是 60Hz,然後每執行一次大約是 16.6ms。使用 requestAnimationFrame 的時候,只需要反覆調用它就可以實現動畫效果。

同時 requestAnimationFrame 會返回一個請求 ID,是回調函數列表中的一個唯一值,可以使用 cancelAnimationFrame 通過傳入該請求 ID 取消回調函數。

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
let requestId: number;
function animation() {
  test.style.marginLeft = `${i}px`;
  requestId = requestAnimationFrame(animation);
  i++;
  if (i > 200) {
    cancelAnimationFrame(requestId);
  }
}
animation();

下圖 1 是上面例子的執行結果:

requestAnimationFrame 執行困惑

使用 JavaScript 實現動畫的方式還可以使用 setTimeout ,下面是實現的代碼

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
let timerId: number;
function animation() {
  test.style.marginLeft = `${i}px`;
  // 執行間隔設置爲 0,來模仿 requestAnimationFrame
  timerId = setTimeout(animation, 0);
  i++;
  if (i > 200) {
    clearTimeout(timerId);
  }
}
animation();

在這裏將 setTimeout 的執行間隔設置爲 0,來模仿 requestAnimationFrame

單單從代碼上實現的方式,看不出有什麼區別,但是從下面具體的實現結果就可以看出很明顯的差距了。

下圖 2 是 setTimeout 執行結果

很明顯能看出,setTimeoutrequestAnimationFrame 實現的動畫“快”了很多。這是什麼原因呢?

可能你也猜到了,Event LooprequestAnimationFrame 在執行的時候有些特殊的機制,下面就來探究一下 Event LooprequestAnimationFrame 的關係。

Event Loop 與 requestAnimationFrame

Event Loop(事件循環)是用來協調事件、用戶交互、腳本、渲染、網絡的一種瀏覽器內部機制。

Event Loop 在瀏覽器內也分幾種:

  • window event loop
  • worker event loop
  • worklet event loop

我們這裏主要討論的是 window event loop。也就是瀏覽器一個渲染進程內主線程所控制的 Event Loop

task queue

一個 Event Loop 有一個或多個 task queues。一個 task queue 是一系列 tasks 的集合。

注:一個 task queue 在數據結構上是一個集合,而不是隊列,因爲事件循環處理模型會從選定的 task queue 中獲取第一個可運行任務(runnable task),而不是使第一個 task 出隊。上述內容來自 HTML 規範。這裏讓人迷惑的是,明明是集合,爲啥還叫“queue”啊 T.T

task

一個 task 可以有多種 task sources (任務源),有哪些任務源呢?來看下規範裏的 Gerneric task sources

  • DOM 操作任務源,比如一個元素以非阻塞的方式插入文檔
  • 用戶交互任務源,用戶操作(比如 click)事件
  • 網絡任務源,網絡 I/O 響應回調
  • history traversal 任務源,比如 history.back()

除此之外還有像 Timers (setTimeoutsetInterval 等)、IndexDB 操作也是 task source

microtask

一個 event loop 有一個 microtask queue,不過這個 “queue” 它確實就是那個 FIFO 的隊列。

規範裏沒有指明哪些是 microtask 的任務源,通常認爲以下幾個是 microtask

  • promises
  • MutationObserver
  • Object.observe
  • process.nextTick (這個東西是 Node.js 的 API,暫且不討論)

Event Loop 處理過程

  1. 在所選 task queue (taskQueue)中約定必須包含一個可運行任務。如果沒有此類 task queue,則跳轉至下面 microtasks 步驟。
  2. taskQueue中最老的 task (oldestTask) 變成第一個可執行任務,然後從 taskQueue 中刪掉它。
  3. 將上面 oldestTask 設置爲 event loop 中正在運行的 task。
  4. 執行 oldestTask。
  5. event loop 中正在運行的 task 設置爲 null。
  6. 執行 microtasks 檢查點(也就是執行 microtasks 隊列中的任務)。
  7. 設置 hasARenderingOpportunity 爲 false。
  8. 更新渲染。
  9. 如果當前是 window event looptask queues 裏沒有 task 且 microtask queue 是空的,同時渲染時機變量 hasARenderingOpportunity 爲 false ,去執行 idle period(requestIdleCallback)。
  10. 返回到第一步。

大體上來說,event loop 就是不停地找 task queues 裏是否有可執行的 task ,如果存在即將其推入到 call stack (執行棧)裏執行,並且在合適的時機更新渲染。

下圖 3 是 event loop 在瀏覽器主線程上運行的一個清晰的流程

在上面規範的說明中,渲染的流程是在執行 microtasks 隊列之後,更進一步,再來看看渲染的處理過程。

更新渲染

  1. 遍歷當前瀏覽上下文中所有的 document ,必須按在列表中找到的順序處理每個 document
  2. 渲染時機(Rendering opportunities):如果當前瀏覽上下文中沒有到渲染時機則將所有 docs 刪除,取消渲染(此處是否存在渲染時機由瀏覽器自行判斷,根據硬件刷新率限制、頁面性能或頁面是否在後臺等因素)。
  3. 如果當前文檔不爲空,設置 hasARenderingOpportunity 爲 true 。
  4. 不必要的渲染(Unnecessary rendering):如果瀏覽器認爲更新文檔的瀏覽上下文的呈現不會產生可見效果且文檔的 animation frame callbacks 是空的,則取消渲染。(終於看見 requestAnimationFrame 的身影了
  5. 從 docs 中刪除瀏覽器認爲出於其他原因最好跳過更新渲染的文檔。
  6. 如果文檔的瀏覽上下文是頂級瀏覽上下文,則刷新該文檔的自動對焦候選對象。
  7. 處理 resize 事件,傳入一個 performance.now() 時間戳。
  8. 處理 scroll 事件,傳入一個 performance.now() 時間戳。
  9. 處理媒體查詢,傳入一個 performance.now() 時間戳。
  10. 運行 CSS 動畫,傳入一個 performance.now() 時間戳。
  11. 處理全屏事件,傳入一個 performance.now() 時間戳。
  12. 執行 requestAnimationFrame 回調,傳入一個 performance.now() 時間戳。
  13. 執行 intersectionObserver 回調,傳入一個 performance.now() 時間戳。
  14. 對每個 document 進行繪製。
  15. 更新 ui 並呈現。

至此,requestAnimationFrame 的回調時機就清楚了,它會在 style/layout/paint 之前調用。

再回到文章開始提到的 setTimeout 動畫比 requestAnimationFrame 動畫更快的問題,這就很好解釋了。

首先,瀏覽器渲染有個渲染時機(Rendering opportunity)的問題,也就是瀏覽器會根據當前的瀏覽上下文判斷是否進行渲染,它會盡量高效,只有必要的時候才進行渲染,如果沒有界面的改變,就不會渲染。

按照規範裏說的一樣,因爲考慮到硬件的刷新頻率限制、頁面性能以及頁面是否存在後臺等等因素,有可能執行完 setTimeout 這個 task 之後,發現還沒到渲染時機,所以 setTimeout 回調了幾次之後才進行渲染,此時設置的 marginLeft 和上一次渲染前 marginLeft 的差值要大於 1px 的。

下圖 5 是 setTimeout 執行情況,紅色圓圈處是兩次渲染,中間四次是處理 setTimout task,因爲屏幕的刷新頻率是 60 Hz,所以大致在 16.6ms 之內執行了多次 setTimeout task 之後纔到了渲染時機並執行渲染。

requestAnimationFrame 幀動畫不同之處在於,每次渲染之前都會調用,此時設置的 marginLeft 和上一次渲染前 marginLeft 的差值爲 1px 。

下圖 6 是 requestAnimationFrame 執行情況,每次調用完都會執行渲染:

所以看上去 setTimeout “快”了很多。

其他應用

使用 setTimeout 來執行動畫之類的視覺變化,很可能導致丟幀,導致卡頓,所以應儘量避免使用 setTimeout 來執行動畫,推薦使用 requestAnimationFrame 來替換它

requestAnimationFrame 除了用來實現動畫的效果,還可以用來實現對大任務的分拆執行

執行 JavaScript task 是在渲染之前,如果在一幀之內 JavaScript 執行時間過長就會阻塞渲染,同樣會導致丟幀、卡頓

針對這種情況可以將 JavaScript task 劃分爲各個小塊,並使用 requestAnimationFrame() 在每個幀上運行。如下例所示

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
  var taskFinishTime;
  do {
    // 假設下一個任務被壓入 call stack
    var nextTask = taskList.pop();
    // 執行下一個 task
    processTask(nextTask);
    // 如何時間足夠繼續執行下一個
    taskFinishTime = window.performance.now();
  } while (taskFinishTime - taskStartTime < 3);
  if (taskList.length > 0) {
    requestAnimationFrame(processTaskList);
  }
}

原文鏈接:requestAnimationFrame 執行機制探索

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章