requestAnimationFrame這個API,可能很多人都聽過,但并沒有真正用過。MDN上的解釋是:
window.requestAnimationFrame() 方法告訴瀏覽器您希望執行動畫,并請求瀏覽器調用指定的函數在下一次重繪之前更新動畫。該方法將在重繪之前調用的回調作為參數。
在three.js里,requestAnimationFrame主要用在渲染器renderer里,作為優化動畫的解決方案。當然在js animation中也需要用到requestAnimationFrame。在談此之前,我們就three.js的應用場景,來簡單介紹一下動畫的相關概念。
動畫的本質是利用了人眼的視覺暫留特性,快速地變換畫面,從而產生物體在運動的假象。而對于 Three.js 程序而言,動畫的實現也是通過在每秒中多次重繪畫面實現的。
為了衡量畫面切換速度,引入了每秒幀數 FPS(Frames Per Second)的概念,是指每秒畫 面重繪的次數。(這也是在游戲中經常遇到的FPS,打過lol的都知道,一般FPS越高,畫面會越流暢)FPS 越大,則動畫效果越平滑,當 FPS 小于 20 時,一般就能明顯感受到 畫面的卡滯現象。
當然,當 FPS 足夠大(比如達到 60),再增加幀數 人眼也不會感受到明顯的變化,反而相應地就要消耗更多資源(比如電影的膠片就需要更 長了,或是電腦刷新畫面需要消耗計算資源等等)
setInterval 與setTimeout
一般做動畫而言,我們第一想到的就是使用setInterval或者setTimeout來實現,如下:
function animate() {
// ...
}
setInterval(animate, 200)
or
function animate(){
setTimeout(animate,200)
}
animate()
由于大部分屏幕刷新的頻率是60HZ,所以我們要做的就是盡量去讓幀率達到60fps。
//1000ms/60 = 16.7ms,故約等于17
setInterval(animate,17)
然后,setInterval與setTimeout所設定的時間,并不一定按照間隔來執行。由于瀏覽器是單線程的緣故,事件都是按異步隊列執行,如果執行setTimeout/setInterval時,有大量的異步事件在等待執行,瀏覽器線程只能讓其等待,這樣delay肯定時大于所設置的時間。
出現的問題:
- 瀏覽器依然在執行一些不必要的動畫,或者異步事件,盡管chrome會對setInterval以及setTimout在1fps做節流處理,但其他瀏覽器并沒有
- setTimeout只會在瀏覽器想要更新的時候更新,而不會考慮計算機是否能夠更新,這就意味著當你在重繪整個屏幕的時候,瀏覽器不得不重繪動畫,此時當你的動畫幀率跟屏幕重繪得幀率不同步時,于是會耗費更多的電量,這就意味著高CPU使用率。
- 另一個要考慮的是多個元素立即發生的運動。一個解決方法是將所有動畫邏輯放到同一個間隔以此來解決可能的動畫調用,即使特定元素可能不需要當前幀的任何動畫
requestAnimationFrame
為了解決上述問題requestAnimationFrame產生了
function animate(){
requestAnimationFrame(animate)
}
animate()
//requestAnimationFrame(animate, element) //可以定義當前節點
requestAnimationFrame的幀率取決于你的瀏覽器以及計算機,但一般來說都是60fps。requestAnimationFrame關鍵的就是他只是請求瀏覽器在下一次可以獲得的機會去展示一幀畫面,而不是在一個已經規劃好的間隔。也就是說瀏覽器能夠根據頁面加載,元素顯示,電池的狀態來選擇requestAnimationFrame的性能。
另外一個requestAnimationFrame的優點是它能夠將所有的動畫都放到一個瀏覽器重繪周期里去做,這樣能保存你的CPU的循環次數,讓你的設備存活時間更長。
當然在用requestAnimationFrame設置動畫后,當頁面出現新的tab后,動畫也會停止,從而減少計算機的開銷。
下面是requestAnimationFrame的polyfill
(function() {
var lastTime = 0;
var vendors = ['ms', 'moz', 'webkit', 'o'];
for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame'];
window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame']
|| window[vendors[x]+'CancelRequestAnimationFrame'];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function(callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function() { callback(currTime + timeToCall); },
timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function(id) {
clearTimeout(id);
};
}());
通過以上polyfill可知,requestAnimationFrame可以用setTimeout來改寫,但注意到所有的callback的執行時間都控制在16ms以內,這也就說明了,requestAnimationFrame每次的執行都是在頁面刷新頻率以內的。
當然,我們也可以自己來控制幀率
var fps = 15;
function animate() {
setTimeout(function() {
requestAnimationFrame(animate);
}, 1000 / fps);
}
當然還有更復雜的辦法
var time;
function animate() {
requestAnimationFrame(animate);
var now = new Date().getTime(),
dt = now - (time || now);
time = now;
// 比如更新x的位置:
this.x += 10 * dt;
// 每毫秒增加10個單位
}
那么我們如何在實際開發中用requestAnimationFrame來優化呢?我們假設有這樣的應用場景,如果頁面要加載上千甚至上萬張圖片(或者說是li),我們模擬的就是成千上萬個dom。
分析:出現卡頓感的主要原因是每次循環都會修改 DOM 結構,考慮上萬張圖片,用戶不會立即看到,所以我們可以縮短循環次數,并且減少DOM操作來進行優化。
- 減少操作DOM,我們可以使用DocumentFragment
- 減少循環時間,使用分治的思想,把30000個li分批次插入到頁面中,每次插入的時機是在頁面重新渲染之前
下面是完整的代碼示例:(在這里用背景顏色代替圖片)
<ul id="js-list"></ul>
ul#js-list{
padding:0px;
display:flex;
flex-wrap:wrap
}
li{
list-style:none;
text-align:center;
line-height:50px;
width:50px;
height:50px;
border:1px solid #000;
}
(() => {
const ndContainer = document.getElementById('js-list');
if (!ndContainer) {
return;
}
const total = 30000;
const batchSize = 4; // 每批插入的節點次數,越大越卡
const batchCount = total / batchSize; // 需要批量處理多少次
let batchDone = 0; // 已經完成的批處理個數
var getRandomColor = function(){
return '#'+Math.floor(Math.random()*16777215).toString(16);
}
function appendItems() {
const fragment = document.createDocumentFragment();
for (let i = 0; i < batchSize; i++) {
const ndItem = document.createElement('li');
ndItem.innerText = (batchDone * batchSize) + i + 1;
ndItem.style.backgroundColor = getRandomColor()
fragment.appendChild(ndItem);
}
// 每次批處理只修改 1 次 DOM
ndContainer.appendChild(fragment);
batchDone += 1;
doBatchAppend();
}
function doBatchAppend() {
if (batchDone < batchCount) {
window.requestAnimationFrame(appendItems);
}
}
// kickoff
doBatchAppend();
ndContainer.addEventListener('click', function (e) {
const target = e.target;
if (target.tagName === 'LI') {
alert(target.innerHTML);
}
});
})();
實現的效果如圖DEMO
以上感謝王仕軍老師提供的思想跟方法。
其實requestAnimationFrame也運用到了react fiber跟angular中,本文在這里不做詳細講解,后期會針對React Fiber與requestAnimayonFrame再做一次深入探究