“从输入 URL 到页面展示” 是一个极其复杂的过程。
要完整地解释这一过程,你需要了解浏览器的多进程架构,网际协议(Internet Protocol,IP),数据包协议(User Datagram Protocol,UDP),传输控制协议(Transmission Control Protocol,TCP),超文本传输协议(HyperText Transfer Protocol,HTTP),域名系统(Domain Name System,DNS),HTTP请求流程,导航流程,渲染流程等一系列网络协议、原理和流程。
知识体系的建立是一个循序渐进的过程,没有捷径,更无法一蹴而就,接下来的专栏文章里我将带你完整地探索这一过程。当然,我不打算从数据包和协议开始,因为那太枯燥了,相反,渲染流程是一个很不错的开端。
思考一下,我们编写的 HTML,CSS,JavaScript 文件,是如何转化为下面的页面的?
我们知道,HTML 的内容是由标记和文本组成,标记也称为标签,它是 Web 语义化的基础;CSS 叫做层叠样式表,是由选择器和属性组成,通过它我们可以对 HTML 进行布局,设置样式;而 JavaScript 是一门脚本语言,我们可以用它创建动态更新的内容,控制多媒体,动画等等,
想要将上述的文件最终呈现为页面,浏览器需要经历一系列复杂的处理流程,如果把整个流程看作一个渲染流水线,按照渲染的时间顺序,流水线包括:构建 DOM 树,样式计算,布局,分层,图层绘制,光栅化,合成,
接下来让我们逐一来看。
DOM Tree Building
渲染流程的第一阶段是 DOM Tree 的构建,先来回顾一下,什么是 DOM ?
从网络进程传递给渲染引擎的 HTML 文件字节流是无法直接被引擎理解的,我们需要一个描述 HTML 文档的数据结构,这个结构就是 DOM;当然,从脚本的角度,DOM 也是提供给 JavaScript 脚本操作的一套文档接口。我们以下面的一段 HTML 为例,
<html>
<body>
<div>Monch</div>
<div>Lee</div>
</body>
</html>
DOM Tree 的构建流程为,
HTML Parser
执行字节流转换为 DOM 这一流程的是渲染引擎内部的 HTML 解析器( HTMLParser )。
解析器接收数据的流程为:网络进程接收到响应头后,根据响应头中的 Content-Type
判断文件类型,如果是 text/html
,浏览器会创建一个渲染进程,然后创建一个网络进程和渲染进程之间共享数据的管道,网络进程接收到数据后就会往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传递给 HTML 解析器。
所以HTML 解析器并不是等整个文档加载完成后才开始解析的,而是网络进程加载多少数据,解析器便解析多少数据。
网络进程传递过来的数据是字节流,字节流首先会转换为 Token,这一步由分词器完成。
HTML 解析器维护了一个 Token 栈,用来计算节点间的父子关系,
字节流会被转换为 Tag Token 和 Text Token,其中 Tag Token 又分为 StartTag 和 EndTag,分别代表开始标签和结束标签。
解析的流程为:开始时,HTML 解析器会默认创建一个根为 document 的 DOM 结构并初始化 Token 栈,将第一个 StartTag document 的 Token 压入栈底,然后分词器开始解析;分词器会把解析出来的 Token 依次入栈,每压入一个 Token,渲染引擎都会为其创建一个 DOM 节点,如果是文本 Token,会创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,
如果解析出来的是 EndTag,HTML 解析器会去判断当前栈顶的元素是否是 对应的 StartTag,如果是则从栈顶弹出 StartTag,直到最后所有的 StartTag 全部弹出,此时 Token 栈为空,解析完成,
实际的生产环境中,解析器接受到的 HTML 文件可能还包括脚本标签,
接下来我们介绍一下 JavaScript 阻塞是如何发生的。
JavaScript Pending
我们对上面的示例稍做修改,引入一个外部样式文件,然后插入一个 script 标签,
<html>
<head>
<style src="https://xxx/foo.css"></style>
</head>
<body>
<div>Monch</div>
<script>document.getElementsByTagName('div')[0].innerText = 'Steven'</script>
<div>Lee</div>
</body>
</html>
暂停思考一下,此时的解析流程是什么样的呢?🤔
解析到 script 标签时,由于无法判断脚本是否会修改当前已经生成的 DOM 结构,此时 HTML 解析器会暂停 DOM 的解析,接着 JavaScript 引擎会介入执行 script 标签内的脚本;如果这里是外链的脚本,在执行前就需要先下载脚本。引擎在执行 JavaScript 脚本前,类似的,由于不清楚 JavaScript 是否操作了 CSSOM,所以会先执行 CSS 文件的下载和解析,再执行 JavaScript 脚本,而 HTML 解析器会一直等到脚本执行完成后再继续工作,所以 JavaScript 会阻塞 DOM Tree 的构建,
现代浏览器在这里做了很多优化,比如 Chrome 支持预解析:当渲染引擎收到字节流后,会同时开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件并提前下载这些文件。
我们也可以通过一些策略来规避或优化脚本对 DOM Tree 构建的阻塞,比如,
- 使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积
- 将 script 标签后置,比如放到 body 的底部
- 如果脚本没有操作 DOM,通过 async 或 defer 标记将脚本设置为异步加载
需要注意的是,async 和 defer 虽然都是异步的,但在执行时机上存在差别:使用 async 标记的脚本会在下载完成后立即执行,而 defer 标记的会在 DOMContentLoaded 事件前执行。
接下来我们介绍渲染流程的第二阶段,样式计算(Recalculate Style)。
Recalculate Style
样式计算的流程可以分为三个阶段:构建 StyleSheets,属性值标准化,计算节点样式,
我们知道层叠样式表(Cascading Style Sheets,CSS)描述了 DOM Tree 中节点的布局和样式,和 HTML 文件一样,渲染引擎也是无法直接理解 CSS 文件的,所以渲染引擎在接收到 CSS 文本时,首先会执行一个转换操作:将 CSS 文本转换为 StyleSheets。
什么是 StyleSheets 呢 ?为了直观的理解,可以在浏览器控制台输入,
document.styleSheets
StyleSheets 是一颗具有查询和修改功能的样式树,
转换完成后,渲染引擎会接着对所有的属性值进行标准化处理,
标准化完成后会开始计算 DOM Tree 中每个节点的具体样式,这里的计算涉及到 CSS 的层叠规则和继承规则,我们来简单回顾一下 CSS 的层叠和继承。
Cascade
CSS 的全称为层叠样式表(Cascading Style Sheets),层叠是 CSS 最基础的概念之一,
那么什么是层叠呢 ?
层叠是一个定义了如何合并来自多个源的属性值的算法。我们知道一个元素可以拥有来自不同源的 CSS 声明,这里的源可能是,
- 浏览器默认的 UserAgent 样式表
- 通过 link 引用的外部 CSS 文件
- 标记内的 CSS
- 元素的 style 属性内嵌的 CSS
当不同的规则都应用于同一个元素时,就会产生冲突(specificity),简单来说,层叠定义了产生冲突时应该应用的规则。如果来源相同,则会根据层叠样式的优先级,层叠顺序决定到底使用哪个值。
你可能发现有些子元素会默认拥有一些样式,这是因为 CSS 属性值继承导致的。
Inheritance
一些 CSS 属性会默认继承其父元素设置的值,这里具体哪些属性会继承很大程度上是由常识决定的。比如像字体大小(font-size),颜色(color)等属性会继承,但是宽度(width), 边距(margins, padding), 边框(border) 等属性就不会被继承。
想象一下,如果 border 可以被继承,我们给父元素设置了边框后,每个子元素和后代元素都会获得一个边框——这太糟糕了!🤔
理解了层叠和继承规则后,回到节点样式的计算流程,这个阶段渲染引擎会遵循 CSS 的层叠和继承规则,计算出 DOM 节点中每个元素的具体样式,最后生成 ComputedStyle,我们可以打开浏览器控制台选择 Element 下的 Computed 标签,查看每个节点的计算样式,
完成 DOM Tree 构建和样式计算后,渲染引擎接下来会进入布局阶段。
布局
怎么理解布局呢?
想象一下,我们现在手里有已经构建好的 DOM Tree 和节点元素的计算样式,但是并不知道元素的几何信息,而且 DOM Tree 中有很多元素(比如 head 标签)是不需要渲染的,所以我们还需要计算出 DOM 树中可见元素的几何位置,这个计算过程就是布局。
布局阶段主要有两个任务:创建布局树和布局计算,
HTML 解析器构建的 DOM Tree 中包含了一些 “特殊” 的节点,比如 head 标签,display 属性为 none 的元素等,它们是不需要被渲染到屏幕上的,所以渲染引擎会额外构建一颗只包含可见元素的布局树,在构建布局树,遍历 DOM 节点的同时,会进行节点几何坐标位置的计算,也就是布局计算,最后这些信息会被保存在**布局树(LayoutTree)**中。
有了布局树后,是不是就可以开始绘制了呢?还是不行 🤔,因为页面往往还包含很多复杂的效果,比如我们常见的3D 变换、页面滚动,层叠上下文的 z 轴排序等等,为了更方便的实现这些效果,渲染引擎会进行分层。
分层
什么是分层呢?
如果大家有用过 PhotoShop,Sketch 这类的设计软件,相信对图层这个概念并不陌生,这里渲染引擎会为特定的节点生成图层,最后构建一颗布局树(LayoutTree)对应的图层树(LayerTree),这个过程就是分层。
为了更直观的理解,我们打开浏览器开发者工具(以腾讯首页为例),选择 Layout 标签,

可以看到,浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。前面我们提到渲染引擎只会为特定的节点生成图层,那哪些是特定的节点呢?渲染引擎会为哪些节点创建图层呢?这里涉及到 CSS 的层叠上下文。
The stacking context
什么是层叠上下文 ?这里可以借 BFC 来辅助理解,
如果你还不了解 BFC,可以阅读我之前的文章可能是最好的 BFC 解析了。
我们知道视觉格式化模型中,BFC 定义了块级盒子在文档流中的布局方式,类似的还有 IFC,FFC,GFC,它们都是针对于二维平面的一些上下文,那么对于三维空间而言,元素在 z 轴上应该怎样排列呢?层叠上下文就是对 HTML 元素的一个三维构想,它定义了元素在 z 轴上的排列方式,简单来说,
- 元素在 z 轴上会按**层叠等级(stacking level)**进行层叠
- 具有层叠上下文的元素会优先于普通元素进行层叠
- 同一个层叠上下文中,层叠等级相同的元素,按它们在文档流中出现的顺序进行层叠
- 同一个层叠上下文中,可以通过 z-index 调整元素的层叠等级
哪些元素会创建层叠上下文呢 ?常见的有,
- 文档根元素(html)
- position 为 absolute 或 relative 且 z-index 不为 auto 的元素
- position 为 fixed 或 sticky 的元素
- flex 容器内,z-index 不为 auto 的子元素
- grid 容器内,z-index 不为 auto 的子元素
- opacity 属性值小于 1 的元素
- transform、filter、clip-path、perspective 值不为 none 的元素
- will-change 设定了任一属性
既然这么多元素都会创建层叠上下文,那么他们之间自然也存在层叠顺序,
浮动元素也会参与层叠计算,会被放置在非定位块与定位块之间,具体可以看层叠与浮动。
了解了层叠上下文后,让我们回到图层创建,通常来说,满足下面的条件之一渲染引擎就会为元素创建图层,
- 拥有层叠上下文属性的元素
- 需要**裁剪(clip)**的地方
- 滚动条
解释一下这里的 clip,以下面的这个 DOM 结构为例,
<div class="box">
<div class="text">
腾讯是一家世界领先的互联网科技公司,用创新的产品和服务提升全球各地人们的生活品质。腾讯成立于1998年,总部位于中国深圳。公司一直秉承科技向善的宗旨。我们的通信和社交服务连接全球逾10亿人,帮助他们与亲友联系,畅享便捷的出行、支付和娱乐生活。腾讯发行多款风靡全球的电子游戏及其他优质数字内容,为全球用户带来丰富的互动娱乐体验。腾讯还提供云计算、广告、金融科技等一系列企业服务,支持合作伙伴实现数字化转型,促进业务发展。
</div>
</div>
<style>
.box {
width: 200px;
overflow: auto;
}
.text {
white-space: nowrap;
}
</style>
当内容超出包含块时就会发生裁剪,这个时候渲染引擎会为原始内容单独创建一个图层,
渲染引擎通过分层完成了图层树(LayerTree)的构建,接下来就可以进行图层绘制。
图层绘制
有了 LayerTree 后,图层的绘制就比较简单了,渲染引擎会遍历 LayerTree,为每一个图层生成一个绘制指令列表,
我们也可以打开浏览器开发者工具 “Layout” 标签,选择一个图层的 Profiler 标签,查看具体的绘制指令和绘制过程,
前面我们介绍的 DOM 树构建,样式计算,布局,分层,绘制指令生成等流程基本都在渲染引擎主线程内完成,而实际上的图层绘制操作是由渲染引擎中的合成线程来完成的,绘制指令列表准备好后,主线程会把列表提交给合成线程,然后执行栅格化流程。
栅格化
介绍栅格化之前,我们来简单看一下上述的两个线程之间的交互关系,
思考一下,这里为什么需要提交(Commit)到另一个线程进行绘制呢?🤔
是不是跟我们之前介绍的 HTML 解析器处理数据的流程有点类似,多线程可以加速图层的绘制,合成线程在接受到图层的绘制指令列表后就可以开始栅格化,而不必等待主线程把完整的绘制指令全部生成。
什么是栅格化呢 ?
合成线程接受到指令列表(一个列表就是一个图层)后,首先会将图层划分为图块(tile),一个图块大小一般为 256 x 256 或 512 x 512,然后根据图块来生成位图,生成位图的操作由栅格化线程完成,所以栅格化就是指将图块转换为位图的过程,
你可能会有疑问,为什么需要分块来合成位图,而不是直接绘制图层 ?
我们来解释一下这里的原因。首先视口(viewport)代表屏幕上页面的可视区域,通常情况下合成线程接收到的图层是大于视口的,为了提高渲染效率,我们没有必要一次性把整个图层全部绘制出来,而是可以选择优先绘制视口附近的位图,所以分块是为了加速图层的绘制,分块后,视口附近的位图会被优先合成。
与前面多线程绘制类似,渲染进程也维护了一个栅格化线程池,用来加速栅格化流程,
现代浏览器一般会通过 GPU 来加速生成位图,这种方式就是我们说的快速栅格化。
合成
栅格化完成后,合成线程会生成一个绘制图块的命令(DrawQuad),然后提交给浏览器进程,浏览器进程接收到 DrawQuad 消息后会将命令对应的页面内容绘制到内存中,然后再将内存显示在屏幕上。最后我们来整体看一下渲染流程,

参考链接
写在最后
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
(完)