requestAnimationFrame 執(zhí)行機制探索
[TOC]
什么是 requestAnimationFrame
window.requestAnimationFrame()
告訴瀏覽器——你希望執(zhí)行一個動畫谍肤,并且要求瀏覽器在下次重繪之前調用指定的回調函數(shù)更新動畫。
該方法需要傳入一個回調函數(shù)作為參數(shù)鸡典,該回調函數(shù)會在瀏覽器下一次重繪之前執(zhí)行溶握。根據(jù)以上 MDN 的定義挠将,requestAnimationFrame
是瀏覽器提供的一個按幀對網(wǎng)頁進行重繪的 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 大約執(zhí)行 60 次摊腋,因為一般的屏幕硬件設備的刷新頻率都是 60Hz妄痪,然后每執(zhí)行一次大約是 16.6ms哈雏。使用 requestAnimationFrame
的時候,只需要反復調用它就可以實現(xiàn)動畫效果衫生。
同時 requestAnimationFrame
會返回一個請求 ID裳瘪,是回調函數(shù)列表中的一個唯一值,可以使用 cancelAnimationFrame
通過傳入該請求 ID 取消回調函數(shù)罪针。
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 是上面例子的執(zhí)行結果:
requestAnimationFrame 執(zhí)行困惑
使用 JavaScript 實現(xiàn)動畫的方式還可以使用 setTimeout
彭羹,下面是實現(xiàn)的代碼
const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
let timerId: number;
function animation() {
test.style.marginLeft = `${i}px`;
// 執(zhí)行間隔設置為 0,來模仿 requestAnimationFrame
timerId = setTimeout(animation, 0);
i++;
if (i > 200) {
clearTimeout(timerId);
}
}
animation();
在這里將 setTimeout
的執(zhí)行間隔設置為 0泪酱,來模仿 requestAnimationFrame
派殷。
單單從代碼上實現(xiàn)的方式,看不出有什么區(qū)別墓阀,但是從下面具體的實現(xiàn)結果就可以看出很明顯的差距了毡惜。
下圖 2 是 setTimeout
執(zhí)行結果
很明顯能看出,setTimeout
比 requestAnimationFrame
實現(xiàn)的動畫“快”了很多斯撮。這是什么原因呢经伙?
可能你也猜到了,Event Loop
和 requestAnimationFrame
在執(zhí)行的時候有些特殊的機制吮成,下面就來探究一下 Event Loop
和 requestAnimationFrame
的關系橱乱。
Event Loop 與 requestAnimationFrame
Event Loop
(事件循環(huán))是用來協(xié)調事件、用戶交互粱甫、腳本泳叠、渲染、網(wǎng)絡的一種瀏覽器內部機制茶宵。
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
在數(shù)據(jù)結構上是一個集合契耿,而不是隊列,因為事件循環(huán)處理模型會從選定的task queue
中獲取第一個可運行任務(runnable task)螃征,而不是使第一個 task 出隊搪桂。上述內容來自 HTML 規(guī)范。這里讓人迷惑的是盯滚,明明是集合踢械,為啥還叫“queue”啊 T.T
task
一個 task
可以有多種 task sources
(任務源),有哪些任務源呢魄藕?來看下規(guī)范里的 Gerneric task sources
-
DOM
操作任務源内列,比如一個元素以非阻塞的方式插入文檔 - 用戶交互任務源,用戶操作(比如 click)事件
- 網(wǎng)絡任務源背率,網(wǎng)絡 I/O 響應回調
- history traversal 任務源话瞧,比如
history.back()
除此之外還有像 Timers
(setTimeout
、setInterval
等)寝姿、IndexDB 操作也是 task source
交排。
microtask
一個 event loop
有一個 microtask queue
,不過這個 “queue” 它確實就是那個 FIFO
的隊列会油。
規(guī)范里沒有指明哪些是 microtask
的任務源个粱,通常認為以下幾個是 microtask
- promises
- MutationObserver
- Object.observe
- process.nextTick (這個東西是 Node.js 的 API,暫且不討論)
Event Loop 處理過程
- 在所選
task queue
(taskQueue)中約定必須包含一個可運行任務翻翩。如果沒有此類task queue
都许,則跳轉至下面microtasks
步驟。 - 讓
taskQueue
中最老的 task (oldestTask) 變成第一個可執(zhí)行任務嫂冻,然后從 taskQueue 中刪掉它胶征。 - 將上面 oldestTask 設置為
event loop
中正在運行的 task。 - 執(zhí)行 oldestTask桨仿。
- 將
event loop
中正在運行的 task 設置為 null睛低。 - 執(zhí)行
microtasks
檢查點(也就是執(zhí)行microtasks
隊列中的任務)。 - 設置
hasARenderingOpportunity
為 false服傍。 - 更新渲染钱雷。
- 如果當前是
window event loop
且task queues
里沒有 task 且microtask queue
是空的,同時渲染時機變量hasARenderingOpportunity
為 false 吹零,去執(zhí)行 idle period(requestIdleCallback
)罩抗。 - 返回到第一步。
大體上來說灿椅,event loop
就是不停地找 task queues
里是否有可執(zhí)行的 task 套蒂,如果存在即將其推入到 call stack
(執(zhí)行棧)里執(zhí)行钞支,并且在合適的時機更新渲染。
下圖 3 是 event loop
在瀏覽器主線程上運行的一個清晰的流程
在上面規(guī)范的說明中操刀,渲染的流程是在執(zhí)行 microtasks
隊列之后烁挟,更進一步,再來看看渲染的處理過程骨坑。
更新渲染
- 遍歷當前瀏覽上下文中所有的
document
撼嗓,必須按在列表中找到的順序處理每個document
。 - 渲染時機(Rendering opportunities):如果當前瀏覽上下文中沒有到渲染時機則將所有 docs 刪除卡啰,取消渲染(此處是否存在渲染時機由瀏覽器自行判斷静稻,根據(jù)硬件刷新率限制、頁面性能或頁面是否在后臺等因素)匈辱。
- 如果當前文檔不為空,設置
hasARenderingOpportunity
為 true 杀迹。 - 不必要的渲染(Unnecessary rendering):如果瀏覽器認為更新文檔的瀏覽上下文的呈現(xiàn)不會產生可見效果且文檔的
animation frame callbacks
是空的亡脸,則取消渲染。(終于看見requestAnimationFrame
的身影了 - 從 docs 中刪除瀏覽器認為出于其他原因最好跳過更新渲染的文檔树酪。
- 如果文檔的瀏覽上下文是頂級瀏覽上下文浅碾,則刷新該文檔的自動對焦候選對象。
- 處理
resize
事件续语,傳入一個performance.now()
時間戳垂谢。 - 處理
scroll
事件,傳入一個performance.now()
時間戳疮茄。 - 處理媒體查詢滥朱,傳入一個
performance.now()
時間戳。 - 運行 CSS 動畫力试,傳入一個
performance.now()
時間戳徙邻。 - 處理全屏事件,傳入一個
performance.now()
時間戳畸裳。 - 執(zhí)行
requestAnimationFrame
回調缰犁,傳入一個performance.now()
時間戳。 - 執(zhí)行
intersectionObserver
回調怖糊,傳入一個performance.now()
時間戳帅容。 - 對每個
document
進行繪制。 - 更新 ui 并呈現(xiàn)伍伤。
至此并徘,requestAnimationFrame
的回調時機就清楚了,它會在 style/layout/paint
之前調用嚷缭。
再回到文章開始提到的 setTimeout
動畫比 requestAnimationFrame
動畫更快的問題饮亏,這就很好解釋了耍贾。
首先,瀏覽器渲染有個渲染時機(Rendering opportunity)的問題路幸,也就是瀏覽器會根據(jù)當前的瀏覽上下文判斷是否進行渲染荐开,它會盡量高效,只有必要的時候才進行渲染简肴,如果沒有界面的改變晃听,就不會渲染。
按照規(guī)范里說的一樣砰识,因為考慮到硬件的刷新頻率限制能扒、頁面性能以及頁面是否存在后臺等等因素,有可能執(zhí)行完 setTimeout 這個 task 之后辫狼,發(fā)現(xiàn)還沒到渲染時機初斑,所以 setTimeout 回調了幾次之后才進行渲染,此時設置的 marginLeft 和上一次渲染前 marginLeft 的差值要大于 1px 的膨处。
下圖 5 是 setTimeout
執(zhí)行情況见秤,紅色圓圈處是兩次渲染,中間四次是處理 setTimout task
真椿,因為屏幕的刷新頻率是 60 Hz鹃答,所以大致在 16.6ms 之內執(zhí)行了多次 setTimeout task
之后才到了渲染時機并執(zhí)行渲染。
requestAnimationFrame
幀動畫不同之處在于突硝,每次渲染之前都會調用测摔,此時設置的 marginLeft 和上一次渲染前 marginLeft 的差值為 1px 。
下圖 6 是 requestAnimationFrame
執(zhí)行情況解恰,每次調用完都會執(zhí)行渲染:
所以看上去 setTimeout “快”了很多锋八。
其他應用
使用 setTimeout
來執(zhí)行動畫之類的視覺變化,很可能導致丟幀修噪,導致卡頓查库,所以應盡量避免使用 setTimeout
來執(zhí)行動畫,推薦使用 requestAnimationFrame
來替換它
requestAnimationFrame
除了用來實現(xiàn)動畫的效果黄琼,還可以用來實現(xiàn)對大任務的分拆執(zhí)行
執(zhí)行 JavaScript task 是在渲染之前樊销,如果在一幀之內 JavaScript 執(zhí)行時間過長就會阻塞渲染,同樣會導致丟幀脏款、卡頓
針對這種情況可以將 JavaScript task 劃分為各個小塊围苫,并使用 requestAnimationFrame()
在每個幀上運行。如下例所示
var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
var taskFinishTime;
do {
// 假設下一個任務被壓入 call stack
var nextTask = taskList.pop();
// 執(zhí)行下一個 task
processTask(nextTask);
// 如何時間足夠繼續(xù)執(zhí)行下一個
taskFinishTime = window.performance.now();
} while (taskFinishTime - taskStartTime < 3);
if (taskList.length > 0) {
requestAnimationFrame(processTaskList);
}
}