你知道的requestAnimationFrame【從0到0.1】

慎玄發表於2019-01-14

隨著技術與裝置的發展,使用者的終端對動畫的表現能力越來越強,更多的場景開始大量使用動畫。在 Web 應用中,實現動畫效果的方法比較多,JavaScript 中可以通過定時器 setTimeout 來實現,css3 可以使用 transitionanimation 來實現,html5 中的 canvas 也可以實現。除此之外,html5 還提供一個專門用於請求動畫的 API,即 requestAnimationFrame

本文內容均非原創,而是在知識點的收集與搬運中學習與理解,也歡迎大家收集與搬運本篇文章!

1. 是什麼

  • HTML5 新增加的 API,類似於 setTimeout 定時器

  • window 物件的一個方法,window.requestAnimationFrame

    partial interface Window {
      long requestAnimationFrame(FrameRequestCallback callback);
      void cancelAnimationFrame(long handle);
    };
    複製程式碼
  • 瀏覽器(所以只能在瀏覽器中使用)專門為動畫提供的 API,讓 DOM 動畫、Canvas 動畫、SVG 動畫、WebGL 動畫等有一個統一的重新整理機制

2. 做什麼

  • 瀏覽器重繪頻率一般會和顯示器的重新整理率保持同步。大多數瀏覽器採取 W3C 規範的建議,瀏覽器的渲染頁面的標準幀率也為 60FPS(frames/ per second)
  • 按幀對網頁進行重繪。該方法告訴瀏覽器希望執行動畫並請求瀏覽器在下一次重繪之前呼叫回撥函式來更新動畫

  • 由系統來決定回撥函式的執行時機,在執行時瀏覽器會自動優化方法的呼叫

    • 顯示器有固定的重新整理頻率(60Hz 或 75Hz),也就是說,每秒最多隻能重繪 60 次或 75 次,requestAnimationFrame 的基本思想讓頁面重繪的頻率與這個重新整理頻率保持同步

      比如顯示器螢幕重新整理率為 60Hz,使用requestAnimationFrame API,那麼回撥函式就每1000ms / 60 ≈ 16.7ms執行一次;如果顯示器螢幕的重新整理率為 75Hz,那麼回撥函式就每1000ms / 75 ≈ 13.3ms執行一次。

    • 通過requestAnimationFrame呼叫回撥函式引起的頁面重繪或迴流的時間間隔和顯示器的重新整理時間間隔相同。所以 requestAnimationFrame 不需要像setTimeout那樣傳遞時間間隔,而是瀏覽器通過系統獲取並使用顯示器重新整理頻率

      比如一個動畫,寬度從 0px 加一遞增到 100px。無緩動效果的情況下,瀏覽器重繪一次,寬度就加 1。

3. 用法

動畫幀請求回撥函式列表:每個 Document 都有一個動畫幀請求回撥函式列表,該列表可以看成是由<handle, callback>元組組成的集合。

  • handle 是一個整數,唯一地標識了元組在列表中的位置,cancelAnimationFrame()可以通過它停止動畫
  • callback 是一個無返回值的、形參為一個時間值的函式(該時間值為由瀏覽器傳入的從 1970 年 1 月 1 日到當前所經過的毫秒數)。
  • 剛開始該列表為空。

頁面可見性 API

  • 當頁面被最小化或者被切換成後臺標籤頁時,頁面為不可見,瀏覽器會觸發一個visibilitychange事件,並設定document.hidden屬性為true
  • 當頁面切換到顯示狀態,頁面變為可見,同時觸發一個visibilitychange事件,設定document.hidden屬性為false
  • 呼叫操作。與setTimeout相似,但是不需要設定間隔時間,使用一個回撥函式作為引數,返回一個大於 0 的整數

    handle = requestAnimationFrame(callback);
    複製程式碼
    • 引數callback,是一個回撥函式,在下次重新繪製動畫時呼叫。該回撥函式接收唯一引數,是一個高精度時間戳(performance.now()),指觸發回撥函式的當前時間(不用手動傳入)
    • 返回值是一個long型的非零整數,是requestAnimationFrame回撥函式列表中唯一的標識,表示定時器的編號,無其他意義
  • 取消操作

    cancelAnimationFrame(handle);
    複製程式碼
    • 引數是呼叫requestAnimationFrame時的返回值
    • 取消操作沒有返回值
  • 瀏覽器執行過程

    • 首先判斷document.hidden屬性是否為true(頁面是否可見),頁面處於可見狀態才會執行後面步驟

    • 瀏覽器清空上一輪的動畫函式

    • requestAnimationFrame將回撥函式追加到動畫幀請求回撥函式列表的末尾

      當執行requestAnimationFrame(callback)的時候,不會立即呼叫 callback 函式,只是將其放入佇列。每個回撥函式都有一個布林標識cancelled,該標識初始值為false,並且對外不可見。

    • 當瀏覽器再執行列表中的回撥函式的時候,判斷每個元組的 callback 的cancelled,如果為false,則執行 callback

      當頁面可見並且動畫幀請求回撥函式列表不為空,瀏覽器會定期將這些回撥函式加入到瀏覽器 UI 執行緒的佇列中

    • 部落格園上yyc 元超的文章深入理解 requestAnimationFrame中提供了讓一個虛擬碼,用來說明“取樣所有動畫”任務的執行步驟

      var list = {};
      var browsingContexts = 瀏覽器頂級上下文及其下屬的瀏覽器上下文;
      for (var browsingContext in browsingContexts) {
      /* !將時間值從 DOMTimeStamp 更改為 DOMHighResTimeStamp 是 W3C 針對基於指令碼動畫計時控制規範的最新編輯草案中的最新更改,
       * 並且某些供應商仍將其作為 DOMTimeStamp 實現。
       * 較早版本的 W3C 規範使用 DOMTimeStamp,允許你將 Date.now 用於當前時間。
       * 如上所述,某些瀏覽器供應商可能仍實現 DOMTimeStamp 引數,或者尚未實現 window.performance.now 計時函式。
       * 因此需要使用者進行polyfill
       */
          var time = DOMHighResTimeStamp   //從頁面導航開始時測量的高精確度時間。DOMHighResTimeStamp 以毫秒為單位,精確到千分之一毫秒。此時間值不直接與 Date.now() 進行比較,後者測量自 1970 年 1 月 1 日至今以毫秒為單位的時間。如果你希望將 time 引數與當前時間進行比較,請使用當前時間的 window.performance.now。
        var d = browsingContext 的 active document;   //即當前瀏覽器上下文中的Document節點
          //如果該active document可見
          if (d.hidden !== true) {
              //拷貝 active document 的動畫幀請求回撥函式列表到 list 中,並清空該列表
              var doclist = d的動畫幀請求回撥函式列表
              doclist.appendTo(list);
              clear(doclist);
          }
          //遍歷動畫幀請求回撥函式列表的元組中的回撥函式
          for (var callback in list) {
              if (callback.cancelled !== true) {
                  try {
                      //每個 browsingContext 都有一個對應的 WindowProxy 物件,WindowProxy 物件會將 callback 指向 active document 關聯的 window 物件。
                      //傳入時間值time
                      callback.call(window, time);
                  }
                  //忽略異常
                  catch (e) {
                  }
              }
          }
      }
      複製程式碼
    • 當呼叫cancelAnimationFrame(handle)時,瀏覽器會設定該 handle 指向的回撥函式的cancelledtrue(無論該回撥函式是否在動畫幀請求回撥函式列表中)。如果該 handle 沒有指向任何回撥函式,則什麼也不會發生。

  • 遞迴呼叫。要想實現一個完整的動畫,應該在回撥函式中遞迴呼叫回撥函式

    let count = 0;
    let rafId = null;
    /**
     * 回撥函式
     * @param time requestAnimationFrame 呼叫該函式時,自動傳入的一個時間
     */
    function requestAnimation(time) {
      console.log(time);
      // 動畫沒有執行完,則遞迴渲染
      if (count < 50) {
        count++;
        // 渲染下一幀
        rafId = requestAnimationFrame(requestAnimation);
      }
    }
    // 渲染第一幀
    requestAnimationFrame(requestAnimation);
    複製程式碼
  • 如果在執行回撥函式或者 Document 的動畫幀請求回撥函式列表被清空之前多次呼叫 requestAnimationFrame 呼叫同一個回撥函式,那麼列表中會有多個元組指向該回撥函式(它們的 handle 不同,但 callback 都為該回撥函式),“採集所有動畫”任務會執行多次該回撥函式。(類比定時器setTimeout

    function counter() {
      let count = 0;
      function animate(time) {
        if (count < 50) {
          count++;
          console.log(count);
          requestAnimationFrame(animate);
        }
      }
      requestAnimationFrame(animate);
    }
    btn.addEventListener("click", counter, false);
    複製程式碼
    • 多次點選按鈕,會發現列印出來多個序列數值(下圖中,連續觸發三次,列印了三個有序列)

      多次呼叫回撥函式

    • 如果是作用於動畫,動畫會出現突變的情況

4. 相容性

來源:Polyfill for requestAnimationFrame/cancelAnimationFrame

在瀏覽器初次載入的時候執行下面的程式碼即可。

// 使用 Date.now 獲取時間戳效能比使用 new Date().getTime 更高效
if (!Date.now)
  Date.now = function() {
    return new Date().getTime();
  };

(function() {
  "use strict";

  var vendors = ["webkit", "moz"];
  for (var i = 0; i < vendors.length && !window.requestAnimationFrame; ++i) {
    var vp = vendors[i];
    window.requestAnimationFrame = window[vp + "RequestAnimationFrame"];
    window.cancelAnimationFrame =
      window[vp + "CancelAnimationFrame"] ||
      window[vp + "CancelRequestAnimationFrame"];
  }
  // 上面方法都不支援的情況,以及IOS6的裝置
  // 使用 setTimeout 模擬實現
  if (
    /iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) ||
    !window.requestAnimationFrame ||
    !window.cancelAnimationFrame
  ) {
    var lastTime = 0;
    // 和通過時間戳實現節流功能的函式相似
    window.requestAnimationFrame = function(callback) {
      var now = Date.now();
      var nextTime = Math.max(lastTime + 16, now);
      // 實際上第1幀是不準確的,首次nextTime - now = 0
      return setTimeout(function() {
        callback((lastTime = nextTime));
      }, nextTime - now);
    };
    window.cancelAnimationFrame = clearTimeout;
  }
})();
複製程式碼

5. 優勢

requestAnimationFrame採用系統時間間隔,保持最佳繪製效率。不會因為間隔時間過短,造成過度繪製,增加開銷;也不會因為間隔時間過長,使動畫卡頓。

從實現的功能和使用方法上,requestAnimationFrame與定時器setTimeout都相似,所以說其優勢是同setTimeout實現的動畫相比。

a. 提升效能,防止掉幀

  • 瀏覽器 UI 執行緒:瀏覽器讓執行 JavaScript 和更新使用者介面(包括重繪和迴流)共用同一個單執行緒,稱為“瀏覽器 UI 執行緒”
  • 瀏覽器 UI 執行緒的工作基於一個簡單的佇列系統,任務會被儲存到佇列中直到程式空閒。一旦空閒,佇列中的下一個任務就被重新提取出來並執行。這些任務要麼是執行 JavaScript 程式碼,要麼執行 UI 更新。
  • 通過setTimeout實現動畫

    • setTimeout通過設定一個間隔時間不斷改變影象,達到動畫效果。該方法在一些低端機上會出現卡頓、抖動現象。這種現象一般有兩個原因:

      • setTimeout的執行時間並不是確定的。

        在 JavaScript 中,setTimeout任務被放進非同步佇列中,只有當主執行緒上的任務執行完以後,才會去檢查該佇列的任務是否需要開始執行。所以,setTimeout的實際執行時間一般比其設定的時間晚一些。這種執行機制決定了時間間隔引數實際上只是指定了把動畫程式碼新增到【瀏覽器 UI 執行緒佇列】中以等待執行的時間。如果佇列前面已經加入了其他任務,那動畫程式碼就要等前面的任務完成後再執行

        let startTime = performance.now();
        setTimeout(() => {
          let endTime = performance.now();
          console.log(endTime - startTime);
        }, 50);
        /* 一個非常耗時的任務 */
        for (let i = 0; i < 20000; i++) {
          console.log(0);
        }
        複製程式碼

        定時器

      • 重新整理頻率受螢幕解析度和螢幕尺寸影響,不同裝置的螢幕重新整理率可能不同,setTimeout只能設定固定的時間間隔,這個時間和螢幕重新整理間隔可能不同

    • 以上兩種情況都會導致setTimeout的執行步調和螢幕的重新整理步調不一致,從而引起丟幀現象。

      • setTimeout的執行只是在記憶體中對影象屬性進行改變,這個改變必須要等到下次瀏覽器重繪時才會被更新到螢幕上。如果和螢幕重新整理步調不一致,就可能導致中間某些幀的操作被跨越過去,直接更新下下一幀的影象。

        假如使用定時器設定間隔 10ms 執行一個幀,而瀏覽器重新整理間隔是 16.6ms(即 60FPS)

        丟幀

        由圖可知,在 20ms 時,setTimeout呼叫回撥函式在記憶體中將影象的屬性進行了修改,但是此時瀏覽器下次重新整理是在 33.2ms 的時候,所以 20ms 修改的影象沒有更新到螢幕上。 而到了 30ms 的時候,setTimeout又一次呼叫回撥函式並改變了記憶體中影象的屬性,之後瀏覽器就重新整理了,20ms 更新的狀態被 30ms 的影象覆蓋了,螢幕上展示的是 30ms 時的影象,所以 20ms 的這一幀就丟失了。丟失的幀多了,畫面就卡頓了。

  • 使用 requestAnimationFrame 執行動畫,最大優勢是能保證回撥函式在螢幕每一次重新整理間隔中只被執行一次,這樣就不會引起丟幀,動畫也就不會卡頓

b. 節約資源,節省電源

  • 使用 setTimeout 實現的動畫,當頁面被隱藏或最小化時,定時器setTimeout仍在後臺執行動畫任務,此時重新整理動畫是完全沒有意義的(實際上 FireFox/Chrome 瀏覽器對定時器做了優化:頁面閒置時,如果時間間隔小於 1000ms,則停止定時器,與requestAnimationFrame行為類似。如果時間間隔>=1000ms,定時器依然在後臺執行)

    // 在瀏覽器開發者工具的Console頁執行下面程式碼。
    // 當開始輸出count後,切換瀏覽器tab頁,再切換回來,可以發現列印的值沒有停止,甚至可能已經執行完了
    let count = 0;
    let timer = setInterval(() => {
      if (count < 20) {
        count++;
        console.log(count);
      } else {
        clearInterval(timer);
        timer = null;
      }
    }, 2000);
    複製程式碼
  • 使用requestAnimationFrame,當頁面處於未啟用的狀態下,該頁面的螢幕重新整理任務會被系統暫停,由於requestAnimationFrame保持和螢幕重新整理同步執行,所以也會被暫停。當頁面被啟用時,動畫從上次停留的地方繼續執行,節約 CPU 開銷。

    // 在瀏覽器開發者工具的Console頁執行下面程式碼。
    // 當開始輸出count後,切換瀏覽器tab頁,再切換回來,可以發現列印的值從離開前的值繼續輸出
    let count = 0;
    function requestAnimation() {
      if (count < 500) {
        count++;
        console.log(count);
        requestAnimationFrame(requestAnimation);
      }
    }
    requestAnimationFrame(requestAnimation);
    複製程式碼

c. 函式節流

  • 一個重新整理間隔內函式執行多次時沒有意義的,因為顯示器每 16.7ms 重新整理一次,多次繪製並不會在螢幕上體現出來
  • 在高頻事件(resizescroll等)中,使用requestAnimationFrame可以防止在一個重新整理間隔內發生多次函式執行,這樣保證了流暢性,也節省了函式執行的開銷
  • 某些情況下可以直接使用requestAnimationFrame替代 Throttle 函式,都是限制回撥函式執行的頻率

6. 應用

  • 簡單的進度條動畫

    function loadingBar(ele) {
      // 使用閉包儲存定時器的編號
      let handle;
      return () => {
        // 每次觸發將進度清空
        ele.style.width = "0";
        // 開始動畫前清除上一次的動畫定時器
        // 否則會開啟多個定時器
        cancelAnimationFrame(handle);
        // 回撥函式
        let _progress = () => {
          let eleWidth = parseInt(ele.style.width);
          if (eleWidth < 200) {
            ele.style.width = `${eleWidth + 5}px`;
            handle = requestAnimationFrame(_progress);
          } else {
            cancelAnimationFrame(handle);
          }
        };
        handle = requestAnimationFrame(_progress);
      };
    }
    複製程式碼
  • 新增緩動效果,實現一個元素塊按照三階貝塞爾曲線的ease-in-out緩動特效引數運動。如何使用 Javascript 實現緩動特效

緩動動畫:指定動畫效果在執行時的速度,使其看起來更加真實。

/**
 * @param {HTMLElement} ele 元素節點
 * @param {number} change 改變數
 * @param {number} duration 動畫持續時長
 */
function moveBox(ele, change, duration) {
  // 使用閉包儲存定時器標識
  let handle;
  // 返回動畫函式
  return () => {
    // 開始時間
    let startTime = performance.now();
    // 防止啟動多個定時器
    cancelAnimationFrame(handle);
    // 回撥函式
    function _animation() {
      // 這一幀開始的時間
      let current = performance.now();
      let eleTop = ele.offsetLeft;
      // 這一幀內元素移動的距離
      let left = change * easeInOutCubic((current - startTime) / duration);
      ele.style.left = `${~~left}px`;
      // 判斷動畫是否執行完
      if ((current - startTime) / duration < 1) {
        handle = requestAnimationFrame(_animation);
      } else {
        cancelAnimationFrame(handle);
      }
    }
    // 第一幀開始
    handle = requestAnimationFrame(_animation);
  };
}
/**
 * 三階貝塞爾曲線ease-in-out
 * @param {number} k
 */
function easeInOutCubic(k) {
  return (k *= 2) < 1 ? 0.5 * k * k * k : 0.5 * ((k -= 2) * k * k + 2);
}
複製程式碼

7. 相關

本文內容均非原創,而是在知識點的收集與搬運中學習與理解,也歡迎大家收集與搬運本篇文章!

  • https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
  • https://caniuse.com/#search=requestAnimationFrame
  • https://www.zhangxinxu.com/wordpress/2013/09/css3-animation-requestanimationframe-tween-%E5%8A%A8%E7%94%BB%E7%AE%97%E6%B3%95/
  • https://javascript.ruanyifeng.com/htmlapi/requestanimationframe.html
  • https://www.cnblogs.com/xiaohuochai/p/5777186.html
  • https://juejin.im/post/5b6020b8e51d4535253b30d1
  • https://www.cnblogs.com/chaogex/p/3960175.html#explain
  • http://www.softwhy.com/article-7204-1.html
  • https://easings.net/zh-cn#
  • https://zhuanlan.zhihu.com/p/25676357
  • https://www.cnblogs.com/onepixel/p/7078617.html

相關文章