渲染流程:如何将 HTML、CSS 与 JavaScript 渲染至浏览器页面上?

550 阅读9分钟

了解渲染流程可以让我们明白页面是如何工作的,这样我们才有更加基础的知识来解决一系列相关的问题,比如如何熟练使用开发者工具、如何优化页面卡顿问题、如何优化动画流程,以及如何通过优化样式表来防止强制同步布局等等。

渲染的机制非常复杂,这也是为什么渲染模块在执行的过程当中会被划分为非常多的子阶段,我们把这样的一个有多个子阶段处理的流程叫做渲染流水线。我们得到的 HTML、CSS 和 JS 文件,通过这样的渲染流水线,便得到了我们最终所看到的页面。

按照渲染的时间顺序,流水线可以分为下面几个子阶段:

  • 构建 DOM 树。
  • 样式计算。
  • 布局阶段。
  • 分层。
  • 生成绘制列表。
  • 分块与栅格化。
  • 合成与显示。

对于每一个阶段,我们应该着重关注输入内容,处理过程与输出内容。

构建 DOM 树

我们需要构建 DOM 树的原因是因为浏览器无法直接理解和使用 HTML,所以我们需要将 HTML 转换为浏览器能够理解的结构,也就是 DOM 树。

在这个阶段,我们的输入内容便是我们的 HTML 内容,处理过程是 HTML 解析器解析的过程,输出内容便是我们最终得到的 DOM 树。

如果想要直观查看 DOM 树结构,我们可以打开 Chrome 的开发者工具,选择 console 界面并输入 document,于是我们便能看到一个完整的 DOM 树结构。

从结果中我们可以看出 DOM 树的内容和 HTML 的内容几乎是一样的,但是不同的地方就在于 DOM 树是保存在内存中的数据结构,可以通过 JavaScript 来查询或修改。

样式计算

我们在第一阶段得到了 DOM 树,但是我们并不知道树中每一个节点,即每个元素,的具体样式,因此我们就需要进行样式计算,在这个阶段我们可以分为三个步骤来完成。

将 CSS 转化为浏览器能够直接理解的结构

首先我们需要考虑 CSS 样式的主要来源,大概分为下面三种:

  • 通过 link 引用的外部 CSS 文件。
  • / 标签内的 CSS 内容。
  • 元素 style 属性中内嵌的 CSS。

与 HTML 文件一样,浏览器仍然无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接受到 CSS 文本时,会执行一个转换操作:将 CSS 文本转换为浏览器可以理解的结构 styleSheets。

我们同样可以通过 Chrome 的 console 页面中查看样式的结构,输入 document.styleSheets 就可以看到样式表。从结果中发现,样式表已经包含了上面提到过的三种来源的所有样式。同样这样的结构也具备了查询和修改的功能。

转换样式表中的属性值,使其标准化

CSS 不同的属性具有不同的量纲,比如字体大小的单位、长度宽度可能用百分比的形式来进行描述,以及颜色会用字符串的形式描述一些常见颜色等等。但是这些都并不是我们属性值的一个标准,因此我们需要将其标准化,也就是将所有的值转换为渲染引擎容易理解的计算结果。

计算出 DOM 树中每个节点的具体样式

在样式的属性值已经标准化后,接下来我们就需要计算 DOM 树中每个节点的样式属性了,这中间就涉及到 CSS 的继承规则和层叠规则。

CSS 继承规则指的是每个节点都会包含父节点的样式。

CSS 层叠规则定义了如何合并来自多个源的属性值的算法,CSS 的全称「层叠样式表」就强调了这一点,层叠规则在 CSS 中处于核心地位。

经历过了这三个步骤,我们在这个阶段最终输出的内容便是每个节点的样式,这被保存在了computed styles 当中。

布局阶段

尽管我们现在已经得到了 DOM 树以及 DOM 树中节点的样式,但是我们还并不知道每个元素具体的几何分布,因此还不能够直接显示页面,接下来我们就需要计算出多目数中每个可见元素的几何位置在何处,我们把这个计算过程叫做布局。

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

创建布局树

在我们的 DOM 树当中,有一部分元素是并不显示的节点,比如 head 标签;有的元素可能是暂时不可见的节点,比如设置了 display: none 属性,所以我们在计算并显示具体的布局之前,我们需要额外购建一棵只包含可见元素的布局树。

为了构建布局树,浏览器大体完成了下面这些工作(1)便利 DOM 树中的所有可见节点,并把这些节点加入布局树中(2)不可见的节点会被布局树忽略。

布局计算

布局计算过程非常复杂,我们暂时先不在此处提及。但是我们要知道的是我们在这个阶段已经得到了最终的布局树,并且知道了每一个节点具体的布局值。

分层

从直观而言,我们已经得到了每个节点的样式,得到了每个节点应该的布局位置,似乎接下来就应该开始正式的绘制页面了,但是并不是。

由于页面中存在许多复杂的效果,比如 3D 变换、页面滚动,或者使用 z-index 属性,所以我们为了更加方便的实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一颗对应的图层数。

我们可以在 Chrome 的 layers 标签中查看可视化页面的分层情况,从效果上看我们可以发现,渲染引擎将页面分为了很多图层,这些图层按照一定顺序叠加在一起,才形成了最终的页面。

很多时候并不是布局树中的每一个节点都包含了一个图层,如果一个节点没有对应的图层,那么这个节点就会从属于父节点所在的图层,但是不管怎么样,最终每一个节点都会直接或间接的从属于一个图层。

那么我们就需要考虑,到底在什么条件下,渲染引擎才会为特定的节点创建新的图层。事实上通常满足下面两点中任意一点的节点都将会被提升为单独的一个图层:

  • 拥有层叠上下文属性的元素会被提升为单独的一层。页面本身是一个二维布局,但是层叠上下文能够让 HTML 元素具有三维的概念,这些 HTML 元素会按照自身属性的优先级分布垂直在这个二维平面的 z 轴。明确了定位属性的元素、定义透明属性的元素以及使用了 CSS 滤镜的元素等等都拥有了层叠上下文属性
  • 需要剪裁的地方也会被创建为图层。

生成绘制列表

在我们已经完成图层树的构建后,渲染引擎将会对图层树中的每一个图层进行绘制,接下来我们关注一下,渲染引擎如何实现图层绘制。

渲染引擎会把图层的绘制拆分为很多小的绘制指令,然后将这些绘制指令按照顺序组成一个待绘制列表。

栅格化操作

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

当图层的绘制列表准备后,主线程会将这个绘制列表提交给合成线程。

通常一个页面可能很大,但是用户只能看到其中的一个部分,我们将用户可以看到的这个部分叫做视口,在有些情况下有的图层可以很大,比如有的页面我们需要使用滚动条滚动好久才能滚动到页面底部,但是通过视图用户只能看到页面的很小一部分,所以在这种情况下,如果我们想要绘制所有图层内容的话,就会产生过于大的开销,这是没有必要的。基于这个原因,合成线程会将图层划分为图块。

接下来合成线程会按照视口附近的图块来优先⽣成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。

而图块是栅格化执⾏的最小单位。渲染进程维护了⼀个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。

合成和显示

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

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

到这⾥,经过这⼀系列的阶段,编写好的 HTML、CSS、JavaScript 文件,就可以显示出来了。

总结

完整的渲染流程大致如下:

  1. 渲染进程将 HTML 内容转换为浏览器能够读懂的 DOM 树结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,并计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并⽣成分层树。
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令DrawQuad给浏览器进程。
  8. 浏览器进程根据DrawQuad消息生成页面,并显示到显⽰器上。