04 页面渲染 | HTML、CSS和JavaScript是如何渲染为页面的

179 阅读13分钟

开始之前,让我们先回顾一下上一节的导航过程:

  1. 用户输入关键字

    • 搜索内容 ---> 结合搜索引擎合成带有搜索关键字的URL
    • 符合URL规则 ---> 加上协议生成完整的URL

    浏览器进程处理用户输入,得到完整的URL,并把URL请求通过进程间通信发送给网络进程

  2. DNS解析

    • 判断是否为HTTPS

      • HTTPS建立TLS连接
    • 先请求本地DNS,没有再去云端请求 ---> 得到即将发起请求的IP地址

    • 判断是否有Cache缓存

      • 有缓存 ---> 直接去7
      • 没有缓存 ---> 进入网络请求
  3. 建立TCP连接

    • 三次握手建立TCP连接
  4. 浏览器发送请求

    • 构建请求报文,并加入本地Cookie到响应头
    • 发送请求报文给服务器
  5. 服务器响应请求

    • 服务器收到请求报文之后,便根据请求信息生成响应数据(响应行、响应头、响应体)
  6. 网络进程解析响应信息

    • 网络进程解析到响应头中的Content-Type,浏览器主进程准备渲染进程
  7. 渲染进程准备好之后,浏览器发送“提交文档”给渲染进程

    • 渲染进程与网络进程进行通信,建立通道,传输数据
    • 数据传输结束,渲染进程发送消息给浏览器进程,文档提交完毕
  8. 浏览器进程更新浏览器界面状态、刷新web页面

  9. 一旦文档被提交,渲染进程便开始页面解析和子资源加载

  10. 页面绘制完成,渲染进程发送消息给浏览器进程。浏览器进程停止加载动画

0. 开始

在上一篇文章中我们介绍了导航相关的流程,那导航被提交后又会怎么样呢?就进入了渲染阶段。这一阶段非常重要,了解其相关流程能让你“看透”页面是如何工作的,有了这些知识,你可以解决一系列相关的问题,比如能熟练使用开发者工具,因为能够理解开发者工具里面大部分项目的含义,能优化页面卡顿问题,使用 JavaScript 优化动画流程,通过优化样式表来防止强制同步布局,等等。

既然如此,那接下来让我好好看看这个渲染流程,它究竟是怎样进行的?

文档被提交之后,渲染进程便开始页面解析和子资源加载了。

渲染进程会怎么处理拿到的HTML、CSS、JavaScript等文件呢?

4-1.png

从图中可以看出,这些数据经过中间渲染模块的处理,最终输出为屏幕上的像素。

渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML/CSS/JavaScript 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线

4-2.png

按照渲染时间顺序,流水线可分为如下子阶段:

  1. 构建DOM树
  2. 样式计算
  3. 布局阶段
  4. 分层
  5. 绘制
  6. 分块
  7. 光栅化
  8. 合成

值得一提的是,这么多子阶段都有一些共同特点:

开始每个子阶段都有其输入的内容

然后每个子阶段有其处理过程

最终每个子阶段会生成输出内容

1. 构建DOM树

因为浏览器无法直接理解和使用 HTML,所以需要渲染进程需要将HTML 转换为浏览器能够理解的结构——DOM 树

DOM树的构建过程:

4-3.png

可在chrome控制台输入document查看当前页面的DOM树:

4-4.png

其中的document就是DOM结构,它的内容和HTML几乎一致,但和HTML不同的是,DOM是保存在内存中的树状结构,可以通过JavaScript来修改

现在我们有了DOM树了,但是每个DOM节点的样式我们还不知道,想要让DOM节点拥有正确的样式,这就需要样式计算了。

2. 样式计算

2.1 转换CSS为styleSheets

和HTML文件一样,浏览器也无法直接理解CSS文本文件,当渲染引擎收到CSS文本的时候,会把CSS文本转换为浏览器可以理解的结构----styleSheets.

你可以在chrome控制台输入document.styleSheets来查看:

4-5.png

这里我们暂且不关注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的一个很重要的特点就是继承和覆盖。

以上面标准化后的代码为例:

4-6.png

从上图可以看出,所有的子节点都继承了父节点的样式,你还可以在chrome的element页面查看继承的过程:

4-7.png

如果你不提供任何样式,默认使用的就是 UserAgent样式(浏览器默认样式)。

简而言之,样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式,在计算中需要遵守CSS的继承和层叠的两个规则。

这个阶段最终输出的内容是每个DOM节点的样式,并被保存在ComputedStyle的结构内。

你可以在chrome中查看每个DOM元素最终的计算样式:

4-8.png

上图显示了html.body.div.input的最终的计算样式

3. 布局阶段

经过上面两个阶段,我们得到了DOM树和树中每个节点的样式,但这还不足以显示元素,因为我们不知道元素在页面的几何位置信息。接下来我们便需要计算出DOM树中每个可见元素的位置信息,我们把这个阶段叫做布局。

布局阶段需要完成两个任务:创建布局树和布局计算。

3.1 创建布局树

布局树创建流程:

4-9.png

为了构建布局树,浏览器大概进行了以下操作:

遍历所有可见的DOM树节点,并把它加入到布局树当中,不可见的节点会被忽略掉

3.1 布局计算

现在我们有了一棵完整的布局树,接下来就要计算布局树中每个节点的具体位置了。布局的计算过程非常复杂,我会在后面的文章中加以介绍。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

4. 分层

有了布局树,并且每个布局树节点的位置信息都计算出来了之后,是否可以进行页面的绘制了?

答案是否定的。

因为页面中还有很多复杂的效果,比如复杂的 3D 变换、页面滚动,或者使用 z-indexing做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还要为特定的节点生成专用的图层,并生成一棵对应的图层树

打开chrome控制台的layer,你就可以看到当前可视化页面的图层情况,如下图所示:

4-10.png

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面

下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:

4-11.png

诚然,并不是每个布局树节点都包含一个图层,如果一个节点没有包含图层(图中的span),那么这个节点就从属于父节点的图层。

不管怎么样,每个节点都会直接或者间接地从属于一个层

那么,什么情况下渲染引擎才会为特定的节点创建新的层呢?满足下列条件其一即可。

  1. 拥有层叠上下文属性的节点会被提升为单独一层

    层叠上下文属性:明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性

4-12.png

  1. 需要剪裁(clip)的地方也会被创建为图层。

    首先你需要了解什么是剪裁。

    比如我给定了一个200px*200px大小的div,但div中的文字超出了这个范围,这时候就产生了剪裁。

    渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。

    这种情况渲染引擎会为文字部分单独地创建一个层,如果有滚动条,也会给滚动条新建一个层。

4-13.png

5. 图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。

接下来我们看看渲染引擎是怎么实现图层绘制的。

首先,渲染引擎会把图层的绘制拆分为很多个小的绘制指令,然后再根据这些指令组成一个待绘制列表:

4-14.png

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。

而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。

所以在图层绘制阶段,输出的内容就是这些待绘制列表

你可以打开chrome查看这些待绘制列表:

4-15.png

图中的左下角是待绘制列表,拖动右下角中的进度条可以重现列表的绘制过程

6.栅栏化操作

待绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的

渲染主进程和合成线程之间的关系:

4-16.png

当待绘制列表准备好之后,会被主线程提交给合成线程。

而合成线程又是怎么工作的呢?

通常情况下,一个页面很大,用户只能看到页面中的一部分。我们把用户看到的这个部分成为视口

而有些情况下,有的图层可以很大,用户只能看到很小的一部分要用滚动条滚动好久才能滚动到底部。所以在这种情况下绘制出全部的图层就会产生很大的开销,而且也没有必要。

因此,合成线程会把图层分为图块,这些图块的面积通常为253×256或者512×512,如下图所示:

4-17.png

然后合成线程会根据视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的

所谓栅格化,是指将图块转换为位图。

图块是栅格化执行的最小单位,渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是再线程池中执行的:

4-18.png

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。而GPU 操作是运行在 GPU 进程中,所以这就涉及到了跨进程操作。

GPU栅格化:

4-19.png

GPU栅格化中,把生成图块的指令发送给 GPU,然后在 GPU 中生成图块的位图,并保存在 GPU 的内存中。

7. 合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。

8. 相关概念

有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念,理解了这些概念对于你后续 Web 的性能优化有很大帮助。

8.1 重排

4-21.png

如果你通过JavaScript或者CSS修改了元素的几何属性(宽、高),浏览器会触发重新布局,重排需要更新完整的渲染流水线

8.2 重绘

4-22.png

如果你只是修改了背景,大小位置信息没有发生变化,页面的布局不需要改变,所以就直接进入绘制阶段,重绘的效率要高于重排。

8.3 直接合成

4-23.png

如果你更改一个既不要布局也不要绘制的属性,比如们使用了 CSS 的 transform 来实现动画效果,可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。

这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段。

9. 渲染流程总结

结合以上,一个完整的渲染流程可以总结为:

  1. 渲染进程将HTML解析为浏览器能读懂的DOM树
  2. 渲染引擎将CSS样式表转换为浏览器可以理解的styleSheets,规范化后,再根据继承和覆盖计算出每个DOM节点的样式
  3. 创建布局树,计算出每个节点的布局信息
  4. 对布局树进行分层,并且生成分层树
  5. 为每个图层生成待绘制列表,并将其提交给合成进程
  6. 合成进程将图层分为图块,并在光栅化线程池中将图块光栅化
  7. 合成线程发送绘制图块命令DrawQuad到浏览器进程
  8. 浏览器进程viz组件根据DrawQuad消息生成页面到内存,然后显示到页面

一张图来总结下这整个渲染流程:

4-20.png