重绘与重排(Repaint & Reflow)
在前端性能优化中,重绘(Repaint) 和 重排(Reflow) 是两个非常核心的概念。理解它们的触发机制和优化手段,不仅能帮助我们写出更高性能的页面,也是前端面试中的高频考点。
浏览器渲染流程
在讨论重绘和重排之前,我们需要先了解浏览器是怎样把 HTML、CSS 和 JavaScript 变成用户看到的页面的。整个流程可以用"流水线"来理解:
每一步做的事情:
| 步骤 | 英文名 | 做了什么 |
|---|---|---|
| 1. 构建 DOM 树 | Parse HTML | 把 HTML 标签解析成一棵节点树 |
| 2. 构建 CSSOM 树 | Parse CSS | 把 CSS 规则解析成样式树 |
| 3. 生成渲染树 | Render Tree | 将 DOM 和 CSSOM 合并,剔除不可见元素(如 display: none) |
| 4. 布局 | Layout / Reflow | 计算每个节点在页面中的位置和大小 |
| 5. 绘制 | Paint / Repaint | 将节点的视觉样式(颜色、阴影、边框等)转化为像素 |
| 6. 合成 | Composite | 将不同图层合并,最终显示在屏幕上 |
想象你在装修房子:布局就是决定每面墙在哪里、房间多大(结构);绘制就是给墙刷漆、贴壁纸(外观);合成就是把所有房间的效果整合起来,形成完整的家。
什么是重排(Reflow)
重排也叫回流,指的是当页面的布局信息发生变化时,浏览器需要重新计算元素的几何属性(位置、大小),然后重新进行布局的过程。
简单理解:任何影响元素"在哪里"和"多大"的改变,都会触发重排。
重排一定会触发重绘,但重绘不一定触发重排。重排的代价比重绘高得多,因为它要从布局阶段重新开始走完整个后续流程。
触发重排的常见操作
1. 修改几何属性
/* 以下属性的修改都会触发重排 */
.box {
width: 200px; /* 宽度 */
height: 100px; /* 高度 */
padding: 10px; /* 内边距 */
margin: 20px; /* 外边距 */
border-width: 2px; /* 边框宽度 */
top: 50px; /* 定位偏移 */
left: 100px;
font-size: 16px; /* 字体大小也会影响布局 */
}
2. DOM 结构变化
// 添加或删除可见元素
document.body.appendChild(newElement);
document.body.removeChild(oldElement);
// 修改元素内容(可能改变尺寸)
element.innerText = '新的文本内容,可能比原来更长';
3. 读取布局信息
这一点容易被忽略 —— 读取某些属性也会触发重排!因为浏览器必须确保返回最新的、准确的值:
// 以下属性/方法的访问都会强制触发重排
element.offsetTop;
element.offsetLeft;
element.offsetWidth;
element.offsetHeight;
element.scrollTop;
element.scrollLeft;
element.scrollWidth;
element.scrollHeight;
element.clientTop;
element.clientLeft;
element.clientWidth;
element.clientHeight;
// 方法
element.getBoundingClientRect();
window.getComputedStyle(element);
浏览器通常会将多次 DOM 修改"攒"在一起,放入一个队列中批量处理(这叫做批量异步重排)。但当你读取布局属性时,浏览器被迫立刻清空这个队列,执行重排以确保你拿到的值是最新的。这就是所谓的强制同步布局(Forced Synchronous Layout)。
4. 窗口和全局变化
// 窗口大小变化
window.addEventListener('resize', handler);
// 字体加载完成(可能改变文字尺寸)
document.fonts.ready.then(() => { /* 可能触发重排 */ });
// 激活 CSS 伪类(如 :hover 改变了尺寸)
重排触发属性速查表
| 类别 | 属性 |
|---|---|
| 盒模型 | width、height、padding、margin、border-width |
| 定位 | position、top、right、bottom、left、float、clear |
| 文字 | font-size、font-weight、font-family、line-height、text-align、white-space |
| 显示 | display、overflow、vertical-align |
| 弹性/网格 | flex、grid-template-columns、gap 等 |
什么是重绘(Repaint)
重绘指的是当元素的外观样式(颜色、背景、阴影等)发生变化,但不影响布局时,浏览器只需要重新"涂色"的过程。
简单理解:只改"长什么样"、不改"在哪里"和"多大",就只触发重绘。
仅触发重绘的常见属性
/* 这些属性的修改只触发重绘,不触发重排 */
.box {
color: red; /* 文字颜色 */
background-color: blue; /* 背景色 */
background-image: url(); /* 背景图 */
border-color: green; /* 边框颜色 */
border-style: dashed; /* 边框样式 */
border-radius: 10px; /* 圆角 */
outline: 1px solid red; /* 轮廓线 */
box-shadow: 0 0 5px #000;/* 阴影 */
visibility: hidden; /* 隐藏(仍占位) */
text-decoration: underline; /* 文本装饰 */
}
visibility: hidden→ 元素不可见但仍然占据空间,只触发重绘display: none→ 元素从渲染树中移除,不占空间,触发重排
重绘与重排的对比
| 对比项 | 重排(Reflow) | 重绘(Repaint) |
|---|---|---|
| 触发条件 | 几何属性变化(位置、大小) | 外观样式变化(颜色、阴影) |
| 涉及阶段 | Layout → Paint → Composite | Paint → Composite |
| 性能开销 | 高(需重新计算布局) | 中(只重新绘制像素) |
| 影响范围 | 可能波及父元素和兄弟元素 | 通常只影响当前元素 |
| 是否包含对方 | 重排一定包含重绘 | 重绘不一定触发重排 |
性能优化策略
了解了重绘和重排的原理后,我们来看如何在实际开发中减少它们的发生。
1. 批量修改样式
// ❌ 差:每次修改都可能触发一次重排
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
// ✅ 好:通过 cssText 一次性修改
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;';
// ✅ 好:通过切换 class 一次性修改
element.className = 'new-style';
// 或者
element.classList.add('new-style');
2. 避免逐项读写交替
// ❌ 差:读写交替,每次读取都强制触发重排
const width = element.offsetWidth; // 读 → 触发重排
element.style.width = width + 10 + 'px'; // 写
const height = element.offsetHeight; // 读 → 再次触发重排
element.style.height = height + 10 + 'px'; // 写
// ✅ 好:先集中读取,再集中写入
const width = element.offsetWidth; // 读
const height = element.offsetHeight; // 读(同一"干净"状态,不会再次重排)
element.style.width = width + 10 + 'px'; // 写
element.style.height = height + 10 + 'px'; // 写(浏览器批量处理)
读写分离 —— 把所有的"读取布局信息"操作集中在一起,把所有的"修改样式"操作集中在一起,避免交替进行。
3. 使用 DocumentFragment 批量操作 DOM
// ❌ 差:每次插入都可能触发重排
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // 每次都可能重排
}
// ✅ 好:先在 Fragment 中组装,一次性插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // Fragment 不在 DOM 中,不会触发重排
}
list.appendChild(fragment); // 只触发一次重排
4. 离线 DOM 操作
将元素从渲染流中取出,修改完成后再放回:
// 方法一:先隐藏,修改完再显示
element.style.display = 'none'; // 触发一次重排
// ... 进行大量修改 ...
element.style.display = 'block'; // 触发一次重排
// 总共只有 2 次重排,而不是 N 次
// 方法二:克隆节点,替换回去
const clone = element.cloneNode(true);
// ... 对 clone 进行大量修改 ...
element.parentNode.replaceChild(clone, element); // 只触发一次重排
5. 使用 transform 和 opacity 做动画
transform 和 opacity 是两个特殊属性,它们的变化只触发合成(Composite)阶段,跳过了布局和绘制,性能最好:
/* ❌ 差:使用 top/left 做动画 → 每帧都触发重排 */
.move-bad {
transition: top 0.3s, left 0.3s;
position: absolute;
top: 0;
left: 0;
}
.move-bad:hover {
top: 100px;
left: 100px;
}
/* ✅ 好:使用 transform 做动画 → 只触发合成,GPU 加速 */
.move-good {
transition: transform 0.3s;
}
.move-good:hover {
transform: translate(100px, 100px);
}
因为它们可以在合成器线程(Compositor Thread) 上独立处理,不需要经过主线程。浏览器会为使用了这些属性的元素创建独立的合成层(Compositing Layer),由 GPU 直接处理,所以非常高效。
6. 使用 will-change 提升合成层
/* 提前告知浏览器该元素即将变化,让浏览器做好优化准备 */
.animated-box {
will-change: transform, opacity;
}
will-change 不能滥用!每个合成层都会消耗额外的内存。只在确实需要动画的元素上使用,动画结束后最好移除。
7. 使用 requestAnimationFrame
// ❌ 差:在任意时刻修改样式,可能导致掉帧
setTimeout(() => {
element.style.transform = 'translateX(100px)';
}, 16);
// ✅ 好:在浏览器下一次重绘前执行,与刷新率同步
requestAnimationFrame(() => {
element.style.transform = 'translateX(100px)';
});
优化策略总结
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 批量修改样式 | 减少重排次数 | 需要同时修改多个样式 |
| 读写分离 | 避免强制同步布局 | 需要读取布局信息后再修改 |
| DocumentFragment | 离线 DOM 操作 | 批量插入大量节点 |
display: none 先隐藏再操作 | 离线 DOM 操作 | 对某个元素进行大量修改 |
transform / opacity 做动画 | 跳过布局和绘制 | 位移、缩放、旋转、淡入淡出 |
will-change | 提前创建合成层 | 即将发生动画的元素 |
requestAnimationFrame | 与浏览器刷新同步 | JavaScript 驱动的动画 |
实战示例:性能对比
下面的例子演示了"如何将 1000 个元素移动到新位置"在优化前后的区别:
// ❌ 未优化版本:频繁触发重排
function moveElementsBad(elements) {
elements.forEach((el) => {
const currentTop = el.offsetTop; // 读 → 强制重排
el.style.top = currentTop + 10 + 'px'; // 写
});
}
// ✅ 优化版本:读写分离
function moveElementsGood(elements) {
// 第一步:集中读取所有位置
const positions = elements.map((el) => el.offsetTop);
// 第二步:集中写入所有新位置
elements.forEach((el, i) => {
el.style.top = positions[i] + 10 + 'px';
});
}
// ✅ 最优版本:使用 transform,完全避免重排和重绘
function moveElementsBest(elements) {
elements.forEach((el) => {
el.style.transform = 'translateY(10px)';
});
}
如何检测重绘和重排
在 Chrome DevTools 中可以直观地观察重绘和重排:
1. Performance 面板
- 打开 DevTools → Performance 面板
- 点击录制按钮,操作页面
- 停止录制,查看时间线中的 Layout(重排)和 Paint(重绘)事件
2. Rendering 面板
- 打开 DevTools → 按
Esc打开底部抽屉 → 选择 Rendering - 勾选 Paint flashing:页面中发生重绘的区域会闪绿色
- 勾选 Layout Shift Regions:发生布局偏移的区域会闪蓝色
3. Performance Monitor
- 打开 DevTools →
Ctrl + Shift + P(Mac:Cmd + Shift + P) - 输入 "Performance Monitor" 并选择
- 实时监控 Layouts / sec 和 Style recalcs / sec
面试高频问答
Q1:什么是重绘和重排?它们有什么区别?
答:
- 重排(Reflow) 是指页面布局信息发生变化时,浏览器重新计算元素的几何属性(位置、大小)的过程。比如修改了
width、height、margin等属性。 - 重绘(Repaint) 是指元素的外观样式发生变化但不影响布局时,浏览器重新绘制元素像素的过程。比如修改了
color、background-color等属性。 - 核心区别:重排一定会导致重绘,但重绘不一定触发重排。重排的性能开销远大于重绘。
Q2:为什么读取 offsetWidth 等属性会触发重排?
答: 浏览器通常会将多次 DOM 修改放入队列中批量处理。但当你读取 offsetWidth、offsetHeight、getBoundingClientRect() 等布局属性时,浏览器为了保证返回准确的值,必须立即清空队列、执行所有挂起的修改并进行重排。这就是强制同步布局(Forced Synchronous Layout)。所以在代码中应避免读写交替,遵循"先读后写"的原则。
Q3:如何减少重排和重绘?请列举至少 3 种优化手段。
答:
- 批量修改样式:使用
cssText或切换className一次性修改多个样式,避免逐条修改。 - 读写分离:先集中读取所有布局属性,再集中写入修改,避免强制同步布局。
- 使用 DocumentFragment:批量 DOM 操作时,先在 Fragment 中组装,最后一次性插入真实 DOM。
- 使用 transform/opacity 做动画:这两个属性只触发合成阶段,跳过布局和绘制,性能最好。
- 离线 DOM 操作:通过
display: none隐藏元素后再批量修改,或使用克隆节点。
Q4:为什么推荐使用 transform 而不是 top/left 做动画?
答: 使用 top/left 做动画,每一帧都会触发重排(Layout)→ 重绘(Paint)→ 合成(Composite)的完整流程。而 transform 属性的变化只触发合成(Composite)阶段,浏览器会为该元素创建独立的合成层,由 GPU 直接处理,不经过主线程,因此性能大幅提升,动画也更流畅。
Q5:visibility: hidden 和 display: none 对重排重绘的影响有什么不同?
答:
display: none:元素从渲染树中完全移除,不占据任何空间。设置或取消都会触发重排 + 重绘。visibility: hidden:元素不可见但仍然占据空间,只是视觉上看不到了。设置或取消只会触发重绘,不会触发重排。- 因此在需要频繁切换显隐的场景下,
visibility: hidden的性能优于display: none。
Q6:什么是合成层(Compositing Layer)?哪些情况会创建合成层?
答: 合成层是浏览器为某些元素单独分配的 GPU 图层。合成层上的变化只需在合成阶段处理,不需要重排和重绘,性能极高。以下情况会创建合成层:
- 使用了
transform的 3D 变换(如translate3d、translateZ) - 设置了
will-change: transform或will-change: opacity - 使用了
<video>、<canvas>、<iframe>等元素 - 使用了 CSS
animation或transition且作用于opacity/transform - 元素与已有合成层重叠(隐式合成)
但合成层越多,内存消耗越大,所以不能滥用。