requestAnimationFrame 執行機制探索

凹凸實驗室發表於2021-11-11

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),不定時推送文章。

相關文章