渲染机制
浏览器渲染机制是一个前端的必备知识。
总体来说,现代浏览器可以分为 7 个抽象部分
- User Interface:用户界面
- Browser Engine: 浏览器引擎
- Rendering Engine: 渲染引擎
- Networking Component: 网络组件
- JavaScript Engine: JS 引擎
- UI Backend:显示后端
- Data Storage:数据存储层(或称 Data Persistence,数据持久层)
顾名思义,渲染引擎用于处理浏览器页面的渲染。下面是一张浏览器渲染引擎处理流程图。
# 渲染流程
黑盒中的四个步骤对应的是 流程图 中 Processing 的部分。当网络进程处理好数据后,通知浏览器线程,浏览器线程调度渲染进程执行渲染。
大体流程分别为:
- HTML Parser 将 HTML 对象化,解析为 DOM 树,将 CSS 解析为 CSSOM Tree
- 建造 Render Tree,将 DOM 树中的各个节点附着上 CSSOM 树中对应节点的 style 信息(DOM Tree + CSSOM Tree = Render Tree)
- 根据 Render Tree 生成显示器中的具体布局信息描述(Layout Tree),比如确切的位置坐标
- UI Backend(显示后端)将信息传递至 GPU,后续 GPU 参与图像绘制,显示到屏幕中
# HTML 解析
解析 DOM 的过程中,预加载扫描器会同时运行,当发现有诸如图片、CSS、JS 等子资源时,通知网络线程进行加载。当 HTML Parser 找到 <script>
标签后,若标签没有 defer 或 async 属性(即非异步脚本)会暂停执行 HTML 的解析,且必须加载、解析并执行 JS 代码后,再继续解析后续标签,以防止有代码直接改变整个 DOM 结构(如 document.write()
)而费时做不必要的解析。
总结
CSS 的加载不会阻塞 DOM 树的解析(DOM 树不依赖 CSS),但非异步 JS 文件的加载会阻塞 HTML 的解析(JS 可能改变 DOM 结构),所以一般 JS 文件放在 body 最下方。
defer / async
<script defer>
当 DOM 树被解析完成后,脚本再执行,且执行时机一定在DOMContentLoaded
之前完成<script async>
与 defer 一样,脚本不会阻塞 DOM 树的解析,但当脚本内容被加载完成后会立即执行
# 样式计算
渲染主进程解析 CSS 生成 CSSOM Tree,附着于 DOM 树中,形成 Render Tree,但此时只有各个元素的样式信息,并未精确到布局(位置),这时候需要继续计算。
# 生成布局树
渲染主进程遍历 Render Tree,根据 Render Tree 生成布局树 Layout Tree,Layout Tree 中包含诸如 x y 坐标元素边框大小之类的信息,结构与 DOM 树类似,但仅包含页面上可见内容有关的信息。
注意
具有 display: none
属性的元素不属于布局树,但是具有 visibility: hidden
属性或类似内容伪类(::before { content: 'Hi!' }
)的元素都包含在布局树中。
# 绘制
生成布局树后仍然不足以渲染一个页面。 比如绘制一幅画的时候,绘制的顺序决定了成画的上下层级,先绘制层级高的,后面会被覆盖。
DOM 也是这样的。
所以,绘制不能以 DOM 顺序来执行。此时需要遍历 Layout Tree 生成一系列的 Paint Records(绘制记录),你可以认为 Paint Records 是一个单向链表,遍历链表可获得正确的绘制顺序。
当布局树发生某些变更,Paint Records 需要同步响应,生成新的绘制顺序以保证绘制过程不会出错。
# 分层
浏览器渲染主进程需要遍历 Layout Tree 生成 Layer Tree。一般情况下,Layer 中的元素相对独立,那么更新该 Layer 的速度会更快,你可以通过增加一个 will-change
的 CSS 属性来告诉浏览器这个元素需要独立地分层,但相应地,Layer 数量增多可能会降低合成的性能,所以页面 Layer 的数量需要做好管理,才能让页面的渲染性能有提升。
我们可以打开 Chrome 的 Layers 看到页面中的所有 Layer,
Details
中包含当前 Layer 的 Size 及 Compositing Reasons。Compositing Reasons 会列举产生 Layer 的原因,包括但不限于:
will-change
CSS 属性- 3D 变换(
translateZ
、translate3d
) 属性 - 使用了硬件加速的
<video>
<canvas>
标签 fixed
或sticky
定位- 有可滚动的内容
- 需要渲染在一个 Composited Layer 之上的元素
点击 Paint Profiler
查看前文提到的 Paint Records
。
# 合成
当走到合成这一步时,浏览器已经知道了文档的结构
、各个元素的样式
、页面的坐标
及 绘制顺序
了,接下来需要将以上所有信息转换为屏幕上能够显示的像素点信息,称为 Rasterizing
(光栅化)。
旧式浏览器常用的手段是:在可视区域内将元素光栅化,随着用户滚动页面,不断调整光栅化的区域,继续光栅化并讲内容填充到缺失的部分。见下:
现代浏览器使用更高效的手段,这种手段称为 Compositing
(合成)。见下:
合成时,浏览器将页面的各个部分分成若干个 Layer
(图层),对各个 Layer 单独进行光栅化,随后在 Compositor
(
合成器) 线程中对各个 Layer 进行合成,可能由多个 Render Layer 合成为 1 个 Composited Layer,也可能一个 Render Layer 独立成为 Composited Layer 。因为已经提前对各个 Layer 进行了光栅化,如果页面发生了滚动,只需要再次对可视区域进行合成(每次合成的结果称为 Frame
,即 帧
),而无需像老式浏览器一样再次光栅化。
上图展示的过程:合成器线程将图层划分块后发送给光栅化线程,光栅化线程对每个图块作光栅处理后,将图块信息存储到 GPU 的内存中,随后 GPU 进行图像的浮点数运。
合成器线程可以让光栅化线程优先处理视野区域(或附近)的图块,因为线程与线程之间是独立的,这样能保证视野区的图像最先被 GPU 处理完毕,不必等待所有图层,甚至当前图层的其余图块。
光栅化后的各个小块称为 tile
,合成器线程收集连续的一系列 tile 作为一个集合,并整合它们的信息,形成一个又一个的 Draw Quad
(绘制四边形),绘制四边形包含图块在内存中的位置等等信息。页面所有 Draw Quad 的集合被称为 Compositor Frame (合成帧)。生成合成帧后渲染进程遍将帧信息提交到浏览器进程。
与此同时,其他渲染进程或者浏览器进程都有可能源源不断地生成合成帧,这些合成帧都会被发送到 GPU 中。
合成的时候不需要渲染(主)进程的参与,所以更推荐使用不会导致浏览器重绘或重排的动画,这样就不需要重新计算 Render Tree 和 Layout Tree,也就不需要渲染(主)进程的参与。
# 重排与重绘
前面已经讲过渲染的步骤,显然,绘制是在布局之后的。如果页面发生重排,必然发生重绘,但发生重绘不一定会重排。 当页面的尺寸导致页面布局、元素的几何位置发生变化,就会触发重排。
注意
读元素的属性值同样会触发重排。引擎需要放弃原本可能会做的优化,马上执行计算并返回准确结果,所以读属性会导致相对较高的渲染性能开销,一般连续读取属性的代码建议写在一起。
可以打开 codepen (opens new window) 这个例子,将代码 copy 到本地进行实验。
我们先将代码中的 @keyframe 中所有 CSS 还原为 top / left,注释掉 transform
。
随后将 Paint flashing
和 Layout Shift Regions
打开,可以看到有两个色块跟随着小球一直移动。对这段动画进行录制,一段时间后暂停,打开 Event Log
可以看到浏览器一直不断地 Schedules Style Recalculation -> Recalculate Style -> Layout -> Update Layer Tree -> Paint -> Composite Layers
,说明一直在触发重排与重绘。
如果使用 translate
代替 CSS 中的 top/ left,惊喜地发现,重排消失了,GPU 的负载也更低:
前面 分层 也提到过,使用 translate3d
对绘制会有性能的提升,此处同样适用。