requestAnimationFrame 執行機制探索

1.什麼是 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是上面例子的執行結果:

raf動畫

2.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 執行結果:

st動畫

完整的例子戳 codesandbox

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

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

3.Event Loop 與 requestAnimationFrame

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

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

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

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

3.1 task queue

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

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

3.2 task

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

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

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

3.3 microtask

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

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

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

3.4 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 loop 且 task queues 裏沒有 task 且 microtask queue 是空的,同時渲染時機變量 hasARenderingOpportunity 爲 false ,去執行 idle period(requestIdleCallback)。
  10. 返回到第一步。

以上是來自規範關於 event loop 處理過程的精簡版整理,省略了部分內容,完整版在這裏

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

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

主線程 event loop

關於主線程做了些什麼,這又是一個宏大的話題,感興趣的同學可以看看瀏覽器內部揭祕系列文章

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

3.5 更新渲染

  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 並呈現。

下圖4()是該過程一個比較清晰的流程:

life of a frame

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

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

首先,瀏覽器渲染有個渲染時機(Rendering opportunity)的問題,也就是瀏覽器會根據當前的瀏覽上下文判斷是否進行渲染,它會盡量高效,只有必要的時候才進行渲染,如果沒有界面的改變,就不會渲染。按照規範裏說的一樣,因爲考慮到硬件的刷新頻率限制、頁面性能以及頁面是否存在後臺等等因素,有可能執行完 setTimeout 這個 task 之後,發現還沒到渲染時機,所以 setTimeout 回調了幾次之後才進行渲染,此時設置的 marginLeft 和上一次渲染前 marginLeft 的差值要大於 1px 的。

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

兩次渲染之間大概執行了4次

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

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

每次調用raf均渲染

調用渲染

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

4.不同瀏覽器的實現

上面的例子都是在 Chrome 下測試的,這個例子基本在所有瀏覽器下呈現的結果都是一致的,看看下面這個例子,它來自 jake archilbald 早在 2017 年提出的這個問題

test.style.transform = 'translate(0, 0)';
document.querySelector('button').addEventListener('click', () => {
  const test = document.querySelector('.test');
  test.style.transform = 'translate(400px, 0)';
  
  requestAnimationFrame(() => {
    test.style.transition = 'transform 3s linear';
    test.style.transform = 'translate(200px, 0)';
  });
});

這段代碼在 Chrome 、Firefox 執行情況如下圖7:

chrome

簡單解釋一下,該例中 requestAnimationFrame 回調裏設置的 transform 覆蓋了 click listener 裏設置的 transform,因爲 requestAnimationFrame 是在計算 css (style) 之前調用的,所以動畫向右移動了 200 px。

注:上面代碼是在 Chrome 隱藏模式下執行的,當你的 Chrome 瀏覽器有很多插件或者打開了很多 tab 時,也可能出現從右往左滑動的現象。
在 safari 執行情況如下圖8:

safari

edge 之前也是也是和 safari 一樣的執行結果,不過現在已經修復了。

造成這樣結果的原因是 safari 在執行 requestAnimationFrame 回調的時機是在 1 幀渲染之後,所以當前幀調用的 requestAnimationFrame 會在下一幀呈現。所以 safari 一開始渲染的位置就到了右邊 400px 的位置,然後朝着左邊 200px 的位置移動。

關於 event loop 和 requestAnimationFrame 更詳細的執行機制解釋,jake 在 jsconf 裏有過專題演講,推薦小夥伴們看一看。

5.其他執行規則

繼續看前面 jake 提出的例子,如果在標準規範實現下,想要實現 safari 呈現的效果(也就是從右往左移動)需要怎麼做?

答案是再加一層 requestAnimationFrame 調用:

test.style.transform = 'translate(0, 0)';
document.querySelector('button').addEventListener('click', () => {
  const test = document.querySelector('.test');
  test.style.transform = 'translate(400px, 0)';
  
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      test.style.transition = 'transform 3s linear';
      test.style.transform = 'translate(200px, 0)';
    });
  });
});

上面這段代碼的執行結果和 safari 一致,原因是 requestAnimationFrame 每幀只執行 1 次,新定義的 requestAnimationFrame 會在下一幀渲染前執行。

6.其他應用

從上面的例子我們得知:使用 setTimeout 來執行動畫之類的視覺變化,很可能導致丟幀,導致卡頓,所以應儘量避免使用 setTimeout 來執行動畫,推薦使用 requestAnimationFrame 來替換它。

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

從圖 4 的渲染流程圖可以得知:執行 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);
  }
}

7.參考資料

WHATWG HTML Standard

現代瀏覽器內部揭祕

JavaScript main thread. Dissected.

requestAnimationFrame Scheduling For Nerds

jake jsconf 演講

optimize javascript execution

從event loop規範探究javaScript異步及瀏覽器更新渲染時機


歡迎關注凹凸實驗室博客:aotu.io

或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章:

歡迎關注凹凸實驗室公衆號

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