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
執行結果
很明顯能看出,setTimeout
比 requestAnimationFrame
實現的動畫“快”了很多。這是什麼原因呢?
可能你也猜到了,Event Loop
和 requestAnimationFrame
在執行的時候有些特殊的機制,下面就來探究一下 Event Loop
和 requestAnimationFrame
的關係。
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
(setTimeout
、setInterval
等)、IndexDB 操作也是 task source
。
microtask
一個 event loop
有一個 microtask queue
,不過這個 “queue” 它確實就是那個 FIFO
的隊列。
規範裏沒有指明哪些是 microtask
的任務源,通常認爲以下幾個是 microtask
- promises
- MutationObserver
- Object.observe
- process.nextTick (這個東西是 Node.js 的 API,暫且不討論)
Event Loop 處理過程
- 在所選
task queue
(taskQueue)中約定必須包含一個可運行任務。如果沒有此類task queue
,則跳轉至下面microtasks
步驟。 - 讓
taskQueue
中最老的 task (oldestTask) 變成第一個可執行任務,然後從 taskQueue 中刪掉它。 - 將上面 oldestTask 設置爲
event loop
中正在運行的 task。 - 執行 oldestTask。
- 將
event loop
中正在運行的 task 設置爲 null。 - 執行
microtasks
檢查點(也就是執行microtasks
隊列中的任務)。 - 設置
hasARenderingOpportunity
爲 false。 - 更新渲染。
- 如果當前是
window event loop
且task queues
裏沒有 task 且microtask queue
是空的,同時渲染時機變量hasARenderingOpportunity
爲 false ,去執行 idle period(requestIdleCallback
)。 - 返回到第一步。
大體上來說,event loop
就是不停地找 task queues
裏是否有可執行的 task ,如果存在即將其推入到 call stack
(執行棧)裏執行,並且在合適的時機更新渲染。
下圖 3 是 event loop
在瀏覽器主線程上運行的一個清晰的流程
在上面規範的說明中,渲染的流程是在執行 microtasks
隊列之後,更進一步,再來看看渲染的處理過程。
更新渲染
- 遍歷當前瀏覽上下文中所有的
document
,必須按在列表中找到的順序處理每個document
。 - 渲染時機(Rendering opportunities):如果當前瀏覽上下文中沒有到渲染時機則將所有 docs 刪除,取消渲染(此處是否存在渲染時機由瀏覽器自行判斷,根據硬件刷新率限制、頁面性能或頁面是否在後臺等因素)。
- 如果當前文檔不爲空,設置
hasARenderingOpportunity
爲 true 。 - 不必要的渲染(Unnecessary rendering):如果瀏覽器認爲更新文檔的瀏覽上下文的呈現不會產生可見效果且文檔的
animation frame callbacks
是空的,則取消渲染。(終於看見requestAnimationFrame
的身影了 - 從 docs 中刪除瀏覽器認爲出於其他原因最好跳過更新渲染的文檔。
- 如果文檔的瀏覽上下文是頂級瀏覽上下文,則刷新該文檔的自動對焦候選對象。
- 處理
resize
事件,傳入一個performance.now()
時間戳。 - 處理
scroll
事件,傳入一個performance.now()
時間戳。 - 處理媒體查詢,傳入一個
performance.now()
時間戳。 - 運行 CSS 動畫,傳入一個
performance.now()
時間戳。 - 處理全屏事件,傳入一個
performance.now()
時間戳。 - 執行
requestAnimationFrame
回調,傳入一個performance.now()
時間戳。 - 執行
intersectionObserver
回調,傳入一個performance.now()
時間戳。 - 對每個
document
進行繪製。 - 更新 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);
}
}