requestAnimationFrame

419 阅读4分钟

demo代码演示

当我们拿到一个列表数据进行渲染时,如果数据量比较大,例如下面代码中模拟渲染一万条数据,可以看到很明显的卡顿,隔了几秒才看到列表展示。

js
复制代码
// 模拟返回10000数据
const mockData = () => {
    return Array(10000).fill({
      name: '张三',
      age: 18
    })
}
this.tableData = mockData()

动画.gif

应对这种情况,主流的方案一般采用虚拟滚动进行优化,本文则主要分享一下分片思想 + requestAnimationFrame的优化方式,仅供大家学习了解!

分片思想

大量数据同时渲染,可能会造成浏览器的阻塞导致页面长时间没反应,既然数据大是罪魁祸首,那我们可以尝试将数据分片,即把一份大的数据分成一份一份较小的数据片,分好之后再把逐个将其送给浏览器进行渲染。例如一万条数据,我们可以分成100份,一份100条,每一次只渲染100条,渲染完成后继续执行下一个任务,直至把所有数据渲染完为止。

image.png

requestAnimationFrame

requestAnimationFrame(callback)window下的一个方法,跟setTimeout用法类似,接收一个回调函数,该回调函数会在浏览器下一次重绘之前执行。只不过requestAnimationFrame的时间间隔是由浏览器自己决定的,浏览器为了保证流畅性,重绘频率会与系统刷新率保持一致,如果刷新率是60hz,即表示每秒刷新60次,也就是每次重绘的间隔时间大概是1000ms/60=16.6msrequestAnimationFrame会跟随浏览器的频率,每16.6ms回调函数就被执行一次,从而避免丢帧卡顿现象。

下面我们通过将数据分片放在requestAnimationFrame里执行渲染,渲染后继续执行下一个分片数据,递归调用直到所有数据渲染完毕。

js
复制代码
const mockData = () => {
    return Array(10000).fill({
      name: '张三',
      age: 18
    })
}
const totalData = mockData()
const pageNum = 100 // 每页条数
const totalPage = Math.ceil(totalData.length / pageNum) // 总共要分成几页
let page = 1
const renderList = () => {
    // 分片渲染的数据
    const renderData = totalData.slice(
      (page - 1) * pageNum,
      page * pageNum
    )
    this.tableData.push(...renderData)
    page++

    if (page <= totalPage) {
      // 递归调用
      window.requestAnimationFrame(renderList)
    }
}

window.requestAnimationFrame(renderList)

动画2.gif

对比效果可以发现同样是加载1万条数据,通过分片渲染的方式可以明显降低延迟或卡顿的现象。

requestAnimationFrame的优势

requestAnimationFrame采用系统时间间隔,保持最佳绘制效率。不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间过长,使动画卡顿。

从实现的功能和使用方法上,requestAnimationFrame与定时器setTimeout都相似,所以说其优势是同setTimeout实现的动画相比。

a. 提升性能,防止掉帧

  • 浏览器 UI 线程:浏览器让执行 JavaScript 和更新用户界面(包括重绘和回流)共用同一个单线程,称为“浏览器 UI 线程”
  • 浏览器 UI 线程的工作基于一个简单的队列系统,任务会被保存到队列中直到进程空闲。一旦空闲,队列中的下一个任务就被重新提取出来并运行。这些任务要么是运行 JavaScript 代码,要么执行 UI 更新。
  • 通过setTimeout实现动画

    • setTimeout通过设置一个间隔时间不断改变图像,达到动画效果。该方法在一些低端机上会出现卡顿、抖动现象。这种现象一般有两个原因:

      • setTimeout的执行时间并不是确定的。

        在 JavaScript 中,setTimeout任务被放进异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列的任务是否需要开始执行。所以,setTimeout的实际执行时间一般比其设定的时间晚一些。这种运行机制决定了时间间隔参数实际上只是指定了把动画代码添加到【浏览器 UI 线程队列】中以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再执行

        js
        复制代码
        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,定时器依然在后台执行)

    js
    复制代码
    // 在浏览器开发者工具的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 开销。

    js
    复制代码
    // 在浏览器开发者工具的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 函数,都是限制回调函数执行的频率

浏览器兼容性

来源:Polyfill for requestAnimationFrame/cancelAnimationFrame

在浏览器初次加载的时候执行下面的代码即可。

js
复制代码
// 使用 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;
  }
})();

参考

链接:juejin.cn/post/684490…
链接:juejin.cn/post/726290…