本文已参与「新人创作礼」活动,一起开启掘金创作之路。 在谈到浏览器渲染机制或是性能优化时,你可能经常听到“事件循环结束后渲染”、“尽量避免 重绘、回流”这样的字眼,但仍然对其原理一知半解,本文将从实用化角度详细讲述浏览器的渲染机制,并基于渲染机制说明如何进行动画性能的优化
渲染原理简述
渲染流水线
也许你在游戏中听过帧率的概念、在电影/动画中也听过帧率的概念,但其实在浏览器中也存在帧率的概念。
帧率(Frame rate)是以帧称为单位的位图图像连续出现在显示器上的频率(速率)(截取自百度百科)
简单来说,一帧就表示一幅画面,帧率就表示1s中呈现的画面数量,当多个画面连续快速(实质是离散)呈现时,就会欺骗人眼,造成动态画面的假象。
浏览器作为一个展示动态画面的窗口,其也是通过将一帧又一帧画面渲染上去实现的。通常情况下,浏览器渲染的帧率为60,但随着页面性能的降低或提升,浏览器会自动调整该页面的帧率。
具体的,每一帧的渲染是怎么进行的呢?简单来说,会经历如下几个步骤:
- JavaScript
从任务队列中取出一系列回调任务放到执行栈中执行,这个过程中可能有操作DOM的行为。
- Style:解析HTML、CSS
1. DOM树构建:通过HTML的解析器将HTML文档转化成一颗由DOM元素和属性节点构成的树。
2. 样式计算:将CSS代码转化成标准格式(转换单位、补全属性名),生成CSSOM,CSSOM位于`document.stylesheets`
中,便于通过JS获取样式信息
3. render树生成:联立DOM树和CSSOM,生成render树,render树由render object构成,但不是每个DOM节点都对应一
个render object。不可视的DOM元素不会变为render object,比如head元素,再比如display属性为none的元素也不会
变为render object(但为hidden的可以)
- Layout
刚生成的render树(或者说每个元素)是没有位置信息和大小信息的,计算这些值的过程称为layout或reflow。
- Paint
需要将render树的信息转换成像素后才能显示在屏幕中,paint所做的是中间一步,将这些信息转换成绘制指令。 转换过程中会生成层叠上下文,使得绘制指令以正确的顺序绘制。
- composite layers
层叠上下文会导致render layer的生成,在这一步,具有部分特殊属性的render layer会被提升为合成层
可以把合成层理解成photoshop中的图层
- 光栅化
光栅化在这里指的就是利用绘制指令生成位图,有多少个合成层则生成多少个位图
位图:内存中像素值的缓冲区
- 合成
将多个合成层进行合并,然后显示在屏幕中
渲染进程
在chrome中,每个tab页面有一个独立的渲染进程,每个渲染进程包括如下的线程:
- 主线程(Main Tread):即下图中的Main,上述渲染流水线的1-5步都由它来完成
- 合成线程(Compositor Thread):即下图中的compositor,负责调用GPU生成绘图指令(比如
transform:translate3D的实现需要GPU),然后将所有合成层进行合并并显示到屏幕上 - 光栅化线程(池):即下图中的Raster,它是一个线程池,可能包含1-4个或更多的光栅化线程,它负责将绘图指令转换成位图( 即上述渲染流水线的第6步)
下图为devtool中,火焰图里一帧渲染过程的截取。
上图中可以看到,还有一个GPU进程,它负责浏览器与GPU之间的通信。
关于渲染流水线的第4-7步,我们需要花更多的篇幅来详细说明。
Paint
绘制,其实就是填充像素生成位图的过程,但这一步不进行真正的位图生成,而是绘制指令的生成。通常,这个过程是在多个层(layer)上完成的。这里的层包含两种:render layer和compositing layer
render layer
当两个元素之间有重叠部分时如何绘制使页面呈现正确的样式呢?想象我们在一张透明纸上画画。每个待画对象的大小以及位置都已经列出,我们只需临摹在纸上即可,假如某个对象与另一个对象发生了重叠,我们就再拿一张同样的透明纸来单独在上面绘制这个可能发生重叠的对象,最后将所有透明纸按照正确顺序重叠在一起便能呈现出正确的画面。
浏览器的做法也是这样,把有可能发生重叠的元素单独放在一个render layer中来进行绘制,render layer 的存在就是为了以正确的顺序组合页面的元素之后来正确地显示重叠内容、半透明元素等。触发创建render layer或者说有可能使得元素发生重叠的 常用 CSS属性/HTML元素如下:
- 根元素(
html) postion且具有明确的z-indexopacitytransformoverflow(属性值不为visible)filtermask<canvas><video>
这些元素会导致一个render layer层的生成,那些没有render layer的元素则使用其父/祖先元素的render layer。
对于render layer,layout只会在对应的render layer上触发,但paint是在所有的render layer上触发
各个情况的层叠顺序如下图所示
图片来自<https://blog.csdn.net/wangfeijiu/article/details/106880978>
compositing layer
部分render layer会被提升为合成层(compositing layer), 为什么需要合成层呢?因为某些元素的绘制使用GPU完成会更快:
- 比如使用
transform: translateX(100px)将元素向右偏移100px,利用GPU的并行计算会更快获得偏移后的位图。 - 除此之外,利用GPU的能力,重新paint时可以只在该合成层上进行
导致提升为合成层的常见情况如下:
<video>元素- 3D
<Canvas>元素或者硬件加速的 2D<Canvas> - 3D
transform(即transform:translateZ()或者transform:translate3D()) - 有
opacity发生改变,或transform发生改变的动画(动画包括transition、animation) - 隐式合成层(在第二节中详细讲解)
光栅化
在说明光栅化之前,再详细说明一下合成线程(合成器) 。合成器的基本任务是:即使主线程繁忙,它依然能从主线程获取足够的信息以独立生成帧以响应未来用户的一些操作,比如滚动页面。
合成器能得到一个类似渲染树的旧的副本,一些输入事件(如滚动)首先从浏览器进程转发到合成器,然后从那里转发到主线程。因此合成器能够独立的处理滚动,进行页面的更新。然后当主线程的composite layers步骤结束之后,则和合成器互相交换信息。
除此以外,合成层的部分绘图指令是由合成器调动GPU来生成的,比如opacity、transform。
也就是说除了主线程的paint阶段生成绘图指令,合成器也会并行的进行一个由GPU完成的部分绘图指令生成
光栅化即将绘图指令将每个合成层转化为实际的位图。每个合成层实际描述的是整个页面,但实际光栅化是以块为单位进行的(即将一个图层分割成多个块,通常从上往下分割为固定高度的矩形块)且只有在首次渲染时才会对整个页面进行光栅化,当页面内容有更新时才会对部分块重新进行光栅化。
如下图所示,在首次渲染光栅化之后,滚动页面并没有触发任何光栅化线程的运行,而当页面中的内容变化(粉色块的向右位移)时,才重新触发光栅化。
光栅化可以分为软件光栅化(Software Rasterization)和硬件光栅化(Hardware Rasterization):
- 软件光栅化:在 CPU 中生成位图,完成之后再上传到GPU 合成(也正是由于这个资源上传过程,使用多个光栅化线程来加速)
- 硬件光栅化:直接在 GPU 中进行位图生成与合成
合成
光栅化完成之后,由合成器线程调动GPU将多个合成层合并,然后显示在屏幕上。
具体的合成层可以再devtool的layer面板中查看,如下图所示,包含两个合成层:
#document是每个页面默认的一个合成层,由根标签生成;#984由右侧滚动条生成(没有滚动条则没有)。右侧details栏还能看到每个层的大小、提升为合成层的原因以及在内存中占用的空间等
动画性能优化
减少JS执行时间
渲染流水线中的1-5步都是由主线程执行,而第一步便是JavaScript的执行。
JS的执行有一个所谓的事件循环机制,即每轮事件循环先从宏任务队列中取出一个回调函数执行,然后依次取出微任务队列中的所有回调函数执行,最后进行渲染。
当然,其中还包括`requestAnimationFrame`(每轮执行)和`requestIdleCallback`(空闲时执行)回调的执行
假设我们要保证60fps的帧率,则每轮事件循环的时间消耗至多为16.7ms,因此我们应该尽量减小JS的执行时间,具体的:
- 减少宏任务与微任务的执行时间,避免再一次事件循环中新增大量微任务(因为微任务队列需要被清空)
- 对于与DOM相关的长时任务,可以分解成多个任务放入
requestAnimationFrame中执行 - 对于与DOM无关的长时任务,可以放在Web Work(一个独立于主线程的JS执行引擎,但无法操作DOM)中执行
减少重排、重绘、强制重排
重绘和回流是渲染步骤中的一小节,但是这两个步骤对于性能影响很大。
- 重绘:页面中部分元素的样式需要改变,且这些改变不影响该节点在页面中的位置和大小,即不会影响布局的。比如改变 字体颜色、背景颜色的改变就叫称为重绘,也就是对应渲染流水线中指出的Paint
- 回流(重排):页面中部分元素的样式需要改变,且这些改变是布局或者几何属性需要改变就称为回流,需要重新生成渲染树。譬如JS为某个p标签节点添加新的样式:"display:none;"。导致该p标签被隐藏起来,该p标签之后的所有节点位置都会发生改变。此时浏览器需要重新生成渲染树,重新布局,即回流。也就是对应渲染流水线中指出的Layout
如果更改了“重绘”属性,例如背景图像、文本颜色或阴影,换言之,不影响页面布局的属性,则浏览器会跳过布局即回流,但仍会绘制。
如果更改了一个既不需要布局也不需要绘制的属性,则浏览器跳转到只是进行合成。
所谓减少重排、重绘指的就是我们可以通过一些手段既避免重排、重绘,又实现目标效果。比如独立为一个层叠上下文,可以避免大面积的重排;提升为一个合成层,还可以避免大面积的重绘
强制重排, 指的是在JavaScript代码执行过程中,如果我们先是修改了DOM相关的信息(即改变了渲染树),然后在后面又访问了盒模型或者与布局相关的信息时,就会导致强制重排。即浏览器为了保证获取信息的正确,会阻塞当前代码的执行,提前进行重排。
注意前提是有修改DOM信息,否则不会触发强制重排
导致强制重排发生的因素有:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight、getComputedStyle方法。
因此,在JS代码编写中,应该注意尽量避免这种强制重排的发生。
使用GPU加速
使用GPU加速的意思就是在合成层实现动画。那么为什么要在合成层实现动画呢?
- transform和opacity属性的变化是由GPU来完成,不触发渲染流水线的任意流程(布局、绘制等)
- 当合成层需要paint时,只需要paint该层本身,不会影响到其他的合成层
合成层在合并时可以由GPU完成,因此多个层的合并对性能影响不大
下面介绍具体使用的一些手段与注意事项
使用transform和opacity
对于大部分动画来说,无非使用放缩、空间变换、旋转、颜色变换等这几种手段实现。而这些操作都可以由transform和opacity属性来完成,我们应该避免使用position这类会导致layout与paint的属性。
- 空间变换
空间变化使用transform的translateX、translateY、translateZ属性值来实现
- 放缩
放缩可以使用transform的scaleX、scaleY、scaleZ属性值来实现
- 旋转
旋转可以使用transform的rotateX、rotateY、rotateZ属性值来实现
- 颜色变换
颜色变换应该避免直接使用background-color来实现动画,因为它会触发渲染流水线。
我们可以借助opacity属性来实现颜色的变换,具体如下所示,将两个盒子进行叠放,设置不同的背景色,通过变换最上层盒子的opacity值便可以实现颜色的变换
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.father {
width: 200px;
height: 200px;
background-color: skyblue;
}
.son {
width: 200px;
height: 200px;
background-color: pink;
animation: color 2s both infinite alternate;
}
@keyframes color {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
</head>
<body>
<div class="father">
<div class="son"></div>
</div>
</body>
</html>
避免隐式合成层
任何一非合成层DOM元素,在一个很明确的合成DOM元素(比如由positio:fixed、video、css动画)之上时都会被强制提升为一个单独的合成层,只为了它在GPU中最后被绘制。这些被强制提升的合成层称为隐式合成层。
下面举个例子说明上述问题。下例为一个将A盒子向右平移的动画(使用transform实现),当A盒子与B盒子重叠时,我们通过z-index来设置哪个盒子在最上方,这里给B盒子设置z-inde:100,因此A盒子会被B盒子覆盖。
但A被提升到了合成层,合成层的合并是在GPU中完成的,如果正常情况下,B盒子隶属于根元素这一个默认的合成层,GPU在进行合成时B元素就会被A元素给覆盖。
当然,如果在通过一个合成层中,这种堆叠关系在paint阶段生成绘制指令时就能处理了。
因此为了实现正确的这种覆盖关系,B元素被迫提升为一个新的合成层。一句话,当元素发生重叠,且非合成层元素z-index比合成层元素大时,该元素就会被强制提升为一个单独的合成层
我们应该注意到这种隐式合成层,它有时会极大的影响性能,因为合成层除了我们所说的各种优势以外也有许多缺陷:
- 内存消耗。每一个合成层都会消耗额外的内存
- 时间消耗。每一次提升合成层时都会重新进行paint,且图层信息传输到GPU时也需要时间
另外一点原因在于使用GPU的性能不一定就比CPU好,《Webkit 技术内幕》中指出:
对于常见的 2D 绘图操作,使用 GPU 来绘图不一定比使用 CPU 绘图在性能上有优势,例如绘制文字、点、线等,
原因是 CPU 的使用缓存机制有效减少了重复绘制的开销而不需要 GPU 并行性。
减少合成层大小
前面说过合成层会占用额外的内存,且传输到GPU时也有时间消耗,因此应当尽量减小合成层大小。这里提供一种方式:使用transform: scale
假设我们需要一个200 X 200的盒子,我们可以定义20 X 20大小的盒子,通过transform: scale(10)放大成200 X 200的盒子,以此减小合成层占用的内存。
下图可以看到,直接定义的方式占用了491KB,而使用放缩的方式,只占用了4.9KB
使用will-change
will-change属性可以强制把一个元素提升到合成层中。它的取值通常为transform、opacity、left、top、right、bottom(对于设置来了postion的元素)。
比如will-change: transform则是告诉浏览器等会该元素可能会发生长时间的transform属性上的变化,提前把该元素提升到合成层。
注意:除了translate3d/translateZ和will-change以外,没有其他属性可以直接把元素提升为合成层,而transform和opacity只有在动画过程( transition 、 animation引起)中才会被提升到合成层,动画结束则合成层被移除。
基于以上描述,主要把will-change的用途总结如下:
- 对于频繁触发的动画,我们可以使用
will-change,让浏览器一直保持着该合成层的资源,避免频繁地提升与移除导致的性能消耗 - 对于不频繁触发的动画,可以通过某些手段(比如移入了某个大盒子)监测,提前写入
will-change属性,让浏览器提前准备好资源,避免动画真正被触发时,由于合成层提升所造成的动画卡顿。当然,当动画结束时,应该移除will-change属性,避免合成层占用过多资源 - 对于
postion的元素使用will-change,第一可以使用GPU生成位图,第二可以避免所有render layer的重新paint,但使用合成层也有相应的代价,因此应该按具体情况分析
\
\
\
\
参考链接: