回流与重绘深度解析
一句话概括
回流(Reflow/Layout)是浏览器重新计算元素几何属性的过程,重绘(Repaint)是浏览器重新绘制元素外观的过程;回流必然触发重绘但重绘不一定触发回流,两者是前端性能优化的核心战场。
背景与意义
一个滚动事件的惨痛教训
2017 年,Twitter 的前端团队发现:当用户快速滚动信息流时,滚动帧率从 60fps 骤降至 15-20fps。
调试过程发现,罪魁祸首不是 JavaScript 计算量过大,而是一行滚动监听代码:
1
2
3
4
5
6
7
function onScroll() {
// 读取 offsetTop 触发强制回流
const offset = element.offsetTop;
// 写入 left 触发下一次回流
element.style.left = offset + 'px';
}
这个看似无害的”先读后写”模式,在滚动事件中每帧触发了数百次强制同步布局(Forced Synchronous Layout),导致渲染管线在主线程上持续崩溃,帧绘制时间从不到 10ms 飙升到 40-50ms。
更麻烦的是——团队在 Chrome DevTools Performance 面板中没有直接看到 “Layout” 事件,而是看到了大量 “Recalculate Style” 和 “Layout” 嵌套。这是因为强制同步布局被浏览器合并到了”帧周期”中,但它的耗时暴露无遗。
最终修复:将 offsetTop 缓存到变量中,改用 transform 代替 left 定位,帧率恢复至 58-60fps。
这个案例说明了核心问题:回流和重绘不是浏览器的”锅”——不恰当的代码触发了不必要的渲染处理。
回流 vs 重绘的性能代价
| 操作 | 代价 | 触发条件 |
|---|---|---|
| 仅重绘 | 中 | 修改 color, background-color, visibility, outline, border-color |
| 回流 + 重绘 | 高 | 修改 width, height, margin, padding, border-width, display, position, font-size, 添加/删除 DOM 元素、窗口 resize |
| 仅合成 | 低(GPU 处理) | 修改 transform, opacity, filter (部分) |
性能量化对比(1000 次操作):
| 操作 | 耗时 |
|---|---|
| 仅合成属性 | ~2ms |
| 仅重绘属性 | ~15ms |
| 回流属性 | ~60ms |
| 强制同步布局 | ~200ms+ |
概念与定义
渲染管线中的回流与重绘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DOM/CSS 变更
│
▼
样式重新计算 (Recalculate Style)
│
▼
┌─────────────────────────────────────────┐
│ 回流 (Reflow / Layout) │ ← 仅当几何属性变化时触发
│ 重新计算元素位置和大小 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 重绘 (Repaint / Paint) │ ← 始终执行
│ 重新生成绘制指令 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 合成 (Composite / Layer) │ ← 始终执行
│ 合并图层,GPU 绘制到屏幕 │
└─────────────────────────────────────────┘
关键术语
| 术语 | 英文 | 说明 |
|---|---|---|
| 回流 | Reflow / Layout | 重新计算元素的几何属性(位置、大小) |
| 重绘 | Repaint / Paint | 重新绘制元素的外观(颜色、背景、阴影等) |
| 强制同步布局 | Forced Synchronous Layout (FSL) | JS 在帧中间强制读取布局属性,触发立即布局 |
| 布局抖动 | Layout Thrashing | 循环中反复触发强制同步布局 |
| 脏位 | Dirty Bit | 标记元素需要重新计算的标记系统 |
| 合成 | Compositing | 将多个图层合并为最终的显示帧 |
最小示例
回流与重绘的验证工具
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<!-- layout-demo.html — 可视化验证回流和重绘 -->
<!DOCTYPE html>
<html>
<head><style>
.container { width: 800px; margin: 20px auto; }
.box { width: 100px; height: 100px; margin: 5px; float: left; transition: 0.3s; }
.controls { margin: 20px 0; }
.controls button { padding: 8px 16px; margin: 4px; cursor: pointer; }
.metrics { font-family: monospace; font-size: 14px; }
</style></head>
<body>
<div class="container">
<div id="grid"></div>
<div class="controls">
<button onclick="testLayout()">⚠️ 强制回流</button>
<button onclick="testPaint()">🎨 仅重绘</button>
<button onclick="testComposite()">🚀 仅合成</button>
<button onclick="testThrashing()">🔥 布局抖动</button>
<button onclick="testGood()">✅ 优化版本</button>
<button onclick="resetAll()">↺ 重置</button>
</div>
<div class="metrics">
<div>第一帧: <span id="metrics-first">-</span></div>
<div>最大帧: <span id="metrics-max">-</span></div>
<div>布局次数: <span id="metrics-layout">-</span></div>
<div>绘制次数: <span id="metrics-paint">-</span></div>
</div>
</div>
<script>
// 初始化 100 个栅格
const grid = document.getElementById('grid');
for (let i = 0; i < 100; i++) {
const box = document.createElement('div');
box.className = 'box';
box.style.backgroundColor = `hsl(${i * 3.6}, 70%, 60%)`;
box.id = `box-${i}`;
grid.appendChild(box);
}
// 测试 1: 强制回流 (修改 width — 几何属性)
function testLayout() {
const boxes = grid.children;
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
// 修改 width 触发回流
box.style.width = (50 + Math.random() * 100) + 'px';
}
}
// 测试 2: 仅重绘 (修改 background — 外观属性)
function testPaint() {
const boxes = grid.children;
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
// 修改背景色触发重绘(不触发回流)
box.style.backgroundColor = `hsl(${Math.random() * 360}, 70%, 60%)`;
}
}
// 测试 3: 仅合成 (修改 transform)
function testComposite() {
const boxes = grid.children;
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
// transform 仅触发合成
box.style.transform = `translateY(${Math.random() * 20}px)`;
}
}
// 测试 4: 布局抖动 (layout thrashing)
function testThrashing() {
const boxes = grid.children;
// 每个循环同时读写布局属性
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
// ⚠️ 每次循环: 读 offsetWidth (强制布局) + 写 width (触发下次布局)
box.style.width = (box.offsetWidth + 5) + 'px';
}
}
// 测试 5: 优化版本 — 先批量读,后批量写
function testGood() {
const boxes = grid.children;
// 1. 先批量读取所有需要的数据
const widths = [];
for (let i = 0; i < boxes.length; i++) {
widths.push(boxes[i].offsetWidth);
}
// 2. 在 RAF 中批量写入 (避免强制布局)
requestAnimationFrame(() => {
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = (widths[i] + 10) + 'px';
}
});
}
function resetAll() {
const boxes = grid.children;
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = '100px';
boxes[i].style.height = '100px';
boxes[i].style.transform = 'none';
boxes[i].style.backgroundColor = `hsl(${i * 3.6}, 70%, 60%)`;
}
}
// 使用 PerformanceObserver 监控布局
if (PerformanceObserver) {
try {
const observer = new PerformanceObserver((list) => {
let layoutCount = 0;
let paintCount = 0;
let maxDuration = 0;
let firstLayout = null;
for (const entry of list.getEntries()) {
if (entry.entryType === 'layout-shift') continue;
if (entry.name === 'Layout') {
layoutCount++;
if (!firstLayout) firstLayout = entry.startTime;
maxDuration = Math.max(maxDuration, entry.duration);
}
if (entry.name.startsWith('Paint')) {
paintCount++;
}
}
if (layoutCount > 0) {
document.getElementById('metrics-first').textContent =
`${firstLayout?.toFixed(1)}ms`;
document.getElementById('metrics-max').textContent =
`${maxDuration.toFixed(1)}ms`;
document.getElementById('metrics-layout').textContent = layoutCount;
document.getElementById('metrics-paint').textContent = paintCount;
}
});
observer.observe({ entryTypes: ['layout-shift', 'longtask'] });
} catch(e) {}
}
console.log('按以下步骤测试:');
console.log('1. 打开 DevTools → Performance → 录制');
console.log('2. 依次点击各测试按钮');
console.log('3. 观察紫色(Layout)和绿色(Paint)条的长度');
</script>
</body>
</html>
核心知识点拆解
1. 回流的触发条件
浏览器的回流不是随机的——它有精确的触发条件集。按影响范围分类:
全局回流(影响整个渲染树):
| 触发条件 | 原因 | 影响范围 |
|---|---|---|
| 窗口 resize | 视口尺寸变化 | 整个文档 |
| 字体加载/替换 | 字体度量变化影响所有文本 | 整个文档 |
| CSS 伪类激活 | :hover 等导致布局变化 | 视范围而定 |
| 样式表切换 | 添加/移除 <link> 或 <style> | 整个文档 |
局部回流(影响子树):
| 触发条件 | 原因 | 影响范围 |
|---|---|---|
| DOM 增删 | appendChild, removeChild | 受影响元素的子树 |
| 几何属性修改 | width/height/margin/padding/border | 局部及相邻元素 |
| 文本内容变化 | textContent/innerText | 局部 |
| 偏移量属性读取 | offsetTop/offsetLeft/offsetWidth/offsetHeight | 强制立即布局 |
| 滚动相关读取 | scrollTop/scrollWidth/clientHeight | 强制立即布局 |
| 计算样式读取 | getComputedStyle | 强制立即布局 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 触发强制同步布局的完整属性清单
const layoutTriggers = [
'offsetTop', 'offsetLeft', 'offsetWidth', 'offsetHeight',
'clientTop', 'clientLeft', 'clientWidth', 'clientHeight',
'scrollTop', 'scrollLeft', 'scrollWidth', 'scrollHeight',
'getComputedStyle', 'getBoundingClientRect',
'scrollIntoView', 'scrollBy', 'scrollTo',
'focus', 'checkVisibility',
'innerText', 'outerText',
];
// 这些属性的共同特点: 返回"当前"值
// 但如果布局队列中有待处理的修改,浏览器必须
// 立即执行一次布局来返回正确的值
2. 布局抖动的形成机制
Layout Thrashing 可能是前端性能中最常见也最隐蔽的问题。它的形成有三个要素:
1
2
3
4
5
6
7
要素 1: 循环操作
要素 2: 每次循环中同时读写布局属性
要素 3: 操作数量足够大 (> 100)
结果: 每次循环触发一次强制同步布局
→ N 次循环 = N 次布局
→ 在 O(N²) 时间内完成 O(N) 的工作
1
2
3
4
5
6
7
8
9
10
11
// 布局抖动的本质: 浏览器无法批量处理
// 浏览器期望:
// [读] [读] [读] ... [写] [写] [写] → 1 次布局
// 浏览器实际看到(逐行循环):
// [读→布局→写] [读→布局→写] [读→布局→写] → 100 次布局
// 为什么?
// 读 offsetWidth 时,浏览器必须返回"正确"的值
// 如果上一次循环的 width 变更还在队列中等待批处理
// 浏览器只能放弃批处理 → 立即计算一次布局
度量布局抖动:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function measureLayoutThrashing(fn, iterations = 100) {
const container = document.getElementById('test-container');
// 创建测试元素
for (let i = 0; i < iterations; i++) {
const div = document.createElement('div');
div.className = 'test-item';
container.appendChild(div);
}
// 使用 Performance API 度量
const start = performance.now();
fn();
const end = performance.now();
console.log(`${iterations} 次操作耗时: ${(end - start).toFixed(2)}ms`);
console.log(`平均每次: ${((end - start) / iterations).toFixed(3)}ms`);
// 清理
container.innerHTML = '';
}
3. 浏览器的”脏位系统”与布局队列
浏览器之所以需要”强制同步布局”,是因为它们内部使用异步批量处理来优化渲染:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 简化自 Blink: 布局脏位系统
class Node {
bool needsLayout_ = false; // 自身需要布局
bool needsStyleRecalc_ = false; // 需要重新计算样式
Node* parent_;
Node* next_; // 用于脏位传播
// 设置脏位时,不需要立即执行
void SetNeedsLayout(LayoutInvalidationReason reason) {
needsLayout_ = true;
// 向祖先传播脏位
if (parent_ && !parent_->needsLayout_) {
parent_->SetChildNeedsLayout(true);
}
}
};
class Document {
// 布局队列 — 存储所有标记为脏的节点
Vector<Node*> dirtyNodes_;
// 标记脏 — 只是入队
void ScheduleLayout(Node* node) {
dirtyNodes_.append(node);
// 注册帧回调(在下一帧开始时统一处理)
ScheduleAnimationFrame();
}
// 执行所有待处理的布局
void PerformLayout() {
for (auto node : dirtyNodes_) {
node->Layout();
node->ClearNeedsLayout();
}
dirtyNodes_.clear();
}
// 强制立即布局 — 当 JS 读取布局属性时调用
void ForceImmediateLayout() {
if (dirtyNodes_.size() > 0) {
PerformLayout();
}
}
};
关键洞察: 批量处理的”预算”被强制同步布局消耗——布局做了 100 次而不是 1 次。
4. 重绘的优化:图层提升
将元素提升为独立的合成层可以显著减少重绘开销。图层提升的核心思想是:改变只影响该层,不影响整个画布。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 三种图层提升的方式
function promoteToLayer(element) {
// 方式 1: will-change (推荐,语义清晰)
element.style.willChange = 'transform';
// 方式 2: 3D transform (hack,任何时候都有效)
element.style.transform = 'translateZ(0)';
// 方式 3: opacity < 1
element.style.opacity = '0.99';
// 注意: 不要滥用图层提升!
// 每个图层消耗额外的 GPU 内存
// 移动设备上 ~2-8MB/层
// 桌面设备上 ~20-50MB/层
}
// 什么时候应该提升图层?
// 1. 动画元素(transform/opacity 动画)
// 2. 固定定位元素
// 3. 滚动容器内部的元素
// 4. 频繁重绘的元素(视频、canvas)
Chrome DevTools 中查看图层:
- DevTools → Rendering → Layer borders
- 橙色边框 = 合成层
- DevTools → Performance → 录制 → Frames → Layers 面板
实战案例
案例:高性能表格的虚拟滚动
一个包含 10000 行数据的表格,如果直接渲染到 DOM 中,初始渲染耗时超过 3 秒——因为浏览器要为 10000 个 <tr> 执行布局计算。使用虚拟滚动(只渲染可见行)可以大幅降低回流成本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// virtual-table.mjs — 高性能虚拟滚动表格
class VirtualTable {
constructor(container, options = {}) {
this.container = container;
this.rowHeight = options.rowHeight || 40;
this.buffer = options.buffer || 5; // 上下额外多渲染的行数
this.totalRows = options.totalRows || 10000;
this.visibleRows = Math.ceil(
container.clientHeight / this.rowHeight
) + this.buffer * 2;
this.data = [];
this.scrollTop = 0;
this.renderedRange = { start: -1, end: -1 };
// 容器必须相对定位
container.style.position = 'relative';
container.style.overflow = 'auto';
// 伪造滚动条
this.spacer = document.createElement('div');
this.spacer.style.height = (this.totalRows * this.rowHeight) + 'px';
this.spacer.style.pointerEvents = 'none';
container.appendChild(this.spacer);
// 表格主体
this.tbody = document.createElement('tbody');
container.appendChild(this.tbody);
// 绑定滚动事件(使用 passive 标记避免阻塞)
container.addEventListener('scroll', () => this.onScroll(), { passive: true });
// 使用 IntersectionObserver 做懒加载
this.setupLazyLoad();
// 初始化
this.render();
}
onScroll() {
const newScrollTop = this.container.scrollTop;
// 仅在滚动超过一行高度时重新渲染
if (Math.abs(newScrollTop - this.scrollTop) >= this.rowHeight) {
this.scrollTop = newScrollTop;
this.render();
}
}
// 使用 requestAnimationFrame 节流
render() {
// 1. 计算当前可见范围
const start = Math.max(0,
Math.floor(this.scrollTop / this.rowHeight) - this.buffer
);
const end = Math.min(this.totalRows,
Math.ceil((this.scrollTop + this.container.clientHeight) / this.rowHeight)
+ this.buffer
);
// 2. 如果范围没变,跳过(避免不必要的 DOM 操作)
if (start === this.renderedRange.start && end === this.renderedRange.end) {
return;
}
this.renderedRange = { start, end };
// 3. 使用 DocumentFragment 批量构建 DOM
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const row = this.data[i];
const tr = document.createElement('tr');
// 使用 CSS transform 定位行(避免触发布局)
tr.style.transform = `translateY(${i * this.rowHeight}px)`;
tr.style.position = 'absolute';
tr.style.width = '100%';
tr.style.height = this.rowHeight + 'px';
tr.dataset.index = i;
// 列渲染
if (row) {
row.forEach(cell => {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
});
}
// 数据绑定(备用)
tr.__data__ = row;
fragment.appendChild(tr);
}
// 4. 一次性替换内容 —— 触发 1 次回流
this.tbody.innerHTML = '';
this.tbody.appendChild(fragment);
}
// 更新单行数据(只重新绘制该行,不触发整体回流)
updateRow(index, newData) {
const tr = this.tbody.querySelector(`[data-index="${index}"]`);
if (tr) {
const cells = tr.children;
newData.forEach((value, i) => {
if (cells[i]) {
cells[i].textContent = value; // 只触发重绘,不触发回流
}
});
this.data[index] = newData;
}
}
// 加载数据
loadData(data) {
// 直接在内存中操作,不触发布局
this.data = data;
this.render(); // 一次性渲染到 DOM
}
setupLazyLoad() {
// 对于真正的懒加载场景,滚动到底部时加载更多
const options = {
root: this.container,
rootMargin: '200px',
};
this.intersectionObserver = new IntersectionObserver(
(entries) => this.onIntersection(entries),
options
);
}
onIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 可见行内的图片等资源开始加载
const index = parseInt(entry.target.dataset.index);
this.lazyLoadRow(index);
}
});
}
lazyLoadRow(index) {
// 延迟加载行内资源(图片、iframe 等)
const row = this.data[index];
// ...
}
}
// 使用示例
const container = document.getElementById('table-container');
const table = new VirtualTable(container, {
rowHeight: 40,
buffer: 5,
totalRows: 10000
});
// 生成测试数据
const data = [];
for (let i = 0; i < 10000; i++) {
data.push([
`Row ${i}`,
`Value A-${i}`,
`Value B-${i}`,
new Date(2026, 0, i % 30).toLocaleDateString()
]);
}
table.loadData(data);
// 性能比较
console.log('性能对比:');
const start1 = performance.now();
// 传统方式(不虚拟滚动):
const badTbody = document.createElement('tbody');
for (let i = 0; i < 10000; i++) {
const tr = document.createElement('tr');
data[i].forEach(v => {
const td = document.createElement('td');
td.textContent = v;
tr.appendChild(td);
});
badTbody.appendChild(tr);
}
console.log(`传统方式 10000 行 DOM 构建: ${(performance.now() - start1).toFixed(1)}ms`);
const start2 = performance.now();
// 虚拟滚动方式:
table.loadData(data);
console.log(`虚拟滚动渲染时间: ${(performance.now() - start2).toFixed(1)}ms`);
使用 requestAnimationFrame 批量处理
结合 RAF,可以实现”无抖动”的动画流水线:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 批量布局调度器
class BatchLayoutScheduler {
constructor() {
this.readQueue = [];
this.writeQueue = [];
this.scheduled = false;
}
// 注册一个"读"操作
read(fn) {
this.readQueue.push(fn);
this.schedule();
}
// 注册一个"写"操作
write(fn) {
this.writeQueue.push(fn);
this.schedule();
}
schedule() {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
try {
// 1. 所有读操作(只读布局属性,不修改)
for (const fn of this.readQueue) {
fn();
}
this.readQueue = [];
// 2. 所有写操作(只修改样式属性,不读)
for (const fn of this.writeQueue) {
fn();
}
this.writeQueue = [];
} finally {
this.scheduled = false;
// 如果有新任务进来,下帧继续
if (this.readQueue.length > 0 || this.writeQueue.length > 0) {
this.schedule();
}
}
});
}
}
// 使用
const scheduler = new BatchLayoutScheduler();
// 动画循环
function animateElements(elements) {
// 批量读:收集所有元素的位置
const positions = [];
for (const el of elements) {
scheduler.read(() => {
positions.push(el.getBoundingClientRect());
});
}
// 批量写:更新所有元素的位置
scheduler.write(() => {
for (let i = 0; i < elements.length; i++) {
const rect = positions[i];
elements[i].style.transform =
`translate(${rect.left + 1}px, ${rect.top}px)`;
}
});
}
底层原理
Blink 的布局算法详析
Chromium 的渲染引擎 Blink 使用 LayoutNG(下一代布局引擎)作为默认布局系统。LayoutNG 使用基于片段(Fragment-based)的布局模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// 简化自 Blink: core/layout/ng/layout_ng_block_flow.cc
class NGLayoutAlgorithm {
// 布局算法入口
virtual const NGLayoutResult* Layout(
const NGLayoutInputNode& node,
const NGConstraintSpace& space,
const NGBreakToken* break_token) {
// 1. 计算可用空间(考虑父容器的边界约束)
NGBfcOffset bfc_offset = ComputeBfcOffset(node, space);
// 2. 子节点布局
NGLayoutInputNode child = node.FirstChild();
while (child) {
NGLayoutResult* child_result;
if (child->IsBlock()) {
// 块级子节点 → 块级格式化上下文
child_result = LayoutBlock(child, space, break_token);
} else if (child->IsInline()) {
// 行内子节点 → 行内格式化上下文
// 需要先做断行(Line Breaker)
child_result = LayoutInline(child, space);
}
// 3. 处理子布局结果
// - Out-of-flow 定位(absolute/fixed)
// - 浮动元素放置
// - 清除浮动 (clear: both)
ProcessChildResult(child, child_result);
// 4. 累积 BFC 偏移
bfc_offset.block_offset += child_result->BlockSize();
child = child.NextSibling();
}
// 5. 边界盒计算(包括 margin collapsing)
LayoutUnit block_size = ComputeBlockSize(node, bfc_offset);
// 6. 创建布局结果
return MakeGarbageCollected<NGLayoutResult>(
node, block_size, bfc_offset, break_token
);
}
// BFC 偏移计算(Block Formatting Context)
NGBfcOffset ComputeBfcOffset(
const NGLayoutInputNode& node,
const NGConstraintSpace& space) {
// BFC 偏移是块级格式化上下文中元素的位置
// 考虑:
// - Margin collapsing(相邻块元素的外边距折叠)
// - Position: relative 偏移
// - 浮动元素的环绕偏移
LayoutUnit bfc_block_offset = space.BfcOffset().block_offset;
// 外边距折叠规则:
// - 相邻块级元素之间的 margin 取较大值
// - 父子元素 margin 在特定条件下折叠
// - 没有 padding/border/inline content 阻断折叠
if (ShouldCollapseMarginTop(node)) {
bfc_block_offset -= std::min(
node->MarginTop(),
PreviousSibling()->MarginBottom()
);
}
return { bfc_block_offset, space.BfcOffset().inline_offset };
}
};
LayoutNG 相对传统布局的关键改进:
- 不可变布局结果:每个
NGLayoutResult是不可变的→缓存可以安全复用 - 缓存友好:如果约束空间和子节点不变,直接返回缓存的结果
- 并行化:在满足约束的子树中可以并行布局(虽然主线程仍是单线程调用)
强制同步布局的检测与防范
在 Blink 中,强制同步布局可以通过 Chrome Tracing 检测到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 简化的 Tracing 事件 — 记录强制同步布局调用的堆栈
void Document::UpdateStyleAndLayoutForNode(Node* node) {
// 如果在 Layout 阶段外调用 → 就是强制同步布局
if (!Lifecycle().GetState() >= DocumentLifecycle::kInPerformLayout) {
// 记录堆栈跟踪
WTF_LOG(Performance,
"Forced synchronous layout triggered by: %s",
CurrentStackTrace().c_str()
);
TRACE_EVENT0("blink,benchmark", "ForcedSynchronousLayout");
// 记录导致强制布局的 JS 堆栈
if (ScriptForbiddenScope::IsScriptAllowed()) {
AppendToPerformanceEntry("ForcedLayout");
}
}
UpdateStyleAndLayoutTree();
PerformLayout();
}
Chrome DevTools 中可视化的方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 在 DevTools 中锁定强制同步布局
// Rendering → Paint flashing → 强制布局会闪烁橙色
// 或通过性能 API 监测
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'Layout' && entry.cancelable) {
console.warn('强制同步布局检测:', entry);
console.warn('调用栈:', entry.sourceFrameNumber);
}
}
});
observer.observe({ entryTypes: ['layout-shift', 'largest-contentful-paint'] });
CSS GPU 加速层的工作原理
当元素被提升为合成层后,CSS 属性修改通过 GPU 合成完成,完全绕过主线程的布局和绘制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 简化自: cc/layers/picture_layer_impl.cc 的栅格化调度
class RasterWorkItemPool {
// 对合成层进行"分块栅格化"
// 默认分块 256x256 像素
// 只有可见(和即将可见)的分块被栅格化
void RasterizeTilesForLayer(LayerImpl* layer) {
// 计算视口中的图块
Region visible_tiles = layer->VisibleTiles();
for (auto tile : visible_tiles) {
if (tile->HasRasterized() && !tile->IsDirty()) {
// ✅ 图块已经栅格化且没有变化 → 使用缓存的纹理
continue;
}
// 栅格化图块(软件或通过 GPU)
if (UseGpuRasterization()) {
// GPU 栅格化 → 通过 OpenGL/Vulkan 着色器程序
// 将绘制指令转换为 GPU 纹理
GpuRasterizer::Rasterize(tile, layer->GetDisplayList());
} else {
// CPU 栅格化 → 使用 Skia 软件渲染
tile->bitmap()->eraseColor(SK_ColorTRANSPARENT);
layer->GetPaintRecorder()->Rasterize(tile->rect(), tile->bitmap());
}
}
}
};
为什么 transform/opacity 不触发布局? 布局计算的是元素的几何属性(位置、大小)。transform 是一个渲染属性——它改变的是”在哪儿画”,而不是”在哪儿摆”。浏览器在合成阶段对变换进行矩阵运算,只影响最终渲染,不影响布局阶段的结果。
高频面试题解析
面试题 1:回流和重绘的区别是什么?从浏览器的渲染管线角度解释。
答案要点:
从渲染管线的五个阶段分析:
| 阶段 | 触发条件 | 性能成本 |
|---|---|---|
| 回流 (Layout) | 几何属性变化:width, height, margin, padding, border, position, font-size, 添加/删除 DOM | O(n),影响子树甚至整个文档树 |
| 重绘 (Paint) | 外观属性变化:color, background-color, visibility, box-shadow, outline | O(n),基于渲染树的节点数 |
| 合成 (Composite) | transform, opacity 等合成属性变化 | O(1),每层独立处理 |
关键区别:
- 回流一定会触发重绘,重绘不一定触发回流
- 回流涉及元素间的几何关系计算(一个元素变化可能影响邻居),重绘只影响元素自身
- 合成完全在 GPU 上处理,独立于主线程
回答的深度加分项:
- LayoutNG 使用”不可变结果”的缓存机制,减少重复布局
- 浏览器使用”帧生命周期”统一处理,但强制同步布局会破坏这个模型
will-change: transform提示浏览器提前创建合成层
面试题 2:什么是布局抖动(Layout Thrashing)?如何诊断和修复?
答案要点:
定义:在渲染帧中间反复读写布局属性,导致浏览器不得不放弃批量处理,为每一次读取执行一次即时布局。
1
2
3
4
5
6
7
8
9
10
// 经典布局抖动
function resizeAll(items) {
// 每次循环:先读,后写
items.forEach(item => {
const width = item.offsetWidth; // 强制同步布局
item.style.width = width * 2 + 'px'; // 标记脏,但在当前帧不会立即执行
// 下一次循环:浏览器发现脏布局 → 再次强制布局
});
// 总布局次数 = items.length 而不是 1
}
诊断方法:
Chrome DevTools Performance 面板:录制后查找紫色 Layout 事件。如果 Layout 发生的频率非常高(> 每秒 10 次),或者 Layout 事件是 js 触发的(条目上有 “Forced reflow” 标记),就说明存在问题。
Rendering 面板 → Layout Shift Regions 或 Paint Flashing:闪烁区域指示强制布局发生的区域。
代码审查:在循环中寻找
offset*、client*、getComputedStyle等读取操作
修复策略:
1
基本原则:批量读 → 批量写 → 避免在两者间穿插任何布局读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 布局抖动版本
for (let i = 0; i < items.length; i++) {
const width = items[i].offsetWidth; // 读
items[i].style.width = width + 10 + 'px'; // 写
}
// ✅ 修复版本 — 先批量读
const widths = items.map(item => item.offsetWidth); // 所有读
// 再批量写(在 RAF 中)
requestAnimationFrame(() => {
items.forEach((item, i) => {
item.style.width = widths[i] + 10 + 'px'; // 所有写
});
});
// ✅ 或使用 FastDom 库
// fastdom.read(() => { ... })
// fastdom.write(() => { ... })
面试题 3:如何衡量 transform 和 left 在性能上的差距?写一个测试方案。
答案要点:
测试的核心是测量帧绘制时间的差异:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 性能对比测试
async function benchmarkPositionVsTransform() {
const results = { left: [], transform: [] };
// 创建测试元素
const boxLeft = document.createElement('div');
boxLeft.style.cssText = 'position:absolute;width:50px;height:50px;background:red;top:0;left:0';
document.body.appendChild(boxLeft);
const boxTransform = document.createElement('div');
boxTransform.style.cssText = 'position:absolute;width:50px;height:50px;background:blue;top:0;left:0;will-change:transform';
document.body.appendChild(boxTransform);
// 测试左移 (left) — 触发布局+绘制+合成
for (let i = 0; i < 100; i++) {
const start = performance.now();
boxLeft.style.left = i + 'px';
await new Promise(r => requestAnimationFrame(r));
results.left.push(performance.now() - start);
}
// 测试变换 (transform) — 仅合成
for (let i = 0; i < 100; i++) {
const start = performance.now();
boxTransform.style.transform = `translateX(${i}px)`;
await new Promise(r => requestAnimationFrame(r));
results.transform.push(performance.now() - start);
}
// 统计
const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
const max = arr => Math.max(...arr);
const p95 = arr => arr.sort((a,b) => a-b)[Math.floor(arr.length * 0.95)];
console.log('=== `left` vs `transform` 性能对比 ===');
console.log('left 方案:', {
avg: avg(results.left).toFixed(2) + 'ms',
max: max(results.left).toFixed(2) + 'ms',
p95: p95(results.left).toFixed(2) + 'ms'
});
console.log('transform 方案:', {
avg: avg(results.transform).toFixed(2) + 'ms',
max: max(results.transform).toFixed(2) + 'ms',
p95: p95(results.transform).toFixed(2) + 'ms'
});
const speedup = avg(results.left) / avg(results.transform);
console.log(`transform 是 left 的 ${speedup.toFixed(1)}x 更快`);
// 清理
boxLeft.remove();
boxTransform.remove();
}
// 运行测试(在实际上生产环境验证)
benchmarkPositionVsTransform();
预期结果(在 Chrome 上进行 100 次循环测试):
left: 平均帧时间 ~3-8ms(有 Layout + Paint + Composite)transform: 平均帧时间 ~0.5-1ms(仅 Composite)- 速度差异:3-10x
为什么差异这么大: left 修改 → 触发 Layout → 浏览器重新计算元素位置(影响可能传播到兄弟/父元素) → Paint → Composite transform 修改 → 不触发 Layout → 不触发 Paint → GPU 在合成阶段直接应用矩阵 → Composite only
总结与扩展
回流和重绘是浏览器渲染性能评估的核心坐标系。理解了它们,你就能:
- 优化动画:避免在动画循环中读取布局属性,使用
transform和opacity - 减少 DOM 操作:批量读写、使用
DocumentFragment、display: none隔离 - 使用图层:合理提升合成层,将频繁变化的元素隔离到独立层
- 避免布局抖动:先批量读,后批量写,在
requestAnimationFrame中统一执行
核心优化检查清单:
1
2
3
4
5
6
7
8
□ 动画属性检查: 使用了 transform/opacity 而非 left/top?
□ 循环检查: 循环中有没有同时读写布局属性?
□ 强制布局: 有没有大量使用 offset*, client*, getBoundingClientRect()?
□ 文档片段: 批量创建 DOM 时是否使用了 DocumentFragment?
□ will-change: 动画元素是否设置了 will-change 属性?
□ 图层验收: DevTools → Layers 面板检查图层数量?
□ 性能预算: 帧时间是否控制在 16ms 以内?
□ 滚动检查: 滚动监听是否标记为 passive?
底层规律:渲染管线的每一层都在用”空间换时间”。合成层(GPU 内存)换减少主线程负载。布局缓存(脏位系统)换减少布局计算。绘制指令缓存(Display List)换减少重绘。理解这个规律,就理解了前端性能优化的本质:在更少的约束下,做更少的工作。
进一步阅读:
- Chrome Internals:
Blink LayoutNG Design Doc - 关键渲染路径 (Critical Rendering Path)
- Web 工作者 + OffscreenCanvas 分离渲染线程
- 新一代渲染引擎:Servo(Mozilla)、LayoutNG+(Chromium)