前言
大家可能经常会听到 css 动画比 js动画性能更好这样的论断,或者是“硬件加速”,“层提升” 这样的字眼;要了解这些内容就需要对浏览器的渲染流程有个大致的了解,本文就是我个人对这些内容的一个总结梳理
需要注意的是:
- 本文仅个人学习总结梳理,如有错漏,望指正
- 本文以谷歌浏览器Blink内核为例,参考内容链接大多需要科学上网
- 随着谷歌浏览器的更新迭代,有些渲染流程或对象名词可能发生变化(如, RenderObject 变成了 LayoutObject,RenderLayer 变成了 PaintLayer),查看相关文档时需要注意文档的时间
渲染流程
先来看下blink的一个大致渲染流程,图源谷歌的一份共享幻灯片 Life of a Pixel ,它比较全面的阐述了浏览的渲染流程,非常值得一看,我们就借这张图来梳理一遍
图中分为 渲染进程(renderer process) 和 GPU进程(GPU process) 两部分,其中渲染进程包含 主线程(main) 和 合成线程(impl)
我们可以借助谷歌开发工具的 performance 标签查看是否执行了某些渲染流程步骤,我这里写了一个简单的html可以作为对比
transform demo
Compositor Layers
add css animation
The Stacking Context
add js animation
1. 构建DOM树
对应头图中 DOM
节点,由于浏览器本身无法直接理解和使用html,所以需要将html转换为浏览器能够理解的DOM树,也正是因此我们才能通过js控制dom节点
2. 样式计算
对应头图中 style
节点,不仅是html,浏览器同样无法直接读懂我们写的 css 。因此浏览器会将我们写的 css 转换成它能理解的 styleSheets
,同时计算每个 DOM 节点的样式结果。包括将处理样式的继承覆盖,将 rem 等相对单位转换成 px,将 margin: 8
这样的缩写,拆开解析成 margin-left: 8
,margin-top: 8
等具体的值。可以通过 computed
标签查看。
3. 布局计算
对应头图中 layout
节点,这个阶段也是我们很常听到的 回流(reflow),重排。在上两个阶段结束后会生成一个储存其计算结果的树结构 LayoutObject Tree
。在这个阶段浏览器会遍历 LayoutObject Tree
计算每个节点在页面上具体的布局(比如是正常流布局,或是flex布局,哪个元素该放到哪个具体的像素位置上),计算文本实际宽高等;这一阶段谷歌正在重构,目前输入和输出都混在 LayoutObject Tree
上,之后可能会将输出部分抽离出来
4. 分层阶段
对应头图中 comp.assign (compositing assignments)
节点,这个阶段是我们获取性能提升的关键。页面上的元素,根据所处坐标空间(基本可以理解为层叠上下文)不同等原因,会被划分为不同的 PaintLayer
,通过分层的方式保证页面上元素以正确的顺序层叠;在此基础上,某些特殊的PaintLayer
会被提升为合成层(Compositing Layers
),每个合成层拥有单独的 GraphicsLayer
, 而没有被提升的 PaintLayer
则与其祖先元素共用同一个 GraphicsLayer
.
它们间的对应关系如下图
每个 GraphicsLayer
都有一个 GraphicsContext
,GraphicsContext
负责输出该层的位图,即每层代表一份位图,GPU将位图合成渲染到屏幕上也就是我们看到的页面
我们可以通过开发者工具的 Layer 标签看到 GraphicsLayer
的分层,划分 PaintLayer
和 提升为 GraphicsLayer
的条件具体可见 无线性能优化:Composite (需要注意层重叠,层压缩问题)
比如我上面的例子中,我给橙色的 div 加上了 will-change:transform
导致了层提升,而蓝色的 div 与 document 共用一个 GraphicsLayer
;我们还可以在 Details
标签看到层提升的具体原因还有内存消耗 (tips: 层提升原因还可以看 safari 浏览器开发者工具的 layers ,会更加具体)
5. Pre-paint
这一阶段主要有两个任务,一是判断与上一次paint
阶段(见下)相比有哪些内容需要被更新,二是构建 property trees
Paint invalidation which invalidates display items which need to be painted.
Builds paint property trees.
property trees
的 property
是指 translation, scale
等需要大量计算的属性。将这些属性抽离出来单独管理,避免父元素的变动导致其子元素上所有的属性都有全部重新计算,具体见 How cc Works
6. paint
绘制阶段,这一阶段即我们常说的重绘阶段,但这一阶段并不是执行实际的页面绘制,而是依据页面内容的层叠顺序生成 绘制任务列表,详见 layer 工具,滚动滑轮可以重播绘制过程,可以观察到,同一层叠上下文情况下,先生成背景绘制任务,再生成元素内容绘制任务,再生成更高层级的层叠上下文元素的绘制任务;
主线程的任务到这里基本结束,将绘制列表提交(commit)到合成线程
7. tiling
tiling 分块,为 GPU光栅化做准备;光栅化是GPU根据绘制任务生成位图,并将位图储存在内存中。大家可能听过 CPU 光栅化的操作,这里引用一段 How cc Works 中文译文
Chromium 目前实际支持三种不同的光栅化和合成的组合方式:软件光栅化 + 软件合成,软件光栅化 + gpu 合成,gpu 光栅化 + gpu 合成。在移动平台上,大部分设备和移动版网页使用的都是 gpu 光栅化 + gpu 合成的渲染方式,理论上性能也最佳
由于这一操作需要消耗较多资源,为了减少资源消耗和使页面更快呈现会将图层进行分块( tiles ),将图块作为光栅化的基本单位,同时优先对视口附近的图块进行光栅化
通过rendering
标签,勾选 layer borders
可以看到分块情况,橙线是不同的 layer
而 青绿色的线则划分了图块
8. raster
这一步由GPU执行光栅化操作,之后的节点我没再深入了解,大概是光栅化生成draw quads
命令,该命令会引用光栅化结果最后将内容展现在屏幕上
总结
最后我们分别录制两个动画的执行流程
js 动画
可以看到 js 动画在每次执行时会重排重绘,执行整个流程,上面橙红色的那条前面有写到 Layout Shift
,即 布局提升,也就是我们说的强制重排,因为我们在 js 脚本里执行了 stacking.getBoundingClientRect().left
访问元素位置,这就需要立刻重排来计算元素当前的位置
css动画
可以看到,css动画主线程上没有进行重排重绘
梳理完整个流程,我们就能理解开头提到的内容了,关键点就在于分层合成
“层提升” 即文中的 分层阶段;
“硬件加速” 即 GPU加速,一些可能导致页面大范围重排重绘(如 translate动画),或需要大量简单计算的任务(如 filter动画)都会导致层提升,将这部分任务交由GPU处理,将处理完后的结果再合成到页面上;
而 css 动画性能更优的原因是:
- 避免了通过js访问元素的位置信息导致强制重排
- css动画元素移动时在合成层上进行,避免了页面重排
- 合成由 GPU 进程控制,即使 js 阻塞主线程,css动画也能正常执行
层提升会加大内存消耗,加大移动端设备负担,需要酌情使用
补充
will-change
上文我们的例子提到了 will-change
属性,它的作用是提前告知浏览器可能变动的属性,让浏览器提前做好准备,提前进行相关计算等,它有以下取值
-
auto
让浏览器自己猜哪些值会变动 -
scroll-position
表示滚动条位置可能发生变化或产生动画 -
contents
表示元素内容可能变动或产生动画 -
表示所有css属性
基本上哪里的css属性变化导致了页面的卡顿都可以使用 will-change
优化
我们的例子中已经写入了 will-change: transform
,因此浏览器一开始就帮我们做了层提升准备,所以橙色 div 一开始在页面上就是分层的情况。而如果我们去掉这个属性,观察 layer 会发现橙色 div 一开始在页面上并没有层提升,只有在执行动画时才进行了层提升,动画结束后层提升又消失了
使用该属性同样要注意的是内存消耗问题,因为浏览器会提前进行优化计算并储存计算结果。由于浏览器本身已经做了十足的性能优化,因此在页面没出现动画卡顿之前没有必要使用该属性,如果需要使用也尽量通过以下形式:
.will-change-parent:hover .will-change {
will-change: transform;
}
.will-change {
transition: transform 0.3s;
}
.will-change:hover {
transform: scale(1.5);
}
当父元素 hover 时,给子元素加上 will-change
,hover 失效则移出,既给了浏览器准备的时间,又避免了一直挂着该属性带来的资源消耗
requestAnimationFrame / requestIdleCallback
讲到动画我们就顺便提一嘴 requestAnimationFrame
和 requestIdleCallback
我们看到的动画都是由屏幕快速播放一系列连贯的图片组成,为了让人眼感受不到卡顿,大多数屏幕的刷新频率都是60Hz,即一秒钟刷新六十次屏幕,每次刷新叫做一帧,一帧时间大约16.7ms,如果一帧的渲染时间超过这个数就会导致动画看起来出现了卡顿,一帧流程大致如下图
requestAnimationFrame
会在每一帧的渲染流程执行前都执行一次,因此使用js实现动画时,相比于 setInterval
实际执行时间的不确定性requestAnimationFrame
更加可靠;
而 requestIdleCallback
则是在每一帧结束前判断是否有剩余时间,如果有则执行,无则不执行
参考链接
Life of a Pixel
chromium renderer/core/paint
无线性能优化:Composite
How cc Works / 中文
How Blink works / 中文
RenderingNG deep-dive: BlinkNG / 中文
The Anatomy of a Frame
《css新世界》