开始之前,让我们先回顾一下上一节的导航过程:
-
用户输入关键字
- 搜索内容 ---> 结合搜索引擎合成带有搜索关键字的URL
- 符合URL规则 ---> 加上协议生成完整的URL
浏览器进程处理用户输入,得到完整的URL,并把URL请求通过进程间通信发送给网络进程
-
DNS解析
-
判断是否为HTTPS
- HTTPS建立TLS连接
-
先请求本地DNS,没有再去云端请求 ---> 得到即将发起请求的IP地址
-
判断是否有Cache缓存
- 有缓存 ---> 直接去7
- 没有缓存 ---> 进入网络请求
-
-
建立TCP连接
- 三次握手建立TCP连接
-
浏览器发送请求
- 构建请求报文,并加入本地Cookie到响应头
- 发送请求报文给服务器
-
服务器响应请求
- 服务器收到请求报文之后,便根据请求信息生成响应数据(响应行、响应头、响应体)
-
网络进程解析响应信息
- 网络进程解析到响应头中的
Content-Type,浏览器主进程准备渲染进程
- 网络进程解析到响应头中的
-
渲染进程准备好之后,浏览器发送“提交文档”给渲染进程
- 渲染进程与网络进程进行通信,建立通道,传输数据
- 数据传输结束,渲染进程发送消息给浏览器进程,文档提交完毕
-
浏览器进程更新浏览器界面状态、刷新web页面
-
一旦文档被提交,渲染进程便开始页面解析和子资源加载
-
页面绘制完成,渲染进程发送消息给浏览器进程。浏览器进程停止加载动画
0. 开始
在上一篇文章中我们介绍了导航相关的流程,那导航被提交后又会怎么样呢?就进入了渲染阶段。这一阶段非常重要,了解其相关流程能让你“看透”页面是如何工作的,有了这些知识,你可以解决一系列相关的问题,比如能熟练使用开发者工具,因为能够理解开发者工具里面大部分项目的含义,能优化页面卡顿问题,使用 JavaScript 优化动画流程,通过优化样式表来防止强制同步布局,等等。
既然如此,那接下来让我好好看看这个渲染流程,它究竟是怎样进行的?
文档被提交之后,渲染进程便开始页面解析和子资源加载了。
渲染进程会怎么处理拿到的HTML、CSS、JavaScript等文件呢?
从图中可以看出,这些数据经过中间渲染模块的处理,最终输出为屏幕上的像素。
渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML/CSS/JavaScript 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线
按照渲染时间顺序,流水线可分为如下子阶段:
- 构建DOM树
- 样式计算
- 布局阶段
- 分层
- 绘制
- 分块
- 光栅化
- 合成
值得一提的是,这么多子阶段都有一些共同特点:
开始每个子阶段都有其输入的内容;
然后每个子阶段有其处理过程;
最终每个子阶段会生成输出内容。
1. 构建DOM树
因为浏览器无法直接理解和使用 HTML,所以需要渲染进程需要将HTML 转换为浏览器能够理解的结构——DOM 树
DOM树的构建过程:
可在chrome控制台输入document查看当前页面的DOM树:
其中的document就是DOM结构,它的内容和HTML几乎一致,但和HTML不同的是,DOM是保存在内存中的树状结构,可以通过JavaScript来修改。
现在我们有了DOM树了,但是每个DOM节点的样式我们还不知道,想要让DOM节点拥有正确的样式,这就需要样式计算了。
2. 样式计算
2.1 转换CSS为styleSheets
和HTML文件一样,浏览器也无法直接理解CSS文本文件,当渲染引擎收到CSS文本的时候,会把CSS文本转换为浏览器可以理解的结构----styleSheets.
你可以在chrome控制台输入document.styleSheets来查看:
这里我们暂且不关注styleSheets的结构,你只需要知道渲染引擎会把得到的CSS文本全部转换为样式表中的数据,并且这些数据是可以修改和查询的,这会为后面的样式操作提供基础。
2.2 转换styleSheets中的属性值,使它标准化
标准化过程会把一些属性值(em,blue, bold)等转换为标准形式
以下面的CSS为例
1 body { font-size: 20px }
2 p {color:blue;}
3 span {display: none}
4 div {font-weight: bold;color:red}
5 div p {color:green;}
标准化后为:
1 body { font-size: 20px }
2 p {color:rgb(0,0,255);}
3 span {display: none}
4 div {font-weight: 700;color:rgb(0,128,0)}
5 div p {color:rgb(255,0,0);}
2.3 计算每个DOM节点的样式
css的一个很重要的特点就是继承和覆盖。
以上面标准化后的代码为例:
从上图可以看出,所有的子节点都继承了父节点的样式,你还可以在chrome的element页面查看继承的过程:
如果你不提供任何样式,默认使用的就是 UserAgent样式(浏览器默认样式)。
简而言之,样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式,在计算中需要遵守CSS的继承和层叠的两个规则。
这个阶段最终输出的内容是每个DOM节点的样式,并被保存在ComputedStyle的结构内。
你可以在chrome中查看每个DOM元素最终的计算样式:
上图显示了html.body.div.input的最终的计算样式
3. 布局阶段
经过上面两个阶段,我们得到了DOM树和树中每个节点的样式,但这还不足以显示元素,因为我们不知道元素在页面的几何位置信息。接下来我们便需要计算出DOM树中每个可见元素的位置信息,我们把这个阶段叫做布局。
布局阶段需要完成两个任务:创建布局树和布局计算。
3.1 创建布局树
布局树创建流程:
为了构建布局树,浏览器大概进行了以下操作:
遍历所有可见的DOM树节点,并把它加入到布局树当中,不可见的节点会被忽略掉
3.1 布局计算
现在我们有了一棵完整的布局树,接下来就要计算布局树中每个节点的具体位置了。布局的计算过程非常复杂,我会在后面的文章中加以介绍。
在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。
4. 分层
有了布局树,并且每个布局树节点的位置信息都计算出来了之后,是否可以进行页面的绘制了?
答案是否定的。
因为页面中还有很多复杂的效果,比如复杂的 3D 变换、页面滚动,或者使用 z-indexing做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还要为特定的节点生成专用的图层,并生成一棵对应的图层树。
打开chrome控制台的layer,你就可以看到当前可视化页面的图层情况,如下图所示:
现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。
下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:
诚然,并不是每个布局树节点都包含一个图层,如果一个节点没有包含图层(图中的span),那么这个节点就从属于父节点的图层。
不管怎么样,每个节点都会直接或者间接地从属于一个层
那么,什么情况下渲染引擎才会为特定的节点创建新的层呢?满足下列条件其一即可。
-
拥有层叠上下文属性的节点会被提升为单独一层
层叠上下文属性:明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性
-
需要剪裁(clip)的地方也会被创建为图层。
首先你需要了解什么是剪裁。
比如我给定了一个200px*200px大小的div,但div中的文字超出了这个范围,这时候就产生了剪裁。
渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。
这种情况渲染引擎会为文字部分单独地创建一个层,如果有滚动条,也会给滚动条新建一个层。
5. 图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。
接下来我们看看渲染引擎是怎么实现图层绘制的。
首先,渲染引擎会把图层的绘制拆分为很多个小的绘制指令,然后再根据这些指令组成一个待绘制列表:
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。
而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。
所以在图层绘制阶段,输出的内容就是这些待绘制列表。
你可以打开chrome查看这些待绘制列表:
图中的左下角是待绘制列表,拖动右下角中的进度条可以重现列表的绘制过程
6.栅栏化操作
待绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的
渲染主进程和合成线程之间的关系:
当待绘制列表准备好之后,会被主线程提交给合成线程。
而合成线程又是怎么工作的呢?
通常情况下,一个页面很大,用户只能看到页面中的一部分。我们把用户看到的这个部分成为视口。
而有些情况下,有的图层可以很大,用户只能看到很小的一部分要用滚动条滚动好久才能滚动到底部。所以在这种情况下绘制出全部的图层就会产生很大的开销,而且也没有必要。
因此,合成线程会把图层分为图块,这些图块的面积通常为253×256或者512×512,如下图所示:
然后合成线程会根据视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的
所谓栅格化,是指将图块转换为位图。
图块是栅格化执行的最小单位,渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是再线程池中执行的:
通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。而GPU 操作是运行在 GPU 进程中,所以这就涉及到了跨进程操作。
GPU栅格化:
GPU栅格化中,把生成图块的指令发送给 GPU,然后在 GPU 中生成图块的位图,并保存在 GPU 的内存中。
7. 合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。
8. 相关概念
有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念,理解了这些概念对于你后续 Web 的性能优化有很大帮助。
8.1 重排
如果你通过JavaScript或者CSS修改了元素的几何属性(宽、高),浏览器会触发重新布局,重排需要更新完整的渲染流水线。
8.2 重绘
如果你只是修改了背景,大小位置信息没有发生变化,页面的布局不需要改变,所以就直接进入绘制阶段,重绘的效率要高于重排。
8.3 直接合成
如果你更改一个既不要布局也不要绘制的属性,比如们使用了 CSS 的 transform 来实现动画效果,可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。
这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段。
9. 渲染流程总结
结合以上,一个完整的渲染流程可以总结为:
- 渲染进程将HTML解析为浏览器能读懂的DOM树
- 渲染引擎将CSS样式表转换为浏览器可以理解的styleSheets,规范化后,再根据继承和覆盖计算出每个DOM节点的样式
- 创建布局树,计算出每个节点的布局信息
- 对布局树进行分层,并且生成分层树
- 为每个图层生成待绘制列表,并将其提交给合成进程
- 合成进程将图层分为图块,并在光栅化线程池中将图块光栅化
- 合成线程发送绘制图块命令DrawQuad到浏览器进程
- 浏览器进程viz组件根据DrawQuad消息生成页面到内存,然后显示到页面
一张图来总结下这整个渲染流程: