返回文章列表

浏览器是怎么把 HTML 变成屏幕上的像素的?一文讲透渲染流水线

当你打开一个网页,从输入 URL 到看到页面,浏览器内部经历了一条完整的"渲染流水线"。这条流水线被拆成了多个阶段,每个阶段有明确的输入和输出。

用一个公式概括:render(HTML) → pixels

下面按阶段逐步拆解。


阶段一:解析(Parse)—— 产出 DOM 树和 CSSOM 树

网络线程拿到 HTML 字符串后,往渲染主线程的消息队列里丢一个"渲染任务"。主线程开始解析。

DOM 树

HTML 是一串字符串,主线程把它解析成一棵树。树的根节点是 document(对应 <html> 标签),然后根据标签的嵌套关系展开父子节点。这棵树叫 DOM 树

CSSOM 树

CSS 的解析产物叫 CSSOM 树,是一个包含了所有 CSS 选择器及其对应样式属性的结构化对象。

CSSOM 有两个重要特性:

A. 层叠(Cascading)

"层叠样式表"的"层叠"就体现在这里。多个来源的样式(浏览器默认样式、外部样式表、内联样式、用户自定义样式)会按优先级规则合并,最终计算出每个节点的样式。

B. 渲染阻塞(Render-Blocking)

在 CSSOM 构建完成之前,浏览器不会开始渲染页面。换个角度说:CSS 会阻塞渲染。

但 CSS 的下载并不阻塞解析。过程分三步:

  1. 预解析:浏览器有一个"预解析线程"(Preload Scanner),它会快速扫描 HTML,找到 <img><link><script> 等外部资源的 URL,提前通知网络线程去下载
  2. 网络下载:网络线程异步下载 CSS 文件
  3. 构建 CSSOM:下载完成后,主线程构建 CSSOM——只有这一步是阻塞渲染的

为什么 CSSOM 必须构建完才能渲染?因为页面绘制需要同时知道 DOM 结构和每个节点的样式。而且 JS 可能通过 getComputedStyle() 查询样式信息,所以 CSSOM 没准备好也会阻塞 JS 执行。


阶段二:样式计算(Style Recalculation)—— 产出 Computed Style

DOM 树和 CSSOM 树都构建完成后,浏览器把它们合并——这个过程叫 样式计算

它会遍历 DOM 树的每一个节点,根据 CSS 选择器权重、层叠规则、继承规则,为每个节点确定最终样式。输出是一个"计算后的样式"(Computed Style)。

这个过程中有几个关键的转化:

  • red 会变成 rgb(255, 0, 0)(预设值 → 绝对值)
  • em 会变成 px(相对单位 → 绝对单位)
  • 未显式设置的属性从父节点继承计算值

阶段三:布局(Layout)—— 产出 Layout 树

有了每个节点的样式,下一步是计算每个节点在屏幕上的位置和大小。这一步依次遍历 DOM 树节点,计算几何信息,产出 Layout 树(以前叫 Render Tree)。

Layout 树和 DOM 树不一定一致:

  • display: none 的节点在 DOM 树里有,但在 Layout 树里没有(几何信息为零)
  • 伪元素::before::after)在 DOM 树里不存在,但在 Layout 树里会生成对应节点
  • 匿名块盒:如果一个 <div> 里既有块级元素又有纯文本,浏览器会自动创建一个匿名块盒把文本包起来,保持布局模型一致
  • 匿名行盒:同理,纯文本没有被行内标签包裹时会被视为匿名行盒

回流(Reflow)

计算几何信息的过程叫回流,也叫重排。任何导致几何信息变化的操作(改宽高、改位置、增删 DOM 节点)都会触发回流。

浏览器的优化策略是:不会你改一次它就算一次。它会批处理——等一段 JS 代码跑完后,统一做一次回流计算。所以回流是"异步"完成的。

但有一个例外:如果 JS 代码中间需要读取布局属性(比如 offsetHeightgetBoundingClientRect()),浏览器会立刻同步回流,否则 JS 读到的是旧数据。


阶段四:分层(Layering)—— 产出 Layer 树

主线程拿到 Layout 树后,会根据一套策略把页面"分层"。把页面拆成多个层的好处是:某一层发生变化时,只需要重绘这一层,其他层不动,大幅提升渲染效率。

触发自动分层的情况:

  • 元素有层叠上下文的属性(z-index + 定位)
  • 硬件加速元素:<video><canvas><iframe>
  • overflow: scroll 的容器
  • 设置了 will-change 属性

will-change:给浏览器的"预警"

will-change 告诉浏览器:"这个元素即将发生变化"。浏览器收到这个信号后,会提前为它创建一个独立的合成层。这样当变化发生时,浏览器不需要重新计算——这一层已经准备好了。

不要滥用。每个合成层都占用 GPU 内存,给所有元素都加 will-change 会让内存爆炸。

层叠上下文:z-index 没你想的那么万能

层叠上下文是 HTML 元素在三维空间(Z 轴)上的分层概念。同一个层叠上下文内的元素按 z-index 排序;但不同层叠上下文的元素之间,z-index 不跨上下文比较。

<div class="relative z-1"> Box A
  <div class="absolute z-999">999</div>
</div>
<div class="relative z-2"> Box B </div>

999 的 z-index 再高,也遮挡不了 Box B——因为它们的层叠上下文不同。999 的比较对象是 Box A 内部的兄弟元素,不涉及 Box B

常见的创建层叠上下文的条件(很容易忽略的):

  • <html> 根元素本身就带一个
  • position: absolute/relativez-index 不为 auto
  • opacity < 1
  • transform 不为 none(包括 scale(1) 这种"看起来没变"的值)
  • filter 不为 none
  • Flex/Grid 容器的子元素,且 z-index 不为 auto

注意:z-index 只在 positionstatic 的元素或 Flex/Grid 子元素上才起作用。


阶段五:绘制(Paint)—— 产出绘制指令集

主线程为每个层单独生成一套绘制指令集,描述这一层该怎么画。指令集的格式类似于 Canvas API 的调用序列:"先画一个红色矩形,再画一段文字……"

为什么不直接画?因为后面的工作更适合交给合成器线程和 GPU 处理。主线程只负责"描述怎么画",然后通过 Commit 把指令集交给合成线程。主线程至此收工。

重绘(Repaint)

如果修改的样式不涉及几何信息(比如改颜色、改背景),浏览器可能跳过前面的 Layout 和 Layering,直接到绘制阶段。这就是重绘

补充一个关系:回流一定会触发重绘(几何变了,外观肯定要重画),但重绘不一定触发回流。


阶段六:分块(Tiling)

从这一步开始,工作从主线程移交到合成线程

如果一次性把整个页面(尤其是长页面)绘制成一张巨型位图,内存扛不住。所以合成线程会把每个图层切成很多小块(tiles),然后维护一个栅格化线程池,把这些瓦片任务分发给多个线程并发处理。


阶段七:光栅化(Raster)—— 产出位图

把每个瓦片变成位图(像素信息)。靠近视口的瓦片优先处理(你正在看的区域要先画出来)。这个过程使用 GPU 硬件加速。


阶段八:画(Draw)—— 最终呈现

合成线程拿到所有层、所有块的位图后,生成一个个"四边形指引"(Draw Quad),告诉 GPU:这张位图画在屏幕的哪个位置、有没有旋转、缩放、变形。

然后合成线程把这些 Quad 提交给 GPU 进程,GPU 完成最终渲染——你看到了屏幕上的像素。

为什么 transform 动画效率高?

变形(旋转、缩放、平移)是在合成线程里处理的,与主线程完全无关。这意味着:

  • 不用重新 Layout
  • 不用重新 Paint
  • 主线程不参与

所以用 transform 做动画比用 left/top 做动画流畅得多——后者会触发回流,全程要重走主线程。


一条完整流水线回顾

  主线程:
  解析  →  Layout树  →  Layer树  →  绘制指令集
  (DOM+CSSOM)  (算位置)   (分层级)   (描述怎么画)
                        ↕ Commit
  合成线程:
  分块  →  光栅化  →  合成
  (切瓦片)  (转位图)  (出Quad)

  GPU进程:
  Quad  →  GPU  →  屏幕像素

理解这条流水线,你就能解释为什么某些 CSS 操作"贵"(触发 Layout)、某些"便宜"(只到 Composite),从而写出更高性能的前端代码。