【譯】前端requestAnimationFrame概述

樑仔發表於2019-02-25

對JavaScript中requestAnimationFrame函式的簡單介紹,用以實現流暢的動畫。原文連結:medium.com/p/456f8a0d0…

requestAnimationFrame是一個讓我感到興奮的玩意,不是因為他有多複雜,而是這名字聽起來就足夠讓人浮想聯翩以致於很想去嘗試。requestAnimationFrame函式與動畫無關,你可以用它做很多事。

我們來談談什麼是動畫。動畫其實是一種假象,是一種不連續的運動以幀的形式呈現給我們的東西。在二十世紀,通常人們觀看的電影其實就是通過膠片記錄和投影的。它是以每秒至少24幀的速度形成的視覺上的運動起來的假象。NTSC廣播的標準的幀速率為23.975FPS,而PAL制式的為25FPS

因此,至少要以24FPS的速率才能形成動畫,但這樣的動畫並不是平滑的,流暢的。平滑的動畫要以無線幀速率才能實現,但是對於人類大腦而言是偵測不到那種情況下的幀速率,可以說60FPS就已經很不錯了。常見的電腦、智慧手機等大部分現代化裝置通常是以60FPS的速率重新整理螢幕的,少部分遊戲系統則支援120FPS。

那麼,什麼又是幀呢?這個沒有絕對的定義,它主要是依賴於使用的具體環境。例如,電影膠片的每一幀都是由所記錄的FPS決定的。在錄製視訊時,把攝像機的幀率調為30FPS,那麼就必須以30FPS的速率在1s內播放生成的30個單獨影象。然而,在討論web時,幀的定義又發生了變化。

對於web動畫,我們可以在裝置螢幕中移動1px或者更多。移動一個元素(DOM元素)的畫素越少,那麼動畫就越流暢,越平滑。幀其實就是DOM元素在螢幕上的實時位置的一個快照。在1s內,如果一個元素以 1px/次 的速度移動60px,那麼FPS值就是60。也就是說,上面等價於以 2px/次 的速度移動120px。雖然移動速度變大了,但是動畫並不會更加流暢平滑,因為相應的元素的移動距離也變大了。

那麼,如何使用JavaScript讓DOM元素產生動畫效果呢?可以使用JavaScript中的setInterval函式。setInterval可以以n毫秒的間隔時間呼叫回撥函式,而且必須在回撥函式的第二個引數傳入n值。為了實現60FPS,我們需要以60次/s 的速度移動一個元素,那意味著元素必須移動大約16.7ms(100ms/60frames)。接下來,我們在回撥執行中移動DOM元素 1px,而且每16.7ms需要呼叫一次回撥。

<div id="box"></div>
複製程式碼
#box {
  width: 50px;
  height: 50px;
  background-color: #000;
}
複製程式碼
var element = document.getElementById('box');
var left = 0;

var animateCallback = function() {
	element.style.marginLeft = (++left) + 'px';

  // clear interval after 60 frame is moved
  if (left == 60) {
    clearInterval(interval);
  }
}

var interval = setInterval(animateCallback, (1000 / 60));
複製程式碼

通過上面的程式碼,我們成功實現了一個平滑的動畫效果,但是在PC或者手機上面顯示時會存在一個很大的問題,並且很難被發現。那就是setInterval函式在n毫秒後,並不能保證被呼叫(想了解更多請閱讀Mozilla文件)。總的說來,setInterval被看成是延遲執行回撥函式的web API。然而,回撥函式總是會被阻塞,這意味著如果網頁正忙於處理其它事務,回撥就不得不等待,直到棧中的非同步任務被清空為止(瞭解更多,請閱讀how JavaScript works,該連結可以讓你瞭解到棧是什麼,以及web api工作原理)。不僅如此,回撥函式的執行可能會消耗掉比16.7ms更長的時間,這就意味著,動畫將執行超過1s(此時,回撥執行60次),60FPS也就無法實現。

接下來,我們瞭解一下什麼是失幀?首先,瀏覽器會以最大m次/秒重新整理螢幕。數字m取決於電腦的螢幕重新整理率,瀏覽器的重新整理率,以及CPU、GPU的處理能力。如果你的瀏覽器只能以30幀/s的速度重新整理螢幕(由於上面的一個或者多個原因造成),那麼以60幀/秒的速度執行動畫是沒有什麼意義的,多餘的幀數將會消失。與此同時,對DOM結構所做的更改要比瀏覽器渲染的要多,這也被稱為佈局抖動,因為這些操作是同步的,會影響網站的效能以及繪製操作,從而導致動畫效果不佳。

瀏覽器只在螢幕有樣式更改,佈局改動,以及迴流時才重新整理螢幕

此時,需要來自瀏覽器的某種回撥函式,他會告訴我們下一次螢幕重新整理的時間,或者更準確的說,是下一次繪製操作將在何時執行。這個回撥函式就是requestAnimationFrame Web API。

作為一個web api,rAF將被非同步呼叫。和setInterval不一樣,requestAnimationFrame不接收delay引數(這裡的delay指的就是setInterval的第二個引數),它只在瀏覽器準備執行下一次繪製操作時呼叫回撥函式,因此我們要在回撥函式中移動DOM元素。

讓我們看一下前面的requestAnimationFrame函式例子

<div id="box"></div>
複製程式碼
#box{
  width: 50px;
  height: 50px;
  background-color: #000;
}
複製程式碼
var element = document.getElementById('box');
var left = 0;
var rAF_ID;

var rAFCallback = function(){
	element.style.marginLeft = (++left) + 'px';
  // cancel animation frame after 60px
  if( left == 60 ) {
    cancelAnimationFrame(rAF_ID);
  }else {
  	rAF_ID = requestAnimationFrame( rAFCallback );
  }
}

rAF_ID = requestAnimationFrame( rAFCallback );
複製程式碼

對比前後js程式碼,我們可能已經注意到了setInterval和requestAnimationFrame之間的一些差異。首先rAF不會在每次每次繪製時自動呼叫。每次更改元素時都需要發出請求。當瀏覽器計劃進行下一次繪製操作時,這些呼叫將被一一壓棧,並被執行。棧中佇列可以在for迴圈,while迴圈中或者在更加準確的遞迴函式中進行。

requestAnimationFrame返回請求的id(整數),我們可以使用這個id來取消請求,使用cancelAnimationFrame(id)方法會取消回撥的執行,從而停止動畫的執行(這裡使用的遞迴)。rAF並不能保證提供60FPS的動畫效果,這只是一種避免丟幀以及提高效率的方法,從而幫助獲取更多的FPS值。這就意味著,如果我們通過使用移動多少畫素來取消動畫,例如上面例子的60px,那麼根據系統中瀏覽器重新整理率,動畫可以持續1s(60FPS)、2s(120FPS)或者更長時間。

那麼,我們究竟應該如何保證在FPS為任意值時,我們的動畫必須在1s內完成,且元素在1s內移動60px呢?這時候就出現了回撥函式引數。

當我們把回撥函式引數傳遞給rAF時,rAF將傳遞時間戳引數(timestamp),該引數以毫秒(ms)為單位,表示web頁面載入以來消耗的時間。該時間戳函式給出了呼叫回撥的準確時間。

因此,在我們想出解決這個難題的邏輯之前,我們已經知道了動畫的持續時間(1s)和距離(60px),我們需要計算在對應的時間裡,我們的progress(根據上下文理解)有多少,再用這個progress乘以60px,這裡的progress代表在第一次回撥執行以來已經用掉了多少時間。公式如下:

progress = ( starttime - timestamp ) / duration
複製程式碼

用progress值乘以距離,我們得到了在下一次繪製時DOM元素的畫素值。

position = distance * progress
複製程式碼

一旦progress達到了100% ,我們就需要停止呼叫requestAnimationFrame函式。此時可能會出現progress超過100%的情況, 出現開始的時間間隔為980ms (第一次回撥操作的時間戳和當前的時間戳之間的差異),下次則可能為1050ms。

當為980ms時,我們不能停止動畫,因為如果按照上面的公式,我們還沒有完全移動元素,這就是為什麼我們需要最小的progress值(100)。

公式如下:

safeProgress = Math.min(progress, 1) // 1 == 100%
複製程式碼

上面的公式也可能出現因為progress的浮點數而造成位置也有浮點數。在這裡我們計算的是css畫素,它與裝置畫素十分不同(深入閱讀請點選連結。css畫素實際上是畫素密度值,即在實際裝置上渲染一個畫素物件(CSS中提到的畫素)需要佔用多少畫素。因此,我們可以使用css畫素浮點值,幸運的話我們仍可以在實際裝置上通過一些畫素來使DOM元素移動。程式碼如下

<div id="box"></div>
複製程式碼
#box{
  width: 50px;
  height: 50px;
  background-color: #000;
}
複製程式碼
var element = document.getElementById('box');
var startTime;
var duration = 1000; // 1 second or 1000ms
var distance = 60; // 60FPS

var rAFCallback = function( timestamp ){
	startTime = startTime || timestamp; // set startTime is null

  var timeElapsedSinceStart = timestamp - startTime;
  var progress = timeElapsedSinceStart / 1000;

  var safeProgress = Math.min( progress.toFixed(2), 1 ); // 2 decimal points

  var newPosition = safeProgress * distance;

  element.style.transform = 'translateX('+ newPosition + 'px)';

  // we need to progress to reach 100%
  if( safeProgress != 1 ){
  	requestAnimationFrame( rAFCallback );
  }
}

// request animation frame on render
requestAnimationFrame( rAFCallback );
複製程式碼

注意:這裡使用transform而不是marginLeft

以上就是requestAnimationFrame的全部執行機制,requestAnimationFrame相較於setInterval或者setTimeout還有其他優勢,就是當瀏覽器tab頁面未使用時,requestAnimationFrame會通過組織requestAnimationFrame回撥的方式暫停動畫,這樣既能節省電量又能保留動畫的狀態。而唯一的不足可能就是它天生的不確定性,我們不知道它何時被呼叫,但這就是我們必須要面對的。

相關文章