渲染流程:HTML、CSS和JavaScript,是如何变成页面的?

100 阅读12分钟

从图中可以看出,左边输入的是 HTML、CSS、JavaScript 数据,这些数据经过中间渲染模块的处理,最终输出为屏幕上的像素。 image.png

HTML 的内容是由标记和文本组成。标记也称为标签,每个标签都有它自己的语义,浏览器会根据标签的语义来正确展示 HTML 内容。如果需要改变 HTML 的字体颜色、大小等信息,就需要用到 CSS。CSS 又称为层叠样式表,是由选择器和属性组成JavaScript(简称为 JS),使用它可以使网页的内容“动”起来

image.png

由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线(webgl 中也有渲染流水管线的概念),其大致流程如下图所示:

image.png 按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

一、构建 DOM 树

为什么要构建 DOM 树呢?这是因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树

image.png

二、样式计算(Recalculate Style)

1. 把 CSS 转换为浏览器能够理解的结构

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

image.png

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

CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

image.png

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

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?这就涉及到 CSS 的继承规则和层叠规则了。

CSS 继承就是每个 DOM 节点都包含有父节点的可继承 的样式

image.png

层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。比如后定义的样式会覆盖前面定义的样式,比如用!important(不推荐)定义的样式优先级高于其他定义等。

三、布局阶段

计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。

1. 创建布局树

DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,我们还要额外地构建一棵只包含可见元素布局树

image.png

为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如属性包含 dispaly:none的元素也没有被包进布局树。

2. 布局计算

布局计算实际是一个对页面布局进行具体位置和大小计算的过程,它基于布局树来完成:

  1. 计算每个元素的位置和尺寸

    • 浏览器遍历布局树,计算每个元素在页面上的确切位置(如 x, y 坐标)。
    • 确定每个元素的尺寸(宽度和高度)。
  2. 处理盒模型(Box Model)

    • 浏览器根据标准的 CSS 盒模型(内容、填充、边框和外边距)来进行布局计算。
    • 例如,内容区域的大小可能受内边距、边框和外边距的影响。
  3. 依赖关系处理

    • 有些元素的位置和尺寸可能取决于其他元素,例如,浮动(float)元素、绝对定位(position: absolute)或固定定位(position: fixed)元素等。
    • 浏览器会按照依赖关系来计算这些元素的位置和尺寸。

四、分层

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了? 答案依然是否定的。 因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。 要想直观地理解什么是图层,你可以打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下图所示:(不知道如何放大这个图,小小的)

Clipboard_Screenshot_1740210280.png

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

image.png

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

第一点,拥有层叠上下文属性的元素会被提升为单独的一层。 页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。若更多层叠上下文的知识,你可以参考这篇文章

image.png

第二点,需要剪裁(clip)的地方也会被创建为图层。

  • CSS 属性 clip 和 clip-path
  • 具有裁剪和滚动内容的容器:overflow: hiddenoverflow: autooverflow: scroll
  • CSS 属性 will-change
  • CSS mask 和 mask-image
  • CSS 动画和变换:涉及 transformopacityfilter, 等属性的 CSS 动画常常触发新图层的创建,以便浏览器可以更高效地合成和渲染。

第五、图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?它会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

image.png 通过 chrome 的开发者工具可以看到详细的绘制指令。

Clipboard_Screenshot_1740212203.png

第六、栅格化(raster)操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系: image.png 当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit) 给合成线程,那么接下来合成线程是怎么工作的呢? 视口的定义:屏幕上页面的可见区域叫视口。通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。 基于这个原因,合成线程会将图层划分为图块(tile) ,这些图块的大小通常是 256x256 或者 512x512,如下图所示:

image.png

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

image.png

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。 相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:

image.png 渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

第七、合成和显示

合成线程会生成绘制图块的命令(如“DrawQuad”),并逐步提交给浏览器进程。这些命令可能会在一部分图块完成光栅化后就开始执行,不必等到所有图块都被完全光栅化。从某种程度上说,浏览器会在尽可能早的时刻显示已经光栅化完成的图块,以尽量减少白屏时间和提高页面渲染速度。

第八、渲染流水线大总结

image.png

一个完整的渲染流程大致可总结为如下:

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

第八、相关概念:“重排”“重绘”和“合成”

1. 更新了元素的几何属性(重排:reflow、relayout)

image.png 通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的

避免和优化重排的方法

为了优化性能,以下是一些常见的避免和优化重排的方法:

  1. CSS 类操作:尽量通过一次性改变元素的 CSS 类,而不是多次逐条修改 CSS 属性。
  2. 批量修改 DOM:尽量将多次 DOM 操作合并为一次性操作。例如,使用文档片段(DocumentFragment)或对元素进行离线(例如通过 display: none or visibility: hidden)操作。
  3. 读写分离:避免在一次 JavaScript 操作中混合读取和写入 DOM 属性。这是因为每次读取几何属性(如 offsetWidth)都会导致浏览器刷新布局信息,可能触发重排。
  4. 使用动画和过渡:使用 CSS 动画或 transition 来改变位置或尺寸,而不是通过频繁的 JavaScript 操作。
  5. 避免复杂的选择器:复杂的 CSS 选择器会影响浏览器计算样式的效率,尽量使用简单、具体的选择器。

2.更新元素的绘制属性(重绘)

image.png

修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些

3. 直接合成阶段

更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:

image.png

比如使用 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率