背景
关于浏览器的渲染过程,网上的文章很多,比较出名的是《Life Of A Pixel》这个视频讲解,这里记录下我学习极客时间《浏览器工作原理》专栏中关于这块的笔记。
我们知道一个现代web页面是由HTML、CSS、JavaScript等文件构成的,那浏览器是如何将这些文件转换为可视化的页面的呢?

渲染过程
上面的示意图给出基本的输入和输出解释,输入是HTML、CSS、JavaScript,输出是web页面,中间的rendering渲染过程就是今天要讲述的内容。
构建DOM树
为什么要构建DOM树呢?首先浏览器是不认识HTML文档的,就像计算机不了解高级语言一样,中间需要一个翻译过程,我们从服务端请求了HTML文档,浏览器最开始接收到的是bytes字节码,然后浏览器将这些字节解析转换为字符,然后根据W3C文档将字符识别为Token,有了Token,就可以由词法分析生成HTML节点,这些节点带有属性和规则信息,最后将这些节点连接起来就构成了DOM树。

样式计算
有了上面的DOM树结构,我们的web页面就有了"骨架",但是还没有"皮肤"和“肌肉”,这些从哪来呢?这些来自于CSS。假设一个我们的CSS内容如下:
<!DOCTYPE html>
<html>
<head>
<!-- 1.通过link引用的css文件 -->
<link href="a.css" rel="stylesheet">
<!-- 2。通过<style>标记引入的css -->
<style>
body {
background: blue;
}
div {
width: 300px;
height: 300px;
}
</style>
</head>
<body>
<!-- 3。通过元素样式引入的css -->
<p><span style="display: block;"></span></p>
</body>
</html>
代码中记录了三种引入CSS的方式。
CSS文本--> styleSheets
浏览器需要解析上面的上面的CSS文本,需要先将CSS文本转换为浏览器可以理解的结构styleSheets,打开Chrome控制台,输入document.styleSheets,可以看到如下结构:

可以看到浏览器已经把所有的CSS文本转换为styleSheets结构中的数据,这些结构也同时具备查询和修改的功能,这为后面的样式操作提供了基础.
标准化样式表中的属性值
我们平时写CSS会有很多方面书写记忆的写法,比如下面的CSS文本:
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
可以看到上面写了很多属性值,但这些类型却不被渲染引擎理解,因此需要将其转换标准化的计算值,你可以理解这个过程是渲染引擎要统一单位和度量衡,转换后结果为:
body { font-size: 32px }
p {color:rgb(0, 0, 255);}
span {display: none}
div {font-weight: 700}
div p {color:rgb(0, 128, 0);}
div {color:rgb(255, 0, 0);}
计算DOM树中每个节点的具体样式
要计算每个节点的样式,就要用到CSS的继承规则和层叠规则了。CSS的继承是每个DOM节点都包含其父节点的样式,以图说明:

从图上可以看到各个节点都有其来自父节点的样式。我们通过Chrome开发者工具Styles也可以看出这样的模式:

然后就是层叠规则发挥作用了,CSS全名叫Cascading Style Sheets(层叠样式表),说明层叠是其主要功能,说白了就是后者覆盖前者。
依靠上面两种规则,最终结算的结果保存在ComputedStyle的结构内,通过Chrome开发者工具中Computed标签可以查看,以上这个过程有的文章也把其理解为生成CSSOM的过程。
构建布局树
知道了DOM树,知道了每个节点的样式,还缺什么呢?还缺DOM节点的具体位置,这个过程就是布局
创建布局树
DOM树和布局树是相似的,主要区别在于DOM树上包含全部节点,包括不可见节点,但是布局树上只有可见节点:

计算布局
布局计算是一个复杂的过程,后续再写相应的文章介绍。
构建分层树
有了DOM树,有了节点样式,又知道了各个节点的具体位置,这回应该可以让浏览器绘制了吧?!别急,还早,这里还有个关键步骤----分层
现代web页面已经不是简单的纯文本和图片了,上面有复杂的交互动画如3D变换、页面滚动、或者z-index做排序等,为了实现这些效果,渲染引擎还需要为特定的节点生成对应的图层,最终形成一棵分层树,其实图层的概念在常见绘图软件如PS中已经屡见不鲜了,通过Chrome开发者工具的Layers标签可以查看web页面的图层,之前做iOS开发时经常使用图层调试,原来Chrome也有类似功能,赞!

现在我们知道了,web页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。那么图层与布局节点又有什么关系呢?

从上图可以看到,并不是每个节点都需要一个图层,如果一个节点没有图层,那么它就从属于其父节点的图层。如上面span节点和文本节点就没有专属图层。
那么什么样的节点才配有专属图层呢?
- 拥有层叠上下文属性的元素会被提升为单独的一层
这里可以直接查看MDN
- 需要裁剪的地方也会被创建为图层
前端初学者肯定写过这样的代码,假定有这样的HTML结构:
<!DOCTYPE html>
<html>
<head>
<style>
body {
background: blue;
}
div {
width: 100px;
height: 100px;
overflow: auto;
background: lightgoldenrodyellow;
}
</style>
</head>
<body>
<div>
<p>testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestt</p>
<p>testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestt</p>
<p>testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestt</p>
</div>
</body>
</html>
div的宽度、高度不足以包括其全部内容,因此文字会超出,这种情况下就产生了裁剪,渲染引擎会把裁剪文字内容的一部分放在div区域,展示效果如下:

从图层工具栏中可以看到渲染引擎将文字部分单独创建了一个图层,如果出现滚动条,滚动条也会是一个单独的层

上述试验结果是在单独使用Chrome浏览器打开这样的html文档下的效果,如果是在OSX的分屏模式下没有起作用,具体原因有待后续研究。
绘制图层
绘制过程和我们日常画画的操作基本相似,先画什么再画什么,比较符合人类直觉,首先渲染引擎将图层的绘制拆分为许多绘制指令,然后将绘制指令组成绘制列表,然后逐步绘制,值得注意的是上图中的Profiler标签工具栏,可以
拖动查看绘制过程。

删格化操作
上面的绘制列表只是用来记录绘制顺序和绘制指令,实际的绘制操作是由渲染引擎中的合成线程来完成的,下图展示了渲染主线程和合成线程的关系:

从上图可以看出,渲染线程完成了绘制列表操作后,就将执行过程交由合成线程来完成了。实际合成过程中,还涉及到视口viewport的概念,有些情况下页面图层很大,比如有的页面滚动条要滚动好久才能到底部,但是用户只能看到很小一部分,这种情况下,就没有必要绘制出所有图层,以造成不必要的开销。合成线程的策略是 首先将图层划分为图块,通常大小为256 * 256,或者512 * 512,然后合成线程会优先绘制视口附近区域的图块,并将这些图块合并生成位图,实际生成位图的操作是由删格化完成的,删格化的过程就是将图块转换为位图。这个过程中渲染进程会维护一个线程池,专门用于执行图块的删格化过程,通常删格化的过程会使用GPU加速,使用GPU生成位图叫快速删格化,生成的位图被保存在GPU中,当然GPU操作肯定是在GPU中完成,这样又涉及到了跨进程通信的问题,具体形式可参考下图:

合成和显示
当所有的图块都光栅化完成,合成线程会生成一个绘制图块的命令”DrawQuad“,然后将该命令提交给渲染进程,渲染进程里面有个叫viz的组件,用于接收DrawQuad的命令,将页面内容绘制到内存中,最后将内存显示到屏幕上,到这里我们的HTML、CSS、Javascript等文件,经过浏览器就会显示出最终的web页面了
渲染流水线
综上整个渲染流程如下:
- 浏览器解析HTML形成DOM树
- 浏览器解析CSS形成styleSheets,计算出DOM节点的样式
- 合并DOM树和各节点样式形成布局树
- 对布局树进行分层,创建分层树
- 根据每个图层生成绘制列表,并将其提交给合成线程
- 合成线程将图层划分为图块,将图块光栅化处理形成位图
- 合成线程返回DrawQuad消息给渲染进程,渲染进程根据DrawQuad消息生成web页面

几个概念?
有了前面的浏览器渲染过程的基础,下面介绍几个日后用于优化浏览器性能优化的概念:重排、重绘、合成
重排
当页面元素的几何属性被更新就产生了重排。我们可以通过Javascript或者CSS更新元素的宽度、高度等几何属性,该操作后,浏览器会重新计算各个节点的样式,也就是渲染流水线中第二步会触发,那么后续流程也都会重新来一遍,所以重排几乎需要更新完整的流水线,所以开销也是最大的。
重绘
只是更新元素的背景色等操作叫重绘,因为元素的几何属性没有被更新,所以渲染流程不需要重新生成布局树,也不用生成分层树,直接进入了绘制图层阶段,相对重排来说执行效率会高一些。
合成
如果我们既没有改变元素的几何属性,也没有重新绘制属性,比如只是transform了一个元素,这种情况下,布局和绘制过程就都省略了,只执行了后续的合成操作,并且因为合成操作在合成线程中完成,并不占用主线程的资源,因此执行效率会大大提升。
相关面试题
为什么CSS要放到header底部,Javascript要放到body底部?
这道题涉及到了渲染过程中构建DOM树和计算节点样式两个过程
- 假设CSS没有放在header底部,那么可以放在header顶部,或者body标签中,其实对于内容不多HTML文档,不涉及异步获取CSS时,放在什么地方其实影响都不算大,但是如果我们的css是从外部获取的,从前面的渲染过程我们知道在生成布局树之前,浏览器需要先加载CSS用于计算各节点样式,放在header底部可以确保页面需要的CSS都优先解析计算完成,这样页面加载时候就不会先出现默认的html样式,然后再出现想要的CSS渲染样式,优化体验;
- Javascript标签为什么建议放到body底部?因为JS是非常强大的,JS可以操作DOM、可以修改样式、甚至处理页面节点上的交互事件等等,因此浏览器在解析
<script>标签时,会阻塞DOM树的构建,转而去执行js,而DOM树的构建是整个渲染过程的最初阶段,进而渲染阶段被阻塞,如果将其放到<body>标签末尾,此时DOM树构建完成,再去执行js就不会影响渲染过程。
参考
- 极客时间《浏览器工作原理与实践》
- 极客时间 每日一课 《为什么CSS要放到header底部,Javascript要放到body底部?》