requestAnimationFrame 執(zhí)行機(jī)制探索
點(diǎn)擊上方?前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
1.什么是 requestAnimationFrame
—
window.requestAnimationFrame()告訴瀏覽器——你希望執(zhí)行一個(gè)動(dòng)畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動(dòng)畫。該方法需要傳入一個(gè)回調(diào)函數(shù)作為參數(shù),該回調(diào)函數(shù)會(huì)在瀏覽器下一次重繪之前執(zhí)行。根據(jù)以上 MDN[1] 的定義,requestAnimationFrame是瀏覽器提供的一個(gè)按幀對(duì)網(wǎng)頁進(jìn)行重繪的 API 。先看下面這個(gè)例子,了解一下它是如何使用并運(yùn)行的:
const?test?=?document.querySelector("#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 次,因?yàn)橐话愕钠聊挥布O(shè)備的刷新頻率都是 60Hz,然后每執(zhí)行一次大約是 16.6ms。使用 requestAnimationFrame 的時(shí)候,只需要反復(fù)調(diào)用它就可以實(shí)現(xiàn)動(dòng)畫效果。
同時(shí) requestAnimationFrame 會(huì)返回一個(gè)請(qǐng)求 ID,是回調(diào)函數(shù)列表中的一個(gè)唯一值,可以使用 cancelAnimationFrame 通過傳入該請(qǐng)求 ID 取消回調(diào)函數(shù)。
const?test?=?document.querySelector("#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í)行結(jié)果:

2.requestAnimationFrame 執(zhí)行困惑—
使用 JavaScript 實(shí)現(xiàn)動(dòng)畫的方式還可以使用 setTimeout ,下面是實(shí)現(xiàn)的代碼:
const?test?=?document.querySelector("#test")!;
let?i?=?0;
let?timerId:?number;
function?animation()?{
??test.style.marginLeft?=?`${i}px`;
??//?執(zhí)行間隔設(shè)置為?0,來模仿?requestAnimationFrame
??timerId?=?setTimeout(animation,?0);
??i++;
??if?(i?>?200)?{
????clearTimeout(timerId);
??}
}
animation();
在這里將 setTimeout 的執(zhí)行間隔設(shè)置為 0,來模仿 requestAnimationFrame。
單單從代碼上實(shí)現(xiàn)的方式,看不出有什么區(qū)別,但是從下面具體的實(shí)現(xiàn)結(jié)果就可以看出很明顯的差距了。
下圖2是 setTimeout 執(zhí)行結(jié)果:

完整的例子戳 codesandbox[2]。
很明顯能看出,setTimeout 比 requestAnimationFrame 實(shí)現(xiàn)的動(dòng)畫“快”了很多。這是什么原因呢?
可能你也猜到了,Event Loop 和 requestAnimationFrame 在執(zhí)行的時(shí)候有些特殊的機(jī)制,下面就來探究一下 Event Loop 和 requestAnimationFrame 的關(guān)系。
3.Event Loop 與 requestAnimationFrame—
Event Loop(事件循環(huán))是用來協(xié)調(diào)事件、用戶交互、腳本、渲染、網(wǎng)絡(luò)的一種瀏覽器內(nèi)部機(jī)制。
Event Loop 在瀏覽器內(nèi)也分幾種:
window event loopworker event loopworklet event loop
我們這里主要討論的是 window event loop。也就是瀏覽器一個(gè)渲染進(jìn)程內(nèi)主線程所控制的 Event Loop。
3.1 task queue
一個(gè) Event Loop 有一個(gè)或多個(gè) task queues。一個(gè) task queue 是一系列 tasks 的集合。
注:一個(gè) task queue 在數(shù)據(jù)結(jié)構(gòu)上是一個(gè)集合,而不是隊(duì)列,因?yàn)槭录h(huán)處理模型會(huì)從選定的 task queue 中獲取第一個(gè)可運(yùn)行任務(wù)(runnable task),而不是使第一個(gè) task 出隊(duì)。上述內(nèi)容來自 HTML規(guī)范[3]。這里讓人迷惑的是,明明是集合,為啥還叫“queue”啊 T.T
3.2 task
一個(gè) task 可以有多種 task sources (任務(wù)源),有哪些任務(wù)源呢?來看下規(guī)范里的 Gerneric task sources[4] :
DOM 操作任務(wù)源,比如一個(gè)元素以非阻塞的方式插入文檔 用戶交互任務(wù)源,用戶操作(比如 click)事件 網(wǎng)絡(luò)任務(wù)源,網(wǎng)絡(luò) I/O 響應(yīng)回調(diào) history traversal 任務(wù)源,比如 history.back()
除此之外還有像 Timers (setTimeout、setInterval等)、IndexDB 操作也是 task source。
3.3 microtask
一個(gè) event loop 有一個(gè) microtask queue,不過這個(gè) “queue” 它確實(shí)就是那個(gè) “FIFO” 的隊(duì)列。
規(guī)范里沒有指明哪些是 microtask 的任務(wù)源,通常認(rèn)為以下幾個(gè)是 microtask:
promises MutationObserver Object.observe process.nextTick (這個(gè)東西是 Node.js 的 API,暫且不討論)
3.4 Event Loop 處理過程
在所選 task queue (taskQueue)中約定必須包含一個(gè)可運(yùn)行任務(wù)。如果沒有此類 task queue,則跳轉(zhuǎn)至下面 microtasks 步驟。 讓 taskQueue 中最老的 task (oldestTask) 變成第一個(gè)可執(zhí)行任務(wù),然后從 taskQueue 中刪掉它。 將上面 oldestTask 設(shè)置為 event loop 中正在運(yùn)行的 task。 執(zhí)行 oldestTask。 將 event loop 中正在運(yùn)行的 task 設(shè)置為 null。 執(zhí)行 microtasks 檢查點(diǎn)(也就是執(zhí)行 microtasks 隊(duì)列中的任務(wù))。 設(shè)置 hasARenderingOpportunity 為 false。 更新渲染。 如果當(dāng)前是 window event loop且 task queues 里沒有 task 且 microtask queue 是空的,同時(shí)渲染時(shí)機(jī)變量 hasARenderingOpportunity 為 false ,去執(zhí)行 idle period(requestIdleCallback)。返回到第一步。
以上是來自規(guī)范關(guān)于 event loop 處理過程的精簡版整理,省略了部分內(nèi)容,完整版在這里[5]。
大體上來說,event loop 就是不停地找 task queues 里是否有可執(zhí)行的 task ,如果存在即將其推入到 call stack (執(zhí)行棧)里執(zhí)行,并且在合適的時(shí)機(jī)更新渲染。
下圖3(源[6])是 event loop 在瀏覽器主線程上運(yùn)行的一個(gè)清晰的流程:

關(guān)于主線程做了些什么,這又是一個(gè)宏大的話題,感興趣的同學(xué)可以看看瀏覽器內(nèi)部揭秘系列文章[7]。
在上面規(guī)范的說明中,渲染的流程是在執(zhí)行 microtasks 隊(duì)列之后,更進(jìn)一步,再來看看渲染的處理過程。
3.5 更新渲染
遍歷當(dāng)前瀏覽上下文中所有的 document ,必須按在列表中找到的順序處理每個(gè) document 。 渲染時(shí)機(jī)(Rendering opportunities):如果當(dāng)前瀏覽上下文中沒有到渲染時(shí)機(jī)則將所有 docs 刪除,取消渲染(此處是否存在渲染時(shí)機(jī)由瀏覽器自行判斷,根據(jù)硬件刷新率限制、頁面性能或頁面是否在后臺(tái)等因素)。 如果當(dāng)前文檔不為空,設(shè)置 hasARenderingOpportunity 為 true 。 不必要的渲染(Unnecessary rendering):如果瀏覽器認(rèn)為更新文檔的瀏覽上下文的呈現(xiàn)不會(huì)產(chǎn)生可見效果且文檔的 animation frame callbacks 是空的,則取消渲染。(終于看見 requestAnimationFrame 的身影了 從 docs 中刪除瀏覽器認(rèn)為出于其他原因最好跳過更新渲染的文檔。 如果文檔的瀏覽上下文是頂級(jí)瀏覽上下文,則刷新該文檔的自動(dòng)對(duì)焦候選對(duì)象。 處理 resize 事件,傳入一個(gè) performance.now() 時(shí)間戳。 處理 scroll 事件,傳入一個(gè) performance.now() 時(shí)間戳。 處理媒體查詢,傳入一個(gè) performance.now() 時(shí)間戳。 運(yùn)行 CSS 動(dòng)畫,傳入一個(gè) performance.now() 時(shí)間戳。 處理全屏事件,傳入一個(gè) performance.now() 時(shí)間戳。 執(zhí)行 requestAnimationFrame 回調(diào),傳入一個(gè) performance.now() 時(shí)間戳。 執(zhí)行 intersectionObserver 回調(diào),傳入一個(gè) performance.now() 時(shí)間戳。 對(duì)每個(gè) document 進(jìn)行繪制。 更新 ui 并呈現(xiàn)。
下圖4(源[8])是該過程一個(gè)比較清晰的流程:

至此,requestAnimationFrame 的回調(diào)時(shí)機(jī)就清楚了,它會(huì)在 style/layout/paint 之前調(diào)用。
再回到文章開始提到的 setTimeout 動(dòng)畫比 requestAnimationFrame 動(dòng)畫更快的問題,這就很好解釋了。
首先,瀏覽器渲染有個(gè)渲染時(shí)機(jī)(Rendering opportunity)的問題,也就是瀏覽器會(huì)根據(jù)當(dāng)前的瀏覽上下文判斷是否進(jìn)行渲染,它會(huì)盡量高效,只有必要的時(shí)候才進(jìn)行渲染,如果沒有界面的改變,就不會(huì)渲染。按照規(guī)范里說的一樣,因?yàn)榭紤]到硬件的刷新頻率限制、頁面性能以及頁面是否存在后臺(tái)等等因素,有可能執(zhí)行完 setTimeout 這個(gè) task 之后,發(fā)現(xiàn)還沒到渲染時(shí)機(jī),所以 setTimeout 回調(diào)了幾次之后才進(jìn)行渲染,此時(shí)設(shè)置的 marginLeft 和上一次渲染前 marginLeft 的差值要大于 1px 的。
下圖5是 setTimeout 執(zhí)行情況,紅色圓圈處是兩次渲染,中間四次是處理 setTimout task,因?yàn)槠聊坏乃⑿骂l率是 60 Hz,所以大致在 16.6ms 之內(nèi)執(zhí)行了多次 setTimeout task 之后才到了渲染時(shí)機(jī)并執(zhí)行渲染。

requestAnimationFrame 幀動(dòng)畫不同之處在于,每次渲染之前都會(huì)調(diào)用,此時(shí)設(shè)置的 marginLeft 和上一次渲染前 marginLeft 的差值為 1px 。
下圖6是 requestAnimationFrame 執(zhí)行情況,每次調(diào)用完都會(huì)執(zhí)行渲染:


所以看上去 setTimeout “快”了很多。
4.不同瀏覽器的實(shí)現(xiàn)—
上面的例子都是在 Chrome 下測試的,這個(gè)例子基本在所有瀏覽器下呈現(xiàn)的結(jié)果都是一致的,看看下面這個(gè)例子,它來自 jake archilbald[9] 早在 2017 年提出的這個(gè)問題[10]:
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 執(zhí)行情況如下圖7:

簡單解釋一下,該例中 requestAnimationFrame 回調(diào)里設(shè)置的 transform 覆蓋了 click listener 里設(shè)置的 transform,因?yàn)?requestAnimationFrame 是在計(jì)算 css (style) 之前調(diào)用的,所以動(dòng)畫向右移動(dòng)了 200 px。
注:上面代碼是在 Chrome 隱藏模式下執(zhí)行的,當(dāng)你的 Chrome 瀏覽器有很多插件或者打開了很多 tab 時(shí),也可能出現(xiàn)從右往左滑動(dòng)的現(xiàn)象。在 safari 執(zhí)行情況如下圖8:

edge 之前也是也是和 safari 一樣的執(zhí)行結(jié)果,不過現(xiàn)在已經(jīng)修復(fù)了。
造成這樣結(jié)果的原因是 safari 在執(zhí)行 requestAnimationFrame 回調(diào)的時(shí)機(jī)是在 1 幀渲染之后,所以當(dāng)前幀調(diào)用的 requestAnimationFrame 會(huì)在下一幀呈現(xiàn)。所以 safari 一開始渲染的位置就到了右邊 400px 的位置,然后朝著左邊 200px 的位置移動(dòng)。
關(guān)于 event loop 和 requestAnimationFrame 更詳細(xì)的執(zhí)行機(jī)制解釋,jake 在 jsconf 里有過專題演講[11],推薦小伙伴們看一看。
5.其他執(zhí)行規(guī)則—
繼續(xù)看前面 jake 提出的例子,如果在標(biāo)準(zhǔn)規(guī)范實(shí)現(xiàn)下,想要實(shí)現(xiàn) safari 呈現(xiàn)的效果(也就是從右往左移動(dòng))需要怎么做?
答案是再加一層 requestAnimationFrame 調(diào)用:
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)';
????});
??});
});
上面這段代碼的執(zhí)行結(jié)果和 safari 一致,原因是 requestAnimationFrame 每幀只執(zhí)行 1 次,新定義的 requestAnimationFrame 會(huì)在下一幀渲染前執(zhí)行。
6.其他應(yīng)用—
從上面的例子我們得知:使用 setTimeout 來執(zhí)行動(dòng)畫之類的視覺變化,很可能導(dǎo)致丟幀,導(dǎo)致卡頓,所以應(yīng)盡量避免使用 setTimeout 來執(zhí)行動(dòng)畫,推薦使用 requestAnimationFrame 來替換它。
requestAnimationFrame 除了用來實(shí)現(xiàn)動(dòng)畫的效果,還可以用來實(shí)現(xiàn)對(duì)大任務(wù)的分拆執(zhí)行。
從圖 4 的渲染流程圖可以得知:執(zhí)行 JavaScript task 是在渲染之前,如果在一幀之內(nèi) JavaScript 執(zhí)行時(shí)間過長就會(huì)阻塞渲染,同樣會(huì)導(dǎo)致丟幀、卡頓。
針對(duì)這種情況可以將 JavaScript task 劃分為各個(gè)小塊,并使用 requestAnimationFrame() 在每個(gè)幀上運(yùn)行。如下例(源[12])所示:
var?taskList?=?breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function?processTaskList(taskStartTime)?{
??var?taskFinishTime;
??do?{
????//?假設(shè)下一個(gè)任務(wù)被壓入?call?stack
????var?nextTask?=?taskList.pop();
????//?執(zhí)行下一個(gè)?task
????processTask(nextTask);
????//?如何時(shí)間足夠繼續(xù)執(zhí)行下一個(gè)
????taskFinishTime?=?window.performance.now();
??}?while?(taskFinishTime?-?taskStartTime?3);
??if?(taskList.length?>?0)?{
????requestAnimationFrame(processTaskList);
??}
}

往期推薦



歡迎加我微信,拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...


7.參考資料—
MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/window/requestAnimationFrame
[2]codesandbox: https://codesandbox.io/s/raf-ycqc3
[3]HTML規(guī)范: https://html.spec.whatwg.org/multipage/webappapis.html#event-loop
[4]Gerneric task sources: https://html.spec.whatwg.org/multipage/webappapis.html#generic-task-sources
[5]https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
[6]https://medium.com/@francesco_rizzi/javascript-main-thread-dissected-43c85fce7e23
[7]瀏覽器內(nèi)部揭秘系列文章: https://developers.google.com/web/updates/2018/09/inside-browser-part1
[8]https://zhuanlan.zhihu.com/p/64917985
[9]jake archilbald: https://jakearchibald.com/
[10]https://github.com/whatwg/html/issues/2569
[11]專題演講: https://www.youtube.com/watch?v=cCOL7MC4Pl0
[12]https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution
[13]WHATWG HTML Standard: https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
[14]現(xiàn)代瀏覽器內(nèi)部揭秘: https://developers.google.com/web/updates/2018/09/inside-browser-part3
[15]JavaScript main thread. Dissected.: https://medium.com/@francesco_rizzi/javascript-main-thread-dissected-43c85fce7e23
[16]requestAnimationFrame Scheduling For Nerds: https://medium.com/@paul_irish/requestanimationframe-scheduling-for-nerds-9c57f7438ef4
[17]jake jsconf 演講: https://www.youtube.com/watch?v=cCOL7MC4Pl0
[18]optimize javascript execution: https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution
[19]從event loop規(guī)范探究javaScript異步及瀏覽器更新渲染時(shí)機(jī): https://github.com/aooy/blog/issues/5
