渲染流水线 — 从布局树到屏幕像素,浏览器的”印刷车间”
上一章我们拿到了布局树——每个可见元素的位置和尺寸都已确定。但布局树只是一张”设计图”,要让用户真正看到画面,还需要经过分层、绘制、光栅化、合成等一系列流水线操作。理解这条流水线,是掌握性能优化的前提。
📋 开篇自测:你已经知道多少?
- 你知道浏览器为什么要把页面分成多个图层吗?
- 重排(Reflow)、重绘(Repaint)、合成(Composite)有什么区别?哪个开销最大?
- 为什么
transform动画比top/left动画更流畅?
一、渲染流水线全景
从 HTML/CSS 到屏幕像素,完整的渲染流水线包含以下阶段:
完整渲染流水线
HTML ─→ DOM树 ─→ 样式计算 ─→ 布局 ─→ 分层 ─→ 绘制 ─→ 分块 ─→ 光栅化 ─→ 合成 ─→ 显示
│ │ │ │ │ │
└─── 第3章已讲 ──────────────────────┘ │ │ │ │
│ │ │ │
本章重点 ────────┴────────┴─────────┘
各阶段负责的线程:
┌────────────────────────────────────────┐ ┌──────────────────┐
│ 主线程 (Main Thread) │ │ 合成线程 │
│ │ │ (Compositor) │
│ DOM → 样式 → 布局 → 分层 → 绘制指令 │ │ 分块 → 调度光栅 │
│ │ │ → 合成帧 │
└────────────────────────────────────────┘ └────────┬─────────┘
│
┌────────▼─────────┐
│ 光栅化线程池 │
│ (Raster Threads) │
│ 图块 → 位图 │
└────────┬─────────┘
│
┌────────▼─────────┐
│ GPU 进程 │
│ 合成 → 屏幕显示 │
└──────────────────┘
二、分层(Layer):为什么要把页面拆成图层
2.1 分层的动机
想象一下 Photoshop 中的图层概念:把一幅复杂的画拆分成多个图层,每个图层可以独立编辑,互不影响。浏览器的分层逻辑也是一样的——当页面中的某个元素发生变化时,只需要重新处理它所在的图层,而不用重新绘制整个页面。
2.2 图层的产生条件
并不是每个 DOM 元素都会单独创建一个图层。浏览器会根据以下条件决定是否为元素创建独立的图层:
显式提升条件(开发者主动触发):
- 设置了
transform属性(包括translate、rotate、scale) - 设置了
opacity且值小于 1 - 设置了
will-change属性 - 使用了 CSS
filter - 使用了 3D 变换(
translate3d、perspective等) <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-change 或 transform: 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 绘制顺序
同一个图层内的元素按照以下规则确定绘制顺序:
- 背景和边框
- 负
z-index的子元素 - 普通流中的块级元素
- 浮动元素
- 普通流中的行内元素
z-index: 0/z-index: auto的定位元素- 正
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)
当元素的几何属性发生变化时,浏览器需要重新计算布局。这是开销最大的操作。
触发重排的常见操作:
- 修改元素的
width、height、padding、margin、border - 修改
top、left、right、bottom(定位元素) - 修改
font-size、line-height - 修改
display属性 - 添加或删除 DOM 元素
- 读取某些布局属性(
offsetWidth、scrollHeight、getComputedStyle等)
重排的渲染链路
样式计算 → 布局 → 分层 → 绘制 → 分块 → 光栅化 → 合成
↑
从这里开始重新执行(开销最大)
6.2 重绘(Repaint)
当元素的外观属性改变但几何属性不变时,只需要重新生成绘制指令,不需要重新布局。
触发重绘但不触发重排的操作:
- 修改
color、background-color - 修改
box-shadow、border-radius(不改变尺寸时) - 修改
visibility(hidden/visible 切换) - 修改
outline
重绘的渲染链路
绘制 → 分块 → 光栅化 → 合成
↑
跳过了布局(比重排快)
6.3 合成(Composite Only)
如果变化只涉及 transform 或 opacity,浏览器可以跳过布局和绘制,直接由合成线程处理。
仅触发合成的操作:
- 修改
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 面板观察图层
- 打开 Chrome DevTools
- 按
Ctrl + Shift + P,输入 “Show Layers” 打开 Layers 面板 - 浏览任意网页,观察页面被分成了多少个图层
- 点击每个图层,查看其创建原因(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
└── 代价: 额外内存占用
📝 结尾自测:检验你的学习成果
- 渲染流水线的完整阶段是什么?哪些阶段在主线程,哪些在合成线程?
- 浏览器为什么要将页面分成图层?哪些 CSS 属性会触发图层创建?
- 重排、重绘、合成分别从渲染流水线的哪个阶段开始执行?各自的触发条件是什么?
- 什么是强制同步布局和布局抖动?如何避免?
- 为什么
transform动画比top/left动画性能更好?背后的原理是什么?
下一章预告:渲染流水线让我们知道了页面是如何一步步变成像素的。但页面中运行的 JavaScript 代码又是如何被执行的?下一章我们将深入 V8 引擎的内部,看看 JavaScript 从源代码到机器码的完整旅程。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90