宏观视角下的浏览器:06 | 渲染流程(下):HTML、CSS和JavaScript,是如何变成页面的?
前言:该篇说明:请见 说明 —— 浏览器工作原理与实践 目录
上篇文章中,我们介绍了渲染流水线中的 DOM 生产、样式计算和布局三个阶段,那今天我们接着讲解渲染流水线后面的阶段。
这里还是先简单回顾下上节前三个阶段的主要内容:在 HTML 页面内容被提交给渲染引擎之后,渲染引擎首先将 HTML 解析为浏览器可以理解的 DOM;然后根据 CSS 样式表,计算出 DOM 树所有节点的样式;接着又计算每个元素的几何坐标位置,并将这些信息保存在布局树中。
分层
现在有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?
答案依然是否定的。
因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动、或者使用 z-index做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。
要想直观地理解什么是图层,你可以打开 Chrome 的 “开发者工具”,选择 “Layers” 标签,就可以可视化页面的分层情况,如下图所示:
渲染引擎给页面多图层示意图
从上图可以看出,渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面,你可以参考下图:
图层叠加的最终展示页面
现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。下面再来看看这些图层和布局树节点之间的关系,如图所示:
布局树和图层树关系示意图
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每个节点都会直接或者间接地从属于一个层。
那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
第一点,拥有图层上下文属性的元素会被提升为单独的一层。
页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 Z 轴上。你可以结合下图来直观感受下:
层叠上下文示意图
从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。
若你想要了解更多层叠上下文的知识,你可以参考这篇文章。
第二点,需要裁剪(clip)的地方也会被创建为图层。
不过首先你需要了解什么是裁剪,结合下面的 HTML 代码:
<style>
div {
width: 200;
height: 200;
overflow: auto;
background: gray;
}
</style>
<body>
<div>
<p>所以元素有了层叠上下文的的属性或者需要被裁剪,那么就会被提升成为单独一层,你可以参考……</p>
<p>从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
<p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p>
</div>
</body>
在这里我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了裁剪,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域,下图是运行时的执行结果:
裁剪执行结果
出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:
被裁减的内容会出现在单独一层
所以说,元素有了层叠上下文的属性或者需要被裁剪,满足这任意一点,就会被提升为单独一层。
图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来看看渲染引擎是怎么实现图层绘制的?
试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作?
通常,你会把你绘制操作分解为散步:
1. 绘制蓝色背景;
2. 在中间绘制一个红色的圆;
3. 再在圆上绘制绿色三角形。
渲染引擎实现图层的绘制与之类似,会把一个图层的绘制分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
绘制列表
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些绘制列表。
你也可以打开 “开发者工具” 的 “Layers” 标签,选择 “document” 层,来实际体验下绘制列表,如下图所示:
一个图层的绘制列表
在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现绘制的绘制过程。
栅格化(raster)操作
绘制列表只是用来记录绘制记录和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:
渲染经常中的合成线程和主线程
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)结合成线程,那么接下来合成线程是怎么工作的呢?
那么得先来看看什么是视口,你可以参考下图:
视口
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做 视口(viewport)。
在有些情况下,有的图层可能很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256 * 256 或者 512 * 512,如下图所示:
图层被划分为图块示意图
然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:
合成线程提交图块给栅格化线程池
通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。
相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:
GPU 栅格化
从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 内存中。
合成显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令—— “DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS 、 JS 等文件,经过浏览器就会显示出页面了。
渲染流水线大总结
现在已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:
完整的渲染流水线示意图
结合上图,一个完整的渲染流程大致可总结为如下:
1. 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
2. 渲染进程将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
3. 创建布局树,并计算元素的布局信息。
4. 对布局树进行分层,并生成分层树。
5.为每个图层生成绘制列表,并将其提交到合成线程。
6. 合成线程将图层分为图块,并在光栅化线程池中将图块转换成位图。
7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
相关概念
有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念—— “重排” “重绘” 和 “合成”。理解了这三个概念对于你后续 Web 的性能优化会有很大帮助。
1. 更新了元素的几何属性(重排)
先参考下图:
更新元素的几何属性
从上图可以看出,如果通过 JS 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重现布局,解析之后的一系列子阶段,这个过程就叫做重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。
2. 更新元素的绘制属性(重绘)
接下来,我们再来看看重绘,比如通过 JS 更改某些元素的背景颜色,渲染流水线会怎样调整呢?参考下图:
更新元素背景
从图中可以看出,如果修改元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫做重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
3. 直接合成阶段
那如果更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:
避开重排和重绘
在上图中,使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没与占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。
至于如何用这些概念去优化页面,会在后面相关章节做详细讲解,这里只需要结合 “渲染流水线” 弄明白这三个概念及原理就行。
总结
通过本文的分析,应该可以看到,Chrome 的渲染流水线还是相当复杂晦涩且难以理解,不过 Chrome 团队在不断添加新功能的同时,也在不断地重构一些子阶段,目的就是让整个渲染架构变得更加简单和高效,正所谓大道至简。
通过这么多年的生活和工作经验来看,无论是做架构设计、产品设计、还是具体到代码的实现,甚至处理生活中的一些事情,能够把复杂问题简单化的人都是具有大智慧的。所以,在工作或生活中,若想要简化遇到的问题,就要刻意地练习,练就抓住问题本质的能力,把那些复杂的问题简单化,从而最终真正解决问题。
思考时间
在优化 Web 性能的方法中,减少重绘、重排是一种很好的优化方式,那么结合文中的分析,你能总结出来为什么减少重绘、重排能优化 Web 性能呢?那又有那些具体的实践方法来减少重绘、重排呢?
减少重排重绘,方法很多:
1.使用 class 操作样式,而不是频繁操作 style
2.避免使用 table 布局
3. 批量 DOM操作,比如 createDocumentFragment,或者使用框架,例如 React
4. Debounce window resize 事件
5. 对 DOM 属性的读写要分离
6. will-change: transform 做优化