前言

如果你要做一个动画,你会使用什么来做?是CSS3,requestAnimationFrame,还是setTimeout,这篇我们用一个例子介绍一下怎么样选择它们,才能让你的动画尽可能流畅。

SetTimeout

这里我们用SetTimeout制作一个非常简单的动画:把页面中的几个div来回平移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.box {
width: 100px;
height: 100px;
background: yellow;
border: 5px solid cornflowerblue;
box-sizing: border-box;
position: relative;
}

</style>
</head>
<body>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<script>
// 在2s时让浏览器卡死1s
setTimeout(() => {
function sleep(sleepTime) {
for(var start = new Date; new Date - start <= sleepTime;) {}
}
sleep(1000);
}, 2000);
// 每隔30ms进行一次运算
setInterval(function calSum() {
let sum = 0;
for (let i = 0; i < 10000000; i++) {
sum += i;
}
}, 30)

let boxs = document.querySelectorAll(".box");
let left = 0;
let flag = 1;
// setTimeout动画
setTimeout(function animation() {
if (flag) {
left += 1;
} else {
left -= 1;
}
if (left >= 200) {
flag = 0;
}
else if (left <= 0) {
flag = 1;
}
for (let i = 0; i < boxs.length; i++) {
let box = boxs[i]
box.style.left = left + "px";
}
setTimeout(animation, 16.7)
}, 16.7)

</script>
</body>
</html>

我们使用Perforence来进行观测

image-20210121151842379

我们可以看到,每一帧的时间都不相同,我的屏幕是60hz的,如果希望动画能尽可能流畅,那就需要每一帧要在1000 ms / 60 = 16.7ms之内渲染出来,但是setTimeout显然不能满足这个需求

image-20210121152631906

如果我们放大详细比较两个帧,就会发现那个隔了27ms的才生成出来的帧前面有一块非常长的时间的空闲的,而当animation在setTimeout中设置的时间到时,calSum正在运行,而且还在运行,而setTimeout设置的回调会被推到宏任务队列中排毒等待执行,所以用setTimeout设置的animation浏览器压根就无法保证每一帧渲染的时间间隔,反映到用户面前的就是动画卡顿,掉帧,不流畅。

image-20210121214734999

事实上,setTimeout有两个非常明显的缺点

  1. 如果动画对应的回调函数前,有许多任务在排队,动画对应的回调有可能不能及时执行

  2. 重复(未来得及执行的)定时器函数有可能会完全填满整个队列

比如说由于某些原因,回调函数会花费很多时间才能执行完毕,甚至比你设定的间隔时间还要长。但是一旦计时器时间到了,它会把下一个回调函数推入队列等待执行,即使前一个还没执行完。如果这一过程不断重复,整个队列都是等待执行的定时器函数,就非常容易影响其他的脚本执行

image-20210121165648564

  1. 另外,因为两者的执行时机都不一定稳定,所以我们为了让动画更平滑,有时会选择比屏幕刷新率略高的频率。这样可能导致重复的,不必要的绘制,也会出现跳帧现象

image-20210121170533144

requestAnimationFrame

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<style>
.box {
width: 100px;
height: 100px;
background: yellow;
border: 5px solid cornflowerblue;
box-sizing: border-box;
position: relative;
}

</style>
</head>
<body>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<script>
setTimeout(() => {
function sleep(sleepTime) {
for(var start = new Date; new Date - start <= sleepTime;) {}
}
sleep(1000);
}, 2000);
setInterval(function calSum() {
let sum = 0;
for (let i = 0; i < 10000000; i++) {
sum += i;
}
}, 30)

let boxs = document.querySelectorAll(".box");
let left = 0;
let flag = 1;
// // requestAnimationFrame动画
requestAnimationFrame(function animation() {
if (flag) {
left += 1;
} else {
left -= 1;
}
if (left >= 200) {
flag = 0;
}
else if (left <= 0) {
flag = 1;
}
for (let i = 0; i < boxs.length; i++) {
let box = boxs[i]
box.style.left = left + "px";
}
requestAnimationFrame(animation);
})

</script>
</body>
</html>

image-20210121155221468

可以看出来,使用了requestAnimationFrame后,浏览器会根据屏幕刷新率,尽可能16.7ms绘制一次页面,这就是requestAnimationFrame的优势了,浏览器是不知道setTimeout会进行动画操作的,而requestAnimationFrame是专用于动画的API,浏览器就会针对性进行优化,让requestAnimationFrame尽可能按照页面刷新的频率调用

事实上,requestAnimationFrame有下面几个优势

  • RequestAnimationFrame不用在事件队列中排队,只要主线程空闲,且时间合适,就会执行
  • 它只绘制用户可见的动画,如果你隐藏了页面或者最小化了窗口,requestAnimationFrame就不会调用,非常节能
  • 当浏览器准备好绘制时(空闲时),requestAnimationFrame才会进行调用(事实上总是会在一帧的开头调用这个函数)。这意味着用 requestAnimationFrame 绘制动画产生出现多个排队的回调函数。
  • 当浏览器准备好时(空闲时)才绘制帧,也确保了没有多余的帧绘制,避免了浪费。

你可以通过下面的代码验证第一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 一些计算
for (let i = 0; i < 10; i++) {
setTimeout(function calSum() {
console.log(`计算执行${i}`);
let sum = 0;
for (let i = 0; i < 300000; i++) {
sum += i;
}
}, 0)
}
let count = 10;
requestAnimationFrame(function animation() {
console.log("动画");
count-- && requestAnimationFrame(animation);
})

image-20210121171418397

这就证明了,requestAnimationFrame不会被队列中的任务阻塞,只要队列中的任务不是消耗了非常漫长的时间,requestAnimationFrame就可以找到合适的时间去插队执行,从而保证动画的流畅性

CSS动画

CSS动画,相对于JS动画而言,有下面几个优点

  • 开发时编写代码较为简单,相比于JS的逐帧控制
  • 声明式的动画,浏览器更容易优化,而且浏览器也可以更精确的按时渲染
  • CSS动画可以避免JS的性能波动,也不用进行GC回收

我使用下面的代码进行了一下测试(CPU 4x slowdown)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.box {
width: 100px;
height: 100px;
background: yellow;
border: 5px solid cornflowerblue;
box-sizing: border-box;
position: relative;
/* 使用CSS动画 */
animation: boxFrames1 infinite 5s linear;
}
@keyframes boxFrames1 {
0%, 100% {
position: relative;
left: 0;
}
50% {
position: relative;
left: 200px;
}
}
</style>
</head>
<body>
<div class="box">123</div>
<script>
for (let i = 0; i < 1000; i++) {
setTimeout(function calSum() {
console.log(`计算执行${i}`);
let sum = 0;
for (let i = 0; i < 300000; i++) {
sum += i;
}
}, 0)
}

let boxs = document.querySelectorAll(".box");
let left = 0;
let flag = 1;
// 使用requestAnimationFrame做JS动画
requestAnimationFrame(function animation() {
if (flag) {
left += 1;
} else {
left -= 1;
}
if (left >= 200) {
flag = 0;
}
else if (left <= 0) {
flag = 1;
}
for (let i = 0; i < boxs.length; i++) {
let box = boxs[i]
box.style.left = left + "px";
}
requestAnimationFrame(animation);
})
</script>

</body>
</html>

CSS动画

image-20210121182816456

JS动画

image-20210121182725097

可以看到,虽然相差不大,但是CSS动画的帧率还是高于JS动画的

如果使用这段代码来测试(CPU 4x slowdown)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.box {
width: 100px;
height: 100px;
background: yellow;
border: 5px solid cornflowerblue;
box-sizing: border-box;
animation: boxFrames1 infinite 5s linear;
position: relative;
}
@keyframes boxFrames1 {
0%, 100% {
position: relative;
left: 0;
}
50% {
position: relative;
left: 200px;
}
}

</style>
</head>
<body>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<div class="box">123</div>
<script>
setInterval(function calSum() {
let sum = 0;
for (let i = 0; i < 500000; i++) {
sum += i;
}
}, 10)

let boxs = document.querySelectorAll(".box");
let left = 0;
let flag = 1;
// requestAnimationFrame动画
requestAnimationFrame(function animation() {
if (flag) {
left += 1;
} else {
left -= 1;
}
if (left >= 200) {
flag = 0;
}
else if (left <= 0) {
flag = 1;
}
for (let i = 0; i < boxs.length; i++) {
let box = boxs[i]
box.style.left = left + "px";
}
requestAnimationFrame(animation);
})

</script>
</body>
</html>

CSS动画

image-20210121183252031

image-20210121183305039

JS动画

image-20210121183319868

image-20210121183334387

可以看到,JS动画在重新计算样式的部分会比CSS动画消耗更多的时间,应该是浏览器对CSS动画做了一些优化导致的。

开启了硬件加速的CSS动画

前面几个动画,即使做了很多优化,但是在开销很大的JS面前仍然无能为力

1
2
3
4
5
6
setTimeout(() => {
function sleep(sleepTime) {
for(var start = new Date; new Date - start <= sleepTime;) {}
}
sleep(1000);
}, 2000);

比如下面的这段JS代码,就会让浏览器在2s-3s时被一个任务完全占用,在这期间,上面列举的方法,都不能让动画生效了

image-20210121195633824

但是,如果我们这时候对代码稍加修改,使用transform来进行动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.box {
width: 100px;
height: 100px;
background: yellow;
border: 5px solid cornflowerblue;
box-sizing: border-box;
animation: boxFrames infinite 5s linear;
position: relative;
}
@keyframes boxFrames {
0%, 100% {
transform: translateX(0px);
}
50% {
transform: translateX(200px);
}
}

</style>
</head>
<body>
<div class="box">123</div>
<script>
setTimeout(() => {
function sleep(sleepTime) {
for(var start = new Date; new Date - start <= sleepTime;) {}
}
sleep(1000);
}, 2000);
</script>
</body>
</html>

有趣的事情就发生了,即使JS一直在运行,CSS动画也还在继续运行

image-20210121200248941

这是为什么呢,因为transform这个属性,会触发硬件加速(提升到合成层就会开启硬件加速)。

先来说说为什么JS执行时动画还可以动吧,因为JS执行是在渲染进程里的main进程(main thread),而触发了硬件加速的元素动画,全程只在compositor线程(compositor thread)里完成,这也就意味着在执行合成操作时,是不会影响到主线程执行的。这就是为什么经常主线程卡住了,但是 CSS 动画依然能执行的原因。(两个线程都是在渲染进程里的)

而且,因为这里使用了GPU来直接进行纹理的合成,操作效率比CPU高很多,主要有下面几点

  1. GPU 上合成图层可以在涉及大量像素的绘图和合成操作中实现比 CPU(无论是在速度和功耗方面)还要好的效率。硬件专为这些类型的工作负载而设计。
  2. GPU 上的内容不需要昂贵的回读(例如加速视频 Canvas2D 或 WebGL )。
  3. CPU 和 GPU 之间的并行性,可以同时运行。

下面这些操作会把对应元素提升为合成层

  1. transforms
  2. video, canvas, iframe 等元素;
  3. opacity
  4. position: fixed;
  5. will-change;
  6. filter;

FAQ & Refer

CPU和GPU的区别

better-javascript-animations-with-requestanimationframe

chorme-develops-animation-and-performance

详谈层合成

浏览器层合成与页面渲染优化

操作不同CSS属性会触发的操作(回流 / 重绘 / 合成)

用CSS开启硬件加速提高网站性能

硬件加速的好与坏