有关requestAnimationFrame

3,262 阅读4分钟

是什么

我们先来看一下JS的Event Loop都干了些什么:

EventLoop执行过程:

  1. 执行同步代码
  2. 执行当前队列尾部所有微任务
  3. 必要的话渲染UI(浏览器是60hz刷新率,所以16ms一帧更新一次UI)
    1. resize/scroll事件(16Ms一次,自带节流)
    2. 判断是否触发media query
    3. 更新动画发送事件
    4. 全屏操作事件
    5. 执行requestAnimationFrame回调
    6. 执行intersectionObserver回调
    7. 更新UI
    8. 如果还有时间,自行requestldleCallback

浏览器重绘频率一般会和显示器的刷新率保持同步。比如显示器屏幕刷新率为 60Hz,使用requestAnimationFrame API,那么回调函数就每1000ms / 60 ≈ 16.7ms执行一次。

requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率。通过定时器 setTimeout 或者 setInterval实现动画。但是定时器动画第一是动画的循时间环间隔不好确定,设置长了动画显得不够平滑流畅,设置短了浏览器的重绘频率会达到瓶颈,第二个问题是定时器第二个时间参数只是指定了多久后将动画任务添加到浏览器的UI线程队列中,如果UI线程处于忙碌状态,那么动画不会立刻执行。为了解决这些问题,H5 中加入了 requestAnimationFrame。

性能

谈到性能,我们再回到上文的Event Loop。当你打开一个 浏览器Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。

所以在低端机上setTimeout偶尔卡顿,是因为它是需要等待主线程代码执行的。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再添加到【浏览器 UI 线程队列】。而且刷新频率受屏幕分辨率和屏幕尺寸影响,不同设备的屏幕刷新率可能不同,setTimeout只能设置固定的时间间隔,这个时间和屏幕刷新间隔可能不同。这都会引起执行步调和屏幕的刷新步调不一致,引起丢帧。

另外,当页面处于未激活的状态下requestAnimationFrame也是暂停执行的,这也会改进性能。

重复绘制

多次调用带有同一回调函数的 requestAnimationFrame,会导致回调在同一帧中执行多次,也就是说它并不管理回调函数。可能会有性能问题。但我们也可以利用它,比如写一个旋转速度随着点击次数增加的代码。每次增加的deg都是1,但是在一帧执行多个回调函数,即绘制了多次动画,旋转越来越快。

var deg = 0;
var id;
var div = document.getElementById("div");
div.addEventListener('click', function () {
    var self = this;
    requestAnimationFrame(function change() {
        self.style.transform = 'rotate(' + (deg++) + 'deg)';
        id = requestAnimationFrame(change);
    });
});
document.getElementById('stop').onclick = function () {
    cancelAnimationFrame(id);
};

也正因为它不管理回调函数,在滚动、这类高触发频率的事件回调里,可能会造成多余的计算和绘制。例如:

window.addEventListener('scroll', e => {
    window.requestAnimationFrame(stamp => {
        animation(stamp)
    })
})

次方案是使用节流函数。但节流函数是通过时间管理队列的,而 requestAnimationFrame 的触发时间是不固定的。完美的解决方案是通过 requestAnimationFrame 来管理队列,其思路就是保证 requestAnimationFrame 的队列里,同样的回调函数只有一个。示意代码如下:

const onScroll = e => {
    if (framing) return
    let framing = true
    window.requestAnimationFrame(timestamp => {
        framing = false
        animation(timestamp)
    })
}
window.addEventListener('scroll', onScroll)

数据渲染

参见经典面试题:‘如何渲染几万条数据并不卡住界面’。如果页面不是激活状态下的话,动画会自动暂停,有效节省了CPU开销。

var total = 100000;
var size = 100;
var count = total / size;
var done = 0;
var ul = document.getElementById('list');

function addItems() {
    var li = null;
    var fg = document.createDocumentFragment();
    for (var i = 0; i < size; i++) {
        li = document.createElement('li');
        li.innerText = 'item ' + (done * size + i);
        fg.appendChild(li);
    }
    ul.appendChild(fg);
    done++;
    if (done < count) {
        requestAnimationFrame(addItems);
    }
};

requestAnimationFrame(addItems);