跳到主要内容
预计阅读 37 分钟

渲染流水线 — 从布局树到屏幕像素,浏览器的”印刷车间”

上一章我们拿到了布局树——每个可见元素的位置和尺寸都已确定。但布局树只是一张”设计图”,要让用户真正看到画面,还需要经过分层、绘制、光栅化、合成等一系列流水线操作。理解这条流水线,是掌握性能优化的前提。

📋 开篇自测:你已经知道多少?

  1. 你知道浏览器为什么要把页面分成多个图层吗?
  2. 重排(Reflow)、重绘(Repaint)、合成(Composite)有什么区别?哪个开销最大?
  3. 为什么 transform 动画比 top/left 动画更流畅?

一、渲染流水线全景

从 HTML/CSS 到屏幕像素,完整的渲染流水线包含以下阶段:

完整渲染流水线

HTML ─→ DOM树 ─→ 样式计算 ─→ 布局 ─→ 分层 ─→ 绘制 ─→ 分块 ─→ 光栅化 ─→ 合成 ─→ 显示
 │                                    │        │        │        │         │
 └─── 第3章已讲 ──────────────────────┘        │        │        │         │
                                               │        │        │         │
                                        本章重点 ────────┴────────┴─────────┘

各阶段负责的线程:
┌────────────────────────────────────────┐  ┌──────────────────┐
│            主线程 (Main Thread)          │  │   合成线程         │
│                                        │  │  (Compositor)     │
│  DOM → 样式 → 布局 → 分层 → 绘制指令    │  │  分块 → 调度光栅    │
│                                        │  │  → 合成帧          │
└────────────────────────────────────────┘  └────────┬─────────┘

                                            ┌────────▼─────────┐
                                            │  光栅化线程池      │
                                            │  (Raster Threads) │
                                            │  图块 → 位图       │
                                            └────────┬─────────┘

                                            ┌────────▼─────────┐
                                            │  GPU 进程          │
                                            │  合成 → 屏幕显示   │
                                            └──────────────────┘

二、分层(Layer):为什么要把页面拆成图层

2.1 分层的动机

想象一下 Photoshop 中的图层概念:把一幅复杂的画拆分成多个图层,每个图层可以独立编辑,互不影响。浏览器的分层逻辑也是一样的——当页面中的某个元素发生变化时,只需要重新处理它所在的图层,而不用重新绘制整个页面。

2.2 图层的产生条件

并不是每个 DOM 元素都会单独创建一个图层。浏览器会根据以下条件决定是否为元素创建独立的图层:

显式提升条件(开发者主动触发)

  • 设置了 transform 属性(包括 translaterotatescale
  • 设置了 opacity 且值小于 1
  • 设置了 will-change 属性
  • 使用了 CSS filter
  • 使用了 3D 变换(translate3dperspective 等)
  • <video><canvas><iframe> 等特殊元素

隐式提升条件(浏览器自动触发)

  • 元素覆盖在已经提升为图层的元素之上(层叠上下文重叠)
  • 设置了 position: fixed
  • 存在可滚动溢出容器(overflow: scroll/auto)——可滚动容器会被提升为合成层以优化滚动性能。注意 overflow: hidden 会创建裁剪上下文,但本身不一定触发合成层创建
图层分层示意

页面 DOM 结构:
<body>
  <div class="header" style="position: fixed">Header</div>
  <div class="content">
    <div class="animated" style="transform: translateX(0)">动画元素</div>
    <p>普通文本</p>
  </div>
  <canvas id="game">Canvas</canvas>
</body>

生成的图层:
┌─────────────────────────────────────────┐
│ 图层1 (根图层): body + .content + p     │  ← 普通元素合并
├─────────────────────────────────────────┤
│ 图层2: .header (position: fixed)       │  ← 固定定位,独立图层
├─────────────────────────────────────────┤
│ 图层3: .animated (transform)           │  ← transform,独立图层
├─────────────────────────────────────────┤
│ 图层4: canvas#game                     │  ← canvas,独立图层
└─────────────────────────────────────────┘

2.3 图层过多的问题

创建独立图层有助于优化动画性能,但图层不是越多越好。每个图层都需要占用额外的内存来存储其位图数据。如果滥用 will-changetransform: translateZ(0) 来强制创建图层,可能会适得其反——内存暴增,反而导致性能下降。

图层内存占用估算

一个 1000x500 像素的图层
= 1000 x 500 x 4 bytes (RGBA)
= 2,000,000 bytes
= ~2 MB

如果页面有 50 个这样的图层
= 50 x 2 MB = 100 MB 仅用于图层位图!

🤔 想一想 在开发中你使用过 will-change 吗?它的正确使用姿势是什么?应该在什么时候添加,什么时候移除?


三、绘制(Paint):生成绘制指令

3.1 绘制指令列表

分层完成后,主线程会为每个图层生成一份绘制指令列表(Paint Records)。绘制指令类似画布 API 的操作记录:

绘制指令列表示例

图层: .card
绘制指令:
1. drawRect(x:0, y:0, w:300, h:200, color:#fff)    // 绘制白色背景
2. drawRect(x:0, y:0, w:300, h:200, borderRadius:8, // 绘制圆角边框
            border:1px solid #e0e0e0)
3. drawText(x:16, y:32, text:"标题", font:18px bold, // 绘制标题文字
            color:#333)
4. drawLine(x:16, y:50, x2:284, y2:50, color:#eee)  // 绘制分隔线
5. drawImage(x:16, y:60, w:268, h:120, src:photo)   // 绘制图片

注意:绘制指令列表只是”操作记录”,此时并没有产生真正的像素数据。真正的像素生成发生在下一步——光栅化。

3.2 绘制顺序

同一个图层内的元素按照以下规则确定绘制顺序:

  1. 背景和边框
  2. z-index 的子元素
  3. 普通流中的块级元素
  4. 浮动元素
  5. 普通流中的行内元素
  6. z-index: 0 / z-index: auto 的定位元素
  7. z-index 的子元素

这个绘制顺序就是 CSS 的**层叠上下文(Stacking Context)**规则。理解这个规则,能帮你解决很多”为什么这个元素被遮住了”的疑惑。


四、分块与光栅化(Tiling & Rasterization)

4.1 为什么要分块

一个网页可能非常长——几万像素的高度并不罕见。如果一次性把整个页面光栅化为位图,不仅耗时极长,还会浪费大量内存(大部分内容用户看不到)。

所以浏览器的策略是:把每个图层切割成多个小块(Tile),优先光栅化视口内的图块

图层分块示意

┌─── 视口 (用户可见区域) ───┐
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐     │    ← 优先光栅化
│  │T1│ │T2│ │T3│ │T4│     │
│  └──┘ └──┘ └──┘ └──┘     │
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐     │    ← 优先光栅化
│  │T5│ │T6│ │T7│ │T8│     │
│  └──┘ └──┘ └──┘ └──┘     │
└───────────────────────────┘
   ┌──┐ ┌──┐ ┌──┐ ┌──┐          ← 稍后光栅化
   │T9│ │T10││T11││T12│
   └──┘ └──┘ └──┘ └──┘
   ┌──┐ ┌──┐ ┌──┐ ┌──┐          ← 延迟或不光栅化
   │T13││T14││T15││T16│
   └──┘ └──┘ └──┘ └──┘

每个 Tile 通常为 256x256 或 512x512 像素

4.2 光栅化

光栅化是把绘制指令转化为实际像素数据(位图)的过程。这个过程发生在光栅化线程池中,并且通常会利用 GPU 来加速。

光栅化流程

合成线程                      光栅化线程池              GPU进程
   │                              │                    │
   │── 图块T1的绘制指令 ─────────→│                    │
   │── 图块T2的绘制指令 ─────────→│ (多线程并行)        │
   │── 图块T3的绘制指令 ─────────→│                    │
   │                              │                    │
   │                              │── GPU光栅化命令 ───→│
   │                              │                    │ 生成位图
   │                              │←── 位图(纹理) ─────│
   │                              │                    │
   │←── 图块T1位图 ──────────────│                    │
   │←── 图块T2位图 ──────────────│                    │
   │←── 图块T3位图 ──────────────│                    │

4.3 GPU 加速光栅化

现代浏览器的光栅化过程会尽可能地使用 GPU。GPU 处理像素运算的效率远高于 CPU——GPU 天生就是为大规模并行运算设计的。

Chrome 使用了一个叫做 Skia 的图形库来执行实际的光栅化操作。Skia 既可以在 CPU 上运行(软件光栅化),也可以在 GPU 上运行(硬件加速光栅化)。


五、合成(Composite):拼出最终画面

5.1 合成帧

当所有可见图块都完成光栅化后,合成线程会收集这些位图的位置信息,生成一个合成帧(Compositor Frame)。合成帧本质上是一份”把这些位图放到屏幕上这些位置”的指令。

合成过程

合成线程:
┌──────────────────────────────────────────────┐
│  收集所有图层的位图和变换信息                    │
│                                              │
│  图层1 (根): 位图 → 位置(0,0), 无变换          │
│  图层2 (header): 位图 → 位置(0,0), fixed       │
│  图层3 (animated): 位图 → 位置(100,200),        │
│                    transform: translateX(50px)│
│  图层4 (canvas): 位图 → 位置(0,500)            │
│                                              │
│  输出: 合成帧 (Draw Quad 列表)                 │
└──────────────────────┬───────────────────────┘

                       ▼ 通过IPC发送给GPU进程
              ┌────────────────┐
              │   GPU 进程      │
              │  执行最终合成    │
              │  输出到屏幕缓冲 │
              └────────┬───────┘


                   屏幕显示

5.2 合成的核心优势

合成操作的关键优势在于:它完全不需要主线程参与。即使主线程正在执行一段耗时的 JavaScript,合成线程仍然可以独立完成合成操作。

这就是为什么以下操作可以实现 60fps 的流畅动画:

  • transform 变换(平移、旋转、缩放)
  • opacity 变化
  • 滚动(页面滚动本质上是合成线程在移动图层位置)
合成动画 vs 主线程动画

使用 transform (合成线程处理):
主线程:    [空闲,可以处理其他任务]
合成线程:  [更新transform] [更新transform] [更新transform] ...
帧率:      60fps ✓ (流畅)

使用 top/left (需要主线程参与):
主线程:    [布局][绘制][布局][绘制][布局][忙于JS...........][布局][绘制]
合成线程:  [等待]  [合成]  [等待]  [合成]      [等待]          [合成]
帧率:      可能掉帧 ✗ (卡顿)

六、重排、重绘与合成:性能的三重门

这是本章最核心的内容。当页面发生变化时,浏览器需要重新执行渲染流水线的部分步骤。根据变化的性质,可能触发三种不同级别的更新:

6.1 重排(Reflow / Layout)

当元素的几何属性发生变化时,浏览器需要重新计算布局。这是开销最大的操作。

触发重排的常见操作

  • 修改元素的 widthheightpaddingmarginborder
  • 修改 topleftrightbottom(定位元素)
  • 修改 font-sizeline-height
  • 修改 display 属性
  • 添加或删除 DOM 元素
  • 读取某些布局属性(offsetWidthscrollHeightgetComputedStyle 等)
重排的渲染链路

样式计算 → 布局 → 分层 → 绘制 → 分块 → 光栅化 → 合成

              从这里开始重新执行(开销最大)

6.2 重绘(Repaint)

当元素的外观属性改变但几何属性不变时,只需要重新生成绘制指令,不需要重新布局。

触发重绘但不触发重排的操作

  • 修改 colorbackground-color
  • 修改 box-shadowborder-radius(不改变尺寸时)
  • 修改 visibility(hidden/visible 切换)
  • 修改 outline
重绘的渲染链路

            绘制 → 分块 → 光栅化 → 合成

          跳过了布局(比重排快)

6.3 合成(Composite Only)

如果变化只涉及 transformopacity,浏览器可以跳过布局和绘制,直接由合成线程处理。

仅触发合成的操作

  • 修改 transform
  • 修改 opacity
仅合成的渲染链路

                              合成

                  跳过了布局和绘制(最快)

6.4 三种更新的性能对比

性能开销对比

重排 (Reflow):
样式 → 布局 → 分层 → 绘制 → 光栅化 → 合成
████   ████   ████   ████    ████    ████   开销: ★★★★★

重绘 (Repaint):
                     绘制 → 光栅化 → 合成
                     ████    ████    ████   开销: ★★★☆☆

合成 (Composite):
                                    合成
                                    ████   开销: ★☆☆☆☆

🤔 想一想 假设你要实现一个元素从左到右移动 300px 的动画,分别用 left 属性和 transform: translateX() 实现,在 Chrome Performance 面板中观察,两者的帧率和 CPU 占用有什么差异?


七、强制同步布局与布局抖动

7.1 强制同步布局(Forced Synchronous Layout)

正常情况下,布局计算会在当前帧的”适当时机”统一进行。但如果你在 JavaScript 中先修改了 DOM 的几何属性,然后立即读取布局信息,浏览器就不得不立即执行布局计算来返回准确的值。这叫做强制同步布局

// 强制同步布局示例
const el = document.querySelector('.box');

el.style.width = '200px';   // 标记DOM为"脏" (需要重新布局)
const height = el.offsetHeight; // 浏览器被迫立即执行布局!

7.2 布局抖动(Layout Thrashing)

如果在循环中反复触发强制同步布局,性能损耗会被成倍放大——这就是布局抖动

// 布局抖动:性能杀手!
const items = document.querySelectorAll('.item');

for (let i = 0; i < items.length; i++) {
  // 每次迭代都会触发一次强制同步布局
  items[i].style.width = items[i].offsetWidth + 10 + 'px';
  //                      ↑ 读取布局信息,触发强制布局
}

// 优化后:读写分离
const widths = [];
// 先批量读取
for (let i = 0; i < items.length; i++) {
  widths.push(items[i].offsetWidth);
}
// 再批量写入
for (let i = 0; i < items.length; i++) {
  items[i].style.width = widths[i] + 10 + 'px';
}
布局抖动的时间线

抖动模式 (每帧内多次布局):
[JS: 写] [强制布局] [JS: 写] [强制布局] [JS: 写] [强制布局] ...
  1ms      5ms        1ms      5ms        1ms      5ms
  总耗时: 18ms (超过16.67ms, 掉帧!)

优化模式 (每帧只布局一次):
[JS: 批量读] [JS: 批量写] [布局]
    2ms           2ms       5ms
  总耗时: 9ms (在16.67ms内, 流畅!)

八、GPU 加速的原理与实践

8.1 什么是 GPU 加速

当一个元素被提升为独立的合成层(Compositing Layer)后,它的变换操作可以由 GPU 直接处理,不需要主线程参与。这就是所谓的”GPU 加速”或”硬件加速”。

GPU 加速的工作方式

无GPU加速 (主线程处理):
┌──────────────────────────────┐
│ 主线程                        │
│ 修改left → 重新布局 → 重新绘制│
│ → 光栅化 → 合成              │
│ 耗时: ~16ms                  │
└──────────────────────────────┘

有GPU加速 (合成线程+GPU处理):
┌──────────────┐  ┌──────────────────┐
│ 主线程        │  │ 合成线程+GPU       │
│ (空闲)       │  │ 修改transform矩阵 │
│              │  │ → 直接合成         │
│              │  │ 耗时: ~1ms        │
└──────────────┘  └──────────────────┘

8.2 触发 GPU 加速的方法

/* 方法1: 使用 will-change(推荐) */
.animated-element {
  will-change: transform;  /* 告知浏览器即将变化 */
}

/* 方法2: 使用 3D 变换(hack 方式) */
.gpu-layer {
  transform: translateZ(0);  /* 强制创建合成层 */
}

/* 方法3: 使用 transform 或 opacity 动画 */
.fade-in {
  transition: opacity 0.3s ease;
}

8.3 GPU 加速的注意事项

做法效果风险
为动画元素设置 will-change提前创建合成层,动画更流畅内存增加
动画结束后移除 will-change释放 GPU 内存下次动画需重新创建层
对所有元素设置 will-change无效果,反而有害内存暴增,GPU 过载
使用 transform 做动画跳过布局和绘制无明显风险
使用 top/left 做动画每帧都要重排性能差,可能掉帧

九、实际场景:一帧的生命周期

浏览器以 60fps 的目标刷新屏幕,意味着每一帧只有 16.67ms 的时间预算。让我们看看一帧内各步骤的时间分配:

一帧的生命周期 (16.67ms 预算)

│← ────────────── 16.67ms ─────────────── →│
│                                           │
│ Input │ JS │ Style │ Layout │ Paint │ Comp│
│ 事件   │执行│ 计算  │ 布局   │ 绘制  │ 合成│
│ ~1ms  │~6ms│ ~1ms  │ ~2ms  │ ~2ms │~1ms │
│                                           │
│ 剩余: ~3.67ms (留给浏览器做其他工作)        │
│                                           │

如果 JS 执行超过 10ms:
│ Input │    JavaScript 执行    │Style│Layout│Paint│
│ ~1ms  │        12ms           │ 1ms │ 2ms │ 2ms │
│                                                   │
│← ────────── 这一帧超过了 16.67ms! → 掉帧! ────→│

requestAnimationFrame 的最佳时机:
│ rAF │ JS │ Style │ Layout │ Paint │ Comp│ Idle │
│  ↑                                             │
│  在每帧开始时调用,确保有完整的时间预算          │

十、动手实验:观察渲染流水线

10.1 实验一:使用 Layers 面板观察图层

  1. 打开 Chrome DevTools
  2. Ctrl + Shift + P,输入 “Show Layers” 打开 Layers 面板
  3. 浏览任意网页,观察页面被分成了多少个图层
  4. 点击每个图层,查看其创建原因(Compositing Reasons)

10.2 实验二:对比重排 vs 合成动画

<!DOCTYPE html>
<html>
<head>
<style>
  .box { width: 100px; height: 100px; background: blue; position: absolute; }
  .reflow { animation: moveReflow 2s infinite; }
  .composite { animation: moveComposite 2s infinite; }

  @keyframes moveReflow { from { left: 0; } to { left: 300px; } }
  @keyframes moveComposite { from { transform: translateX(0); } to { transform: translateX(300px); } }
</style>
</head>
<body>
  <div class="box reflow">left</div>
  <div class="box composite" style="top:120px">transform</div>
</body>
</html>

在 Performance 面板中录制,对比两个动画的 CPU 占用和帧率差异。

10.3 实验三:触发布局抖动

// 在控制台执行,然后观察 Performance 面板
const div = document.createElement('div');
div.style.cssText = 'width:100px;height:100px;background:red;position:absolute;';
document.body.appendChild(div);

// 布局抖动
console.time('thrashing');
for (let i = 0; i < 1000; i++) {
  div.style.width = div.offsetWidth + 1 + 'px';
}
console.timeEnd('thrashing');

// 优化后
console.time('optimized');
let w = div.offsetWidth;
for (let i = 0; i < 1000; i++) {
  w += 1;
}
div.style.width = w + 'px';
console.timeEnd('optimized');

十一、本章知识脉络总结

渲染流水线知识地图

渲染流水线
├── 分层 (Layer)
│   ├── 目的: 局部更新,减少重绘范围
│   ├── 触发条件: transform/opacity/will-change/fixed/canvas等
│   └── 注意: 图层过多会增加内存开销

├── 绘制 (Paint)
│   ├── 输出: 绘制指令列表 (不是像素)
│   └── 绘制顺序: 层叠上下文规则

├── 分块 + 光栅化 (Tiling + Raster)
│   ├── 分块: 将图层切割为小图块
│   ├── 优先级: 视口内图块优先
│   └── GPU加速: 使用GPU执行光栅化

├── 合成 (Composite)
│   ├── 合成帧: 收集所有图层位图的位置信息
│   ├── 独立于主线程: 不受JS阻塞影响
│   └── GPU绘制: 最终输出到屏幕

├── 三种更新级别
│   ├── 重排: 几何变化 → 从布局开始 (最慢)
│   ├── 重绘: 外观变化 → 从绘制开始 (中等)
│   └── 合成: transform/opacity → 仅合成 (最快)

├── 性能陷阱
│   ├── 强制同步布局: 写后立即读
│   └── 布局抖动: 循环中反复读写

└── GPU加速
    ├── 原理: 合成层由GPU直接处理
    ├── 触发: will-change / transform / opacity
    └── 代价: 额外内存占用

📝 结尾自测:检验你的学习成果

  1. 渲染流水线的完整阶段是什么?哪些阶段在主线程,哪些在合成线程?
  2. 浏览器为什么要将页面分成图层?哪些 CSS 属性会触发图层创建?
  3. 重排、重绘、合成分别从渲染流水线的哪个阶段开始执行?各自的触发条件是什么?
  4. 什么是强制同步布局和布局抖动?如何避免?
  5. 为什么 transform 动画比 top/left 动画性能更好?背后的原理是什么?

下一章预告:渲染流水线让我们知道了页面是如何一步步变成像素的。但页面中运行的 JavaScript 代码又是如何被执行的?下一章我们将深入 V8 引擎的内部,看看 JavaScript 从源代码到机器码的完整旅程。

购买课程解锁全部内容

前端进阶第一课:11 章掌握浏览器核心

¥29.90