利用 requestAnimationFrame 优化动画

背景

使用 Web 技术实现元素的动画效果我们常用的技术有 CSS Animation 动画或者利用定时器重绘元素样式以模拟动画效果,比如元素的淡入淡出。CSS 动画我们只需要指定其各个状态下的值,浏览器就会自动帮我们绘制流畅的动画效果了。

但使用定时器去绘制动画的时候,浏览器并没有提供什么优化措施,其流畅度完全受其运行状态影响,比如:

  • 主线程阻塞,计算时间超过帧等待时间造成帧丢失的情况
  • 定时器无法感知浏览器重绘时间点,可能会恰好错过,导致需要额外请求一次重绘
  • 如果帧率设置过高(比如120),超过了浏览器重绘的频率(比如60),还会造成多余的计算量(多了60次)
  • 定时器回调执行的时间并不是严格意义上与其延时参数一致,往往受到当时执行环境资源的印象,导致帧与帧之间的时间间隔并不一致
  • 这时候就轮到 requestAnimationFrame 闪亮登场了。

requestAnimationFrame 是什么?

requestAnimationFrame 本质也是一种定时器,其是浏览器新加入的 API 用来优化动画效果。其特点可归纳如下:

  • 浏览器可以把并行的多个动画操作集成在一次重排版/重绘(reflow/repaint) 中进行,以达到优化性能与流畅动画的目的
  • 如果使用了动画的页面并不在当前浏览器主页面,那么会暂停动画的执行,以达到减少资源占用的目的
  • 其绘制频率基本与用户的屏幕刷新率一致,大多数在60帧左右

如何使用?

如果我们要做一个60帧的数字计数器动画效果,以前我们会这样写:

var el = document.queryElementById('counter');
var num = 0;
function countUp() {
  el.textContent = num;
  if (num < 100) {
    setTimeout(countUp, 1000 / 60);
  }
  num++;
}
countUp();

采用新的 API 这样写即可:

var el = document.queryElementById('counter');
var num = 0;
function countUp() {
  el.textContent = num;
  if (num < 100) {
    window.requestAnimationFrame(countUp);
  }
  num++;
}
countUp();

可以看出来其只是替换了原来已有的setTimeout方法,并且不需要开发者自己指定延时,默认一般也是60fps的效果。

如果需要取消动画,调用 cancelAnimationFrame 方法即可:

var animation = window.requestAnimationFrame(...);
window.cancelAnimationFrame(animation);

自定义帧率

理想情况下 requestAnimationFrame 的绘制频率是与显示器是一致的(60fps),如果想要人为的控制帧率,比较直接的做法如下:

var el = document.queryElementById('counter');
var num = 0;
var fps = 30;
function countUp() {
  el.textContent = num;
  if (num < 100) {
    setTimeout(function () {
      window.requestAnimationFrame(countUp);
    }, 1000 / fps);
  }
  num++;
}
countUp();

但如上做法有个缺陷,每次 setTimeout 回调执行的时候,还需要再等到下一个 requestAnimationFrame 事件才行。为了去掉额外的 setTimeout 调用,我们让回调自行计算时间间隔即可,如下:

var el = document.queryElementById('counter');
var num = 0;
var fps = 30;
var interval = 1000 / fps;
var delta;
var now;
var latest = Date.now();
function countUp() {
  if (num < 100) {
    window.requestAnimationFrame(countUp);
  }
  now = Date.now();
  delta = now - latest;
  if (delta > interval) {
    latest = now - (delta % interval);
    el.textContent = num;
    num++;
  }
}
countUp();