Skip to content

浏览器渲染流程深度解析

核心思想:浏览器并非在接收到 HTML 文档后一瞬间就将页面呈现出来,其背后经历了一个复杂且精细的流水线过程。理解这个过程是前端性能优化的基石。整个流程可以概括为:从网络获取文档,然后由渲染主线程和合成线程协作,经过多个阶段,最终将页面绘制到屏幕上。

一、 整体流程概览

浏览器从接收到 HTML 文档到最终在屏幕上显示页面,大致分为以下几个核心步骤。每一步都有其特定的输入和输出,并将输出作为下一步的输入。

  1. 解析 HTML (Parse HTML):将 HTML 文本解析成 DOM 树 (DOM Tree)
  2. 样式计算 (Style Calculation):结合 CSS 规则,计算出 DOM 树中每个节点的最终样式,生成带样式的 DOM 树。
  3. 布局 (Layout):根据节点的样式计算其在屏幕上的精确位置和大小,生成 布局树 (Layout Tree)
  4. 分层 (Layer):将布局树划分为不同的图层,生成 图层树 (Layer Tree)
  5. 生成绘制指令 (Paint):为每个图层生成绘制指令列表。
  6. 分块 (Tiling):将每个图层划分为更小的图块。
  7. 光栅化 (Rasterization):将图块转换为位图(像素信息)。
  8. 绘制 (Draw):将位图发送给 GPU,最终显示在屏幕上。

二、 详细步骤拆解

阶段一:解析 HTML,生成 DOM 树和 CSSOM 树

当渲染主线程拿到 HTML 文档后,它的首要任务是将其解析成浏览器能够理解的结构——DOM 树

  1. 字节流转字符串 (Bytes -> Characters):网络传输的是 0 和 1 的字节数据。浏览器首先根据文件的编码格式(如 UTF-8)将其转换为可读的字符串。
  2. 标记化 (Tokenization):渲染引擎对字符串进行词法分析(“拆词”),将其拆解成一个个的 标记 (Token),例如 <html><p>、文本内容等。
  3. 构建节点 (Tokens -> Nodes):根据 Token 创建对应的 DOM 节点。
  4. 构建 DOM 树 (Nodes -> DOM Tree):将各个节点根据其父子关系链接起来,形成一个树状结构,即 DOM 树
html
<!DOCTYPE html>
<html>
<head>
  <title>My Page</title>
</head>
<body>
  <p>Hello World</p>
</body>
</html>

上述 HTML 会被解析成类似下图的 DOM 树结构:

  • document
    • html
      • head
        • title
          • "My Page"
      • body
        • p
          • "Hello World"

CSS 解析与 CSSOM

在解析 HTML 的过程中,如果遇到 <link> 标签或 <style> 标签,浏览器会并行处理 CSS。

  • 预解析线程:为了提高效率,浏览器会启动一个 预解析线程,快速扫描 HTML 文档,寻找外部资源链接(如 CSS、JS 文件),并提前开始下载。
  • CSSOM 树:与解析 HTML 类似,浏览器会将 CSS 规则解析成一个树形结构——CSS 对象模型 (CSSOM, CSS Object Model)。这棵树记录了所有的样式规则、选择器和具体的样式声明。

结论:解析阶段完成后,我们得到了两个独立的树形结构:DOM 树(内容结构)和 CSSOM 树(样式规则)。

JavaScript 的影响

当主线程解析到 <script> 标签时,情况有所不同:

  • 阻塞行为:主线程会 暂停 HTML 的解析,等待 JavaScript 文件下载(如果外链)并执行完毕。
  • 原因:JavaScript 代码可能会修改当前的 DOM 树(例如使用 document.write()document.createElement())。为了避免后续解析的 DOM 结构不正确,浏览器必须等待 JS 执行完成。
  • 优化建议:这就是为什么我们通常建议将 <script> 标签放在 <body> 的末尾,或者使用 asyncdefer 属性来避免阻塞渲染。
html
<body>
  <script src="app.js"></script>
</body>

阶段二:样式计算 (Computed Style)

现在我们有了 DOM 树和 CSSOM 树,需要将它们结合起来,确定每个 DOM 节点最终应该应用哪些样式。

这个过程是 为 DOM 树的每个节点计算出其所有 CSS 属性的最终值

  • 输入:DOM 树 + CSSOM 树
  • 输出:一个带样式的 DOM 树

在这一步:

  • 值规范化:浏览器会将所有相对单位(如 em, rem)转换成绝对单位(px),将颜色值(如 red)转换成统一的 rgb(255, 0, 0) 格式等。
  • 计算所有样式:即使你只为元素设置了 color: red,浏览器也会计算出该元素所有的 CSS 属性值(如 font-size, margin, display 等),未指定的属性会使用继承值或默认值。

注意:这一步的产出是一棵包含了每个节点完整样式信息的 DOM 树,为后续的布局奠定了基础。

阶段三:布局 (Layout)

虽然我们知道了每个节点的样子(颜色、字体大小等),但还不知道它们在页面上的具体位置和尺寸。布局 阶段就是为了计算出这些几何信息。

  • 输入:带样式的 DOM 树
  • 输出布局树 (Layout Tree),也叫渲染树 (Render Tree)

这个过程会递归地遍历 DOM 树,为每个可见节点计算出其坐标(x, y)和盒子大小(width, height)。

布局树与 DOM 树并非一一对应

  • display: none 的节点不会出现在布局树中,因为它不占据任何空间。
  • 伪元素(如 ::before, ::after)虽然不在 DOM 树中,但会出现在布局树中。
  • 某些不可见的元素(如 <head>)也不会出现在布局树中。

这一步的操作也常被称为 重排 (Reflow)回流

阶段四:分层 (Layering)

为了优化渲染,特别是在处理复杂的动画、滚动等场景时,浏览器会引入分层的概念,类似于 Photoshop 中的图层。

  • 输入:布局树
  • 输出图层树 (Layer Tree)

主线程会遍历布局树,识别出需要被提升为单独图层的节点,并创建一棵图层树。

为什么需要分层? 将页面内容分成多个图层后,如果某个图层发生变化(例如一个 transform 动画),浏览器只需要重绘这一个图层,而不需要影响其他图层,从而极大地提升了渲染效率。

哪些情况会创建新图层? 拥有特定 CSS 属性的节点会被浏览器视为独立的图层,常见情况包括:

  • 拥有 3D 变换的元素 (transform: translateZ(...), rotate3d(...) 等)
  • 拥有 position: fixed 的元素
  • 拥有 will-change 属性的元素
  • 拥有 z-indexpositionabsolute/relative 的元素
  • 拥有 opacity, filter 等属性的元素

阶段五:生成绘制指令 (Paint)

分层完成后,渲染主线程 的工作基本告一段落。它会为每个图层生成一份详细的绘制指令列表。

  • 输入:图层树
  • 输出:每个图层的绘制指令列表 (Paint Records)

这个指令列表非常简单,类似于:

  1. 将画笔移动到 (x1, y1)
  2. 绘制一个矩形,颜色为蓝色
  3. 将画笔移动到 (x2, y2)
  4. 写入文本 "Hello"

关键点:此时,渲染主线程 会将这些指令列表提交给 合成线程 (Compositor Thread),接下来的工作将由合成线程和 GPU 接管。

阶段六、七、八:合成与绘制 (Tiling, Raster, Draw)

合成线程 接手后,将执行最后的光栅化和绘制流程。

6. 分块 (Tiling)

由于图层可能很大,一次性处理整个图层会非常耗时。因此,合成线程会将每个图层划分成若干个小的 图块 (Tiles)(通常是 256x256 或 512x512 像素)。

7. 光栅化 (Rasterization)

  • 合成线程将每个图块的绘制任务分发给 光栅线程 (Raster Threads)
  • 光栅线程负责将图块转换成位图,即填充每个像素点的具体颜色信息(RGB 值)。
  • 这个过程通常会优先处理视口(viewport)内的图块,并且可以由 GPU 加速完成,速度极快。

8. 绘制 (Draw / Compositing)

  • 所有图块光栅化完成后,合成线程会收集到每个图块的位图信息,这些信息被称为 绘制四边形 (Draw Quads)
  • 合成线程将这些 Draw Quads 信息打包,通过一个命令发送给 GPU
  • GPU 接收到命令后,将所有图层、所有图块的位图信息按照正确的位置、顺序 composite(合成)起来,最终显示在屏幕上。

三、 相关面试题

Q1: 什么是重排 (Reflow) 和重绘 (Repaint)?

  • 重排 (Reflow):当元素的几何属性(如宽度、高度、边距、位置)发生变化,导致浏览器需要 重新计算布局树,这个过程就是重排。重排是一个成本非常高的操作,因为它会从 布局 (Layout) 阶段开始,完整地走一遍后续流程。
  • 重绘 (Repaint):当元素的非几何属性(如颜色、背景、visibility)发生变化,浏览器会跳过布局和分层阶段,直接 重新生成绘制指令 并执行后续的绘制流程。
  • 关系重排一定会触发重绘,但重绘不一定会触发重排。重排的性能开销远大于重绘。

Q2: 为什么 transform 的效率高?

传统的 leftmargin 动画会不断修改元素的几何属性,频繁触发 重排 (Reflow),这会消耗大量 渲染主线程 的计算资源,导致动画卡顿。

transform 的效率之所以高,是因为:

  1. 分层:对元素应用 transform 通常会将其提升为一个独立的合成层。
  2. 跳过重排和重绘transform 的变化不会触发渲染主线程的重排和重绘。
  3. 合成线程处理:动画的每一帧变化,都是由 合成线程 直接处理的。它只需要向 GPU 发送新的矩阵变换信息来移动图层,完全不占用主线程。

下面的代码可以清晰地展示这个区别:

html
<style>
  .ball { width: 50px; height: 50px; border-radius: 50%; background: red; position: absolute; }
  .ball-transform { top: 50px; left: 0; animation: move-transform 3s linear infinite; }
  .ball-left { top: 150px; left: 0; animation: move-left 3s linear infinite; }
  @keyframes move-transform { from { transform: translateX(0); } to { transform: translateX(300px); } }
  @keyframes move-left { from { left: 0; } to { left: 300px; } }
</style>
<body>
  <div class="ball ball-transform"></div>
  <div class="ball ball-left"></div>
  <button onclick="blockMainThread()">阻塞主线程5秒</button>
  <script>
    function blockMainThread() {
      const start = Date.now();
      while (Date.now() - start < 5000) {
        // 死循环,阻塞渲染主线程
      }
    }
  </script>
</body>

当你点击按钮时,会发现使用 left 动画的小球卡住不动了(因为主线程被阻塞,无法进行重排),而使用 transform 动画的小球依然流畅运动(因为它由未被阻塞的合成线程驱动)。