requestAnimationFrame
和 FPS
大部分显示器的刷新率是60次/秒,也就是画面维持在60fps
时人眼会觉得很流畅。浏览器的页面也是一帧一帧渲染出来的,因此要保证页面流畅,每帧渲染的的时间间隔不应该超过1000 / 60 ≈ 16.7ms
,如果连续三帧低于24FPS
,则视为出现了卡顿。当然我玩LOL
的时候低于50fps
都觉得卡的一比,所以才买了凄惨虹3070
requestAnimationFrame
MDN解释:
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒60次。raf会把dom的变化集中操作,避免重复绘制。
raf
注册的回调会在每次浏览器重绘之前执行,次数通常与浏览器屏幕刷新次数相匹配,意思我60fps
的显示器,每隔16.67ms
就会刷新一次,那么我们可以在回调的里面计算当前FPS
。
// 如果连续三次绘制都低于24帧/秒,则视为卡顿
const smoothThreshold = 1000 / 24;
function calcFps() {
let now = performance.now();
let frame = 0;
let fps = 0;
function checkFps() {
frame++;
if (frame >= 3) {
console.log("页面出现了掉帧");
}
// 如果渲染下一帧超过流畅阈值,视为卡顿
if (performance.now() - now < smoothThreshold) {
fps = (frame * 1000) / (performance.now() - now);
frame = 0;
}
now = performance.now();
log(fps);
window.requestAnimationFrame(checkFps);
}
requestAnimationFrame(checkFps);
}
window.requestAnimationFrame(calcFps);
// 节流控制打印频率
function log (fps) {
return throttle(() => console.log(fps));
}
function throttle(fn, timeout = 600) {
let timer = null;
let now = performance.now();
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => {
if (performance.now() - now >= timeout) {
fn.call(null, ...args);
now = performance.now();
}
}, Math.max(timeout - (performance.now() - now)));
};
}
复制代码
为什么不使用setTimeout
计算fps?
有以下两个原因:
-
因为
setTimeout
受事件队列的影响,回调的执行时机要在任务栈清空后才会压栈,这就导致setTimeout
回调执行时机往往会比设置的间隔时间要晚,而requestAnimationFrame
的回调是由浏览器调度的,在绘制帧前会自动执行,不存在精度问题。 -
不同设备的屏幕绘制频率可能会不同,比如
120hz
的屏幕绘制时间就是8.4ms
, 而setTimeout
只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。
使用requestAnimationFrame
和setTimeout
做动画
实现动画的方式可以通过css
或者js
,先讨论js
做动画的一个表现情况。其中比较requestAnimationFrame
和setTimeout
谁更合适做动画。下面使用两个小球做匀速运动,1号使用setTimeout
, 2号球用raf
。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
.span2,
.span4 {
color: white;
text-align: center;
line-height: 30px;
height: 30px;
width: 30px;
background-color: red;
border-radius: 50%;
margin-bottom: 50px;
}
</style>
</head>
<body>
<div class="span2">1</div>
<div class="span4">2</div>
<script>
// 设定为60帧
const threshold = 1000 / 60;
const duration = 5000;
// 每一帧向前挪动距离
const step = 1000 / (duration / threshold);
const span2 = document.querySelector(".span2");
const span4 = document.querySelector(".span4");
let n = performance.now();
function moveSetTimeout(el, count = 0) {
setTimeout(() => {
count++;
// 测试回调执行时机是否会比设置的间隔时间要晚
if (performance.now() - n > 20) {
console.log("setimeout callback trigger:", performance.now() - n);
}
n = performance.now();
el.style.transform = `translateX(${count * step}px)`;
if (count * step < 1000) {
moveSetTimeout(el, count);
}
}, threshold);
}
function moveRaf(el, count = 0) {
const run = () => {
count++;
el.style.transform = `translateX(${count * step}px)`;
if (count * step < 1000) {
window.requestAnimationFrame(run);
}
};
window.requestAnimationFrame(run);
}
moveSetTimeout(span2);
window.requestAnimationFrame(() => moveRaf(span4));
</script>
</body>
</html>
复制代码
运动动图:
可以看得出raf
2号小球运动平滑,而1号球出现了抖动丢帧的情况,而且1号球位移的距离比2号球要落后一些,位置上并没有重合。控制台多次打印。接下来分析下原因
原因分析
- 为什么定时器回调执行时机不准
- 为什么会出现抖动
为什么定时器回调执行时机不准
因为受event loop
影响,即使定时器设置了16.67ms
的间隔,但是因为定时器回调是宏任务,同步任务栈清空之前,宏任务不会被压栈,当主线程执行同步任务时间过长,定时器的回调执行时间也会被延后,这就导致了回调执行的间隔大于16.67ms
为什么会出现抖动
因为setTimeout操作的dom变化,会在浏览器下一次重绘之前执行,否则只会停留在内存中。而定时器回调因为无法保证跟浏览器重绘时间重合,会导致某一帧没有绘制,直接绘制下一帧,出现跳跃的情况,所以才会抖动。 下面举例每一帧 让dom每帧向左移动3.3px
;
const threshold = 1000 / 60;
const duration = 5000;
// 每一帧向前挪动距离
const step = 1000 / (duration / threshold); // 3.33px
复制代码
// 定时器回调每次被触发的真实时间间隔
setimeout callback call: 17.79
setimeout callback call: 21.60
setimeout callback call: 16.79
setimeout callback call: 16.79
setimeout callback call: 19.29
复制代码
根据上面控制台的定时间间隔做下面表格。
时间 / 类型 | setTimeout偏移距离 |
raf偏移距离 |
浏览器是否绘制 |
---|---|---|---|
0ms | 0 | 0 | 否 |
16.7ms | 0 | 向左偏移3.3px | 是 |
17.79ms | 回调执行,设置向左偏移3.3px,等待下次绘制 | 0 | 否 |
33.4ms | 向左偏移3.3px | 向左偏移3.3px | 是 |
39.39 | 回调执行,设置向左偏移3.3px,等待下次绘制 | 0 | 否 |
50.10ms | 向左偏移3.3px | 向左偏移3.3px | 是 |
56.18ms | 回调执行,设置向左偏移3.3px,等待下次绘制 | 0 | 否 |
66.80ms | 向左偏移3.3px | 向左偏移3.3px | 是 |
根据上面的表格可以看得出,浏览器渲染第四帧的时候,定时器才渲染了三帧的位移,而raf
保持一致,后面偏差会慢慢偏大,会出现定时器某一帧没有渲染,直接渲染后面某一帧,出现跳跃问题。因此尽量不要使用定时器做动画,如果要使用js做动画,应该使用raf
。
定时器和raf
同异
共同点
- 注册的回调都是宏任务,受event-loop管控
- 运行在后台标签页或者隐藏的
iframe
里时,会被暂停调用以提升性能和电池寿命
为什么说受event-loop
管控,看下下面代码
moveSetTimeout(span2);
window.requestAnimationFrame(() => moveRaf(span4));
setTiemout(() => {
const now = performance.now();
// js线程直接挂起2000ms
while(performance.now() - now < 2000) {}
}, 1000)
复制代码
两个小球动会在1s后暂停2s, 然后继续动画,这是因为js任务栈被挂起两秒,而js线程和浏览器UI线程是互斥,raf和定时器的回调一直处于等待中,直到js线程中的任务栈被清空。因此即使使用raf
做动画需要考虑这种场景,否则raf
也可以做出很卡的动画。
差异
- raf可以确保该回调函数会在浏览器下一次重绘之前执行,而定时器回调执行时机跟浏览器绘制不同步
- raf可以智能跟随屏幕刷新率来确保回调执行,而定时器需要手动设置时间间隔
- raf需要
IE10
以上版本支持,定时器无限制
动画尽量使用css3替代js
用Css3代替js做动画有以下好处:
- 脱离js线程影响(首次渲染后),即使js线程挂起了,并不影响动画, 因为动画运行在合成线程上
- 可以利用硬件GPU加速
- css3动画不会触发浏览器重绘重排
关于详细说明可以查看这里:主线程和合成线程
关于第一点可以用下面代码验证: JS线程挂起了2秒,并不影响动画的渲染。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
.span1 {
height: 30px;
display: inline-block;
width: 30px;
background-color: red;
border-radius: 50%;
margin-bottom: 20px;
animation: 5s linear move;
color: white;
text-align: center;
line-height: 30px;
}
@keyframes move {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(100vw));
}
}
</style>
</head>
<body>
<span class="span1">1</span>
<script>
setTimeout(() => {
let now = performance.now();
while(performance.now() - now < 2000){}
}, 1000)
</script>
</body>
</html>
复制代码
这也是为什么即使JS主线程卡住了,CSS 动画依然能执行。
第三点可以通过peformance
面板录制查看
总结
通过分析比较,更好的了解raf和定时器的做动画的差异,顺带复习了下event-loop和浏览器线程。
参考: