在Web应用中,实现动画效果的方法比较多,Javascript 中可以通过定时器 setTimeout 来实现,css3 可以使用 transition 和 animation 来实现,html5 中的 canvas 也可以实现。除此之外,html5 还提供一个专门用于请求动画的API,那就是 requestAnimationFrame,顾名思义就是请求动画帧
简介
window.requestAnimationFrame()
会告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()
简单来说,如果只是单纯的调用了一次 window.requestAnimationFrame(callback)
函数,浏览器在下一次重绘之前调用 callback
函数之后,这个流程就算正式结束了,如果你还想在下一次浏览器重绘之前再次调用回调函数更新动画,那么就需要在回调函数内部递归调用 window.requestAnimationFrame
函数
用法
回调函数会被传入 DOMHighResTimeStamp 参数,DOMHighResTimeStamp 指当前被 requestAnimationFrame()
排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)
window.requestAnimationFrame(function (timeStamp) {
console.log(timeStamp);
window.requestAnimationFrame(arguments.callee);
})
下图左边为我们自己打印的屏幕刷新间隔(60Hz),右边为 requestAnimationFrame
中回调函数执行时打印出来的时间戳,发现每一次回调函数均在不同的时间间隔内被执行
请确保总是使用第一个参数(或其它获得当前时间的方法)计算每次调用之间的时间间隔,否则动画在高刷新率的屏幕中会运行得更快。因为人眼对图像会有短暂的记忆,对应60Hz的屏幕刷新率差不多就够了。对于刷新率较高的设备,其实没有必要每次浏览器刷新都更新图像
下面是 MDN
官网给的示例:2s中内匀速移动一个元素
const element = document.getElementById('some-element-you-want-to-animate');
let start;
function step(timestamp) {
if (start === undefined)
start = timestamp;
const elapsed = timestamp - start;
//这里使用`Math.min()`确保元素刚好停在200px的位置。
element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
if (elapsed < 2000) { // 在两秒后停止动画
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
补充: window.requestAnimationFrame()
的返回值是一个 long
整数,请求 ID
,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame()
以取消回调函数。
setTimeout
在很多时候,我们都是依赖 setTimeout
来实现动画——通过设置一个间隔时间动态的改变图像的位置,从而达到动画的效果。但我们会发现,利用 setTimetout
实现的动画在某些低端机上会出现卡顿、抖动的现象。 这种现象的产生有两个原因:
setTimeout
的执行时间并不是确定的。在Javascript
中,setTimeout
任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,因此setTimeout
的实际执行时间一般要比其设定的时间晚一些。
const start = Date.now()
setTimeout(() => {
console.log(Date.now() - start); // 1007
}, 1000)
- 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的屏幕刷新频率可能会不同,而
setTimeout
只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。
以上两种情况都会导致 setTimeout
的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。 那为什么步调不一致就会引起丢帧呢?
首先要明白,setTimeout
的执行只是在内存中对图像属性进行改变,这个变化必须要等到屏幕下次刷新时才会被更新到屏幕上。如果两者的步调不一致,就可能会导致中间某一帧的操作被跨越过去,而直接更新下一帧的图像。假设屏幕每隔16.7ms刷新一次,而 setTimeout
每隔10ms设置图像向左移动1px, 就会出现如下绘制过程:
- 第0ms: 屏幕未刷新,等待中,
setTimeout
也未执行,等待中
- 第10ms: 屏幕未刷新,等待中,
setTimeout
开始执行并设置图像属性left=1px
- 第16.7ms: 屏幕开始刷新,屏幕上的图像向左移动了1px,
setTimeout
未执行,继续等待中
- 第20ms: 屏幕未刷新,等待中,
setTimeout
开始执行并设置left=2px
- 第30ms: 屏幕未刷新,等待中,
setTimeout
开始执行并设置left=3px
- 第33.4ms:屏幕开始刷新,屏幕上的图像向左移动了3px,
setTimeout
未执行,继续等待中
- …
从上面的绘制过程中可以看出,屏幕没有更新 left=2px
的那一帧画面,图像直接从1px的位置跳到了3px的的位置,这就是丢帧现象,这种现象会引起动画卡顿,从而给用户造成不好的体验
requestAnimationFrame 的优势
回调函数执行次数通常是每秒60次,但在大多数遵循
W3C
建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的 ](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/iframe) 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命
CPU节能
使用 setTimeout
实现的动画,当页面被隐藏或最小化时,setTimeout
仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费 CPU
资源。而requestAnimationFrame
则完全不同,当页面处理未激活的状态下,比如说缩小隐藏、切换页面等等,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的 requestAnimationFrame
也会停止渲染,当页面被重新激活时,动画才会从上次停留的地方继续执行,这种做法有效节省了 CPU
开销
注意:在笔者查阅有关 requestAnimationFrame
的文章时,经常会看到有文章说到请求动画帧可以用来做函数节流,在高频率事件(resize
、scroll
等)中,可以有效的防止在一个刷新间隔内执行多次函数。但在查阅了具体的资料和自己实践过之后发现,scroll
、resize
的过程其实就是浏览器自身的帧帧渲染,也就是说对应的事件在一个刷新间隔中只会执行一次,而就算使用了请求动画帧也是同样的效果。这样一来,使用 requestAnimationFrame
其实并没有起到优化的效果,并不适合做节流,还是更加适合在动画设计中使用
兼容性问题
由于 requestAnimationFrame
目前还存在兼容性问题,而且不同的浏览器还需要带不同的前缀。因此需要通过优雅降级的方式对 requestAnimationFrame
进行封装,优先使用高级特性,然后再根据不同浏览器的情况进行回退,直至只能使用 setTimeout
的情况。下面的代码就是有人在 github
上提供的 polyfill
,详细介绍请参考 github代码
Polyfill 是一块代码(通常是 Web 上的 JavaScript),用来为旧浏览器提供它没有原生支持的较新的功能
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']);
}
if (/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) // iOS6 is buggy || !window.requestAnimationFrame ||
!window.cancelAnimationFrame) {
var lastTime = 0;
window.requestAnimationFrame = function (callback) {
var
now = Date.now();
var nextTime = Math.max(lastTime + 16, now);
return setTimeout(function () {
callback(lastTime = nextTime);
}, nextTime - now);
};
window.cancelAnimationFrame = clearTimeout;
}
}());
面试题
熟悉事件循环的朋友都知道 js
的异步任务分为了宏任务和微任务,但我们今天的主题 requestAnimationFrame
很特殊,我们不能很决定的说它是宏任务还是微任务,但我们可以将它理解为介于微任务和宏任务之间的任务
我们知道,每次执行宏任务之前都会先检查一下微任务队列是否有微任务存在,如果存在则会先执行微任务,等到所有的微任务执行完了之后才会去执行宏任务,但其实,执行每个宏任务之前,浏览器还会判断是否快要进行绘制了,如果是则会执行 requestAnimationFrame
的回调函数,如果还没到要重新绘制的时候则直接跳过 requestAnimationFrame
去执行宏任务
浏览器的绘制和屏幕的刷新率有关,一般为 60HZ
,所以我们并不能确定 requestAnimationFrame
和其它宏任务之间的执行顺序,但能确定的是,它一定在微任务执行之后才会执行,下面看几道面试题:
- 请求动画帧和
setTimeout
结合,输出下列的打印结果:
setTimeout(() => {
console.log(1);
});
requestAnimationFrame(() => {
console.log(2);
});
setTimeout(() => {
console.log(4);
});
Promise.resolve(3).then((res) => {
console.log(res);
});
答案可能是 3 -> 1 -> 2 -> 4
、3 -> 2 ->1 -> 4
、3 -> 1 -> 4 -> 2
中的任意一个
Promise.then
中的回调函数属于微任务,微任务队列的优先级最高,下次事件循环的时候会最先执行
- 第一和第三个
setTimeout
对应的回调会按照顺序放到宏任务队列中,可以保证1一定是在4之前打印
- 但由于每次执行宏任务之前不仅要检查有无微任务,还需要检查浏览器是否准备重绘,所以
requestAnimationFrame
执行的时机和其它的宏任务并不能确定
- 在
React
中与useEffect
结合,输出下列的打印结果:
function App() {
requestAnimationFrame(() => {
console.log(1);
});
useEffect(() => {
console.log(2);
}, []);
return <div></div>;
}
答案同样不是唯一的,打印结果为 1 -> 2
或 2 -> 1
,useEffect
中的回调函数可以看成是一个特殊的宏任务,跟上题中的意思差不多,既然是宏任务,每次执行前就有可能会执行 requestAnimationFrame
的回调,当然也可能不执行,所以具体的顺序并不能确认