# 重排与重绘优化
彻底理解 Reflow 和 Repaint 的区别、触发条件、性能影响,以及减少重排重绘的实战策略
前置知识:第01章(浏览器渲染管线)+ 第02章(合成层)
阅读指南(初学者必看)
为什么你需要学习重排与重绘?
你已经知道了浏览器渲染管线的 8 个阶段。但知道理论还不够,你需要知道:
- 哪些操作会触发重排?哪些只触发重绘?
- 为什么强制同步布局(Layout Thrashing)会让页面卡死?
- 如何批量优化 DOM 操作,减少渲染开销?
学完本章,你能回答:
- 修改
width和修改color的性能差异是什么? - 为什么先读取布局属性再写入会导致 100 次重排?
- 如何用
transform完全避免重排?
一、什么是重排(Reflow)?
重排(Reflow),也叫布局(Layout),是浏览器重新计算页面元素位置和尺寸的过程。
触发重排的操作
| 类别 | 属性 |
|---|---|
| 尺寸 | width, height, min-width, max-width |
| 位置 | top, left, right, bottom, margin, padding |
| 布局 | display, position, float, clear, overflow |
| 文字 | font-size, line-height, text-align |
| 其他 | border-width, outline-width |
// 这些操作触发重排
element.style.width = '100px';
element.style.margin = '10px';
document.body.appendChild(newElement);
const height = element.offsetHeight; // 强制同步布局
重排的成本
重排是渲染管线中最昂贵的操作之一:
- 关联计算:影响子元素、兄弟元素、父元素
- 同步执行:阻塞主线程
- 一次重排可能需要 10-50ms(60fps要求每帧16.67ms)
二、什么是重绘(Repaint)?
重绘(Repaint) 是浏览器重新绘制元素外观的过程。尺寸和位置不变,只改变外观。
触发重绘的操作
| 类别 | 属性 |
|---|---|
| 颜色 | color, background-color, border-color |
| 背景 | background-image, background-position |
| 可见性 | visibility(注意:不是display!) |
// 这些操作只触发重绘
element.style.color = 'red';
element.style.backgroundColor = 'blue';
重绘的成本
重绘比重排便宜得多:
- 不需要计算布局
- 只影响元素自身
- 一次重绘大约 1-5ms
三、重排 vs 重绘 vs 合成
| 对比项 | 重排 | 重绘 | 合成 |
|---|---|---|---|
| 触发条件 | 尺寸、位置、布局变化 | 外观变化 | transform/opacity变化 |
| 是否计算布局 | 是 | 否 | 否 |
| 是否重新绘制 | 是 | 是 | 否 |
| 影响范围 | 元素 + 关联元素 | 仅元素自身 | 仅元素自身 |
| 耗时 | 10-50ms | 1-5ms | <1ms |
| 性能影响 | 严重 | 轻微 | 极优 |
代价排序:重排 > 重绘 > 合成
修改 width/height/top/left → 重排 → 最慢
修改 color/background → 重绘 → 中等
修改 transform/opacity → 合成 → 最快
四、强制同步布局(Forced Synchronous Layout)
什么是强制同步布局?
当你在 JavaScript 中先读取布局属性,再修改样式,浏览器会被迫立即执行布局计算。这叫做强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing)。
代码示例
// 错误!强制同步布局
function updateLayout() {
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
// 读取布局属性 -> 浏览器被迫立即计算布局
const height = box.offsetHeight;
// 修改样式 -> 再次触发布局
box.style.height = height * 2 + 'px';
});
// 循环100次 -> 浏览器做了200次布局计算!
}
发生了什么?
浏览器本来是延迟布局的(等到需要显示时才计算)。但当你读取 offsetHeight 时,浏览器必须立即给出准确的值,所以被迫立刻计算布局。然后你修改了样式,下一次读取又被迫重新计算。
如何避免强制同步布局?
方案1:先读取,后写入
// 正确:先读取所有值,再修改
function updateLayout() {
const boxes = document.querySelectorAll('.box');
// 第一步:读取所有值
const heights = [];
boxes.forEach(box => {
heights.push(box.offsetHeight); // 只触发1次布局
});
// 第二步:修改所有样式
boxes.forEach((box, index) => {
box.style.height = heights[index] * 2 + 'px';
});
// 总共只触发2次布局!
}
方案2:使用 requestAnimationFrame
// 正确:在下一帧渲染前批量处理
function updateLayout() {
requestAnimationFrame(() => {
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
box.style.height = box.offsetHeight * 2 + 'px';
});
});
}
方案3:使用 FastDOM 库
// FastDOM 会自动批量处理读和写
fastdom.measure(() => {
const height = element.offsetHeight;
fastdom.mutate(() => {
element.style.height = height * 2 + 'px';
});
});
五、减少重排重绘的实战策略
策略1:使用 class 代替逐个修改 style
// 错误:每修改一个属性就触发一次重排
const box = document.getElementById('box');
box.style.width = '100px';
box.style.height = '100px';
box.style.margin = '10px';
box.style.padding = '5px';
// 正确:使用 class 一次性修改
box.className = 'new-style';
// 或者使用 cssText
box.style.cssText = 'width:100px;height:100px;margin:10px;padding:5px;';
策略2:使用 transform 代替 top/left
// 错误:触发重排
box.style.left = '100px';
box.style.top = '50px';
// 正确:只触发合成
document.getElementById('box').style.transform = 'translate(100px, 50px)';
策略3:先隐藏元素,修改完再显示
// 错误:每修改一个子元素都触发重排
list.style.width = '100px';
items.forEach(item => {
item.style.height = '50px'; // 每次都会触发重排
});
// 正确:先隐藏,批量修改,再显示
list.style.display = 'none'; // 第1次重排
list.style.width = '100px';
items.forEach(item => {
item.style.height = '50px';
});
list.style.display = 'block'; // 第2次重排
// 总共只触发2次重排!
策略4:使用 DocumentFragment 批量插入DOM
// 错误:每 appendChild 都触发重排
const list = document.getElementById('list');
for (let i = 0; i < 100; i++) {
const item = document.createElement('li');
item.textContent = 'Item ' + i;
list.appendChild(item); // 每次触发重排
}
// 正确:使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const item = document.createElement('li');
item.textContent = 'Item ' + i;
fragment.appendChild(item); // 不触发重排
}
list.appendChild(fragment); // 只触发1次重排
策略5:使用 requestAnimationFrame 批量执行
// 错误:在滚动事件中直接修改样式
window.addEventListener('scroll', () => {
document.getElementById('header').style.top = window.scrollY + 'px';
});
// 正确:使用 requestAnimationFrame
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
document.getElementById('header').style.transform = 'translateY(' + window.scrollY + 'px)';
ticking = false;
});
ticking = true;
}
});
策略6:缓存布局属性值
// 错误:反复读取布局属性
for (let i = 0; i < 100; i++) {
// 每次读取都强制布局
if (element.offsetWidth > 500) {
element.style.width = '500px';
}
}
// 正确:缓存布局属性
const width = element.offsetWidth; // 只读取1次
for (let i = 0; i < 100; i++) {
if (width > 500) {
element.style.width = '500px';
}
}
六、Chrome DevTools 检测重排重绘
方法1:Performance 面板
- 打开 Chrome DevTools -> Performance 面板
- 点击录制按钮,操作页面
- 停止录制
- 查看 Main 线程的火焰图:
- 黄色条 = JavaScript 执行
- 紫色条 = 样式计算(Style)
- 蓝色条 = 布局(Layout)
- 绿色条 = 绘制(Paint)
方法2:Rendering 面板
- 打开 Chrome DevTools -> 按 Esc -> 点击菜单(三个点)
- 选择 More tools -> Rendering
- 勾选以下选项:
- Paint flashing:重绘的区域会绿色闪烁
- Layout Shift Regions:布局变化的区域会蓝色闪烁
方法3:Layers 面板
- 打开 Chrome DevTools -> 按 Esc -> More tools -> Layers
- 查看合成层的数量和内存占用
自问自答
Q:为什么 visibility:hidden 不会触发重排,但 display:none 会?
A:visibility:hidden 只是让元素看不见,但元素仍然占据原来的空间(位置和大小不变),所以只需要重绘。display:none 会让元素从 Render 树中移除,不再占据空间,周围的元素会填补它的位置,所以需要重排。
Q:修改 transform 真的完全不触发重排吗? A:是的!transform 只改变元素的变换矩阵,不改变元素在文档流中的位置和尺寸。浏览器可以把元素预先绘制在一个独立的合成层上,transform 只是改变这个层的位置/旋转/缩放,由 GPU 处理,不需要 CPU 参与布局计算。
Q:如何批量检测页面中的强制同步布局? A:打开 Chrome DevTools 的 Performance 面板,录制页面操作后,在 Main 线程火焰图中搜索"Layout",如果出现很多短小的紫色条交替出现(读-写-读-写模式),就说明存在强制同步布局。
Q:CSS 动画和 JS 动画哪个性能更好? A:CSS 动画通常性能更好,因为浏览器可以优化 CSS 动画的执行(如提前创建合成层、在合成线程运行)。JS 动画在主线程执行,如果逻辑复杂可能阻塞渲染。但如果动画需要复杂的动态计算(如物理模拟),JS 动画更灵活。
实践任务
- 任务1:用 Chrome DevTools Performance 面板录制页面操作,识别重排和重绘的发生时刻
- 任务2:制造强制同步布局,用 Rendering 面板的 Paint flashing 观察影响范围
- 任务3:将一个
left/top动画改造成transform动画,对比帧率变化 - 任务4:用 DocumentFragment 批量插入 1000 个 DOM 元素,对比插入耗时
- 任务5:缓存布局属性,将循环中的
offsetWidth读取从 100 次降到 1 次
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| 重排/重绘 | 第01章 | 渲染管线的第4~6阶段 |
| 强制同步布局 | 第04章 | 长任务阻塞渲染导致掉帧 |
| transform优化 | 第02章 | 合成层避免重排的原理 |
| Performance面板 | 第10章 | 性能监控与预算 |
上一章:02-合成层与GPU加速 下一章:04-事件循环与渲染时序