在 18.1 节中,我们了解了浏览器从接收 HTML 到绘制像素的完整渲染流水线。而真实的 Web 页面是动态的——用户交互、数据更新、动画效果都会导致页面发生变化。浏览器处理这些变化时,会触发两种代价不同的更新机制:重绘 和重排(又称回流)。理解它们的区别和触发条件,是写出高性能页面交互的第一步。
18.2.1 什么是重绘(Repaint)
重绘是指元素的视觉外观发生变化,但不影响布局时,浏览器重新绘制该元素的过程。常见的触发场景包括:
- 修改
color、background-color、border-color、box-shadow等视觉样式 - 修改
visibility(注意不是display) - 修改
outline、text-decoration等不影响几何尺寸的属性
重绘并不需要重新计算元素的几何位置和大小(即不需要布局阶段),浏览器可以直接在已生成的渲染树上进行像素级的重新绘制。所以重绘的开销相对较小,但也不是零成本。绘制面积越大、视觉复杂度越高(如多层阴影、渐变),重绘所需时间越长。
18.2.2 什么是重排(Reflow)
重排是指元素的几何属性发生变化,导致浏览器必须重新计算页面布局,然后重新绘制受影响部分的过程。重排几乎总会触发后续的重绘,而比单纯的重绘昂贵得多。
重排的触发条件非常广泛,日常开发中不小心就会踩中:
修改影响几何尺寸的 CSS 属性
width、height、padding、margin、borderposition、top、left、right、bottomdisplay(从none到其他值会触发重建,从其他值到none也会)font-size、text-align等
操作 DOM
- 增加、删除、移动 DOM 节点
- 改变 DOM 的结构顺序
读取某些布局属性
浏览器为了优化性能,通常会将多个重排操作积攒起来一次性执行。但如果代码中穿插读取以下强制布局属性,浏览器就必须马上(同步地)执行队列中的重排,以确保返回准确值。这被称为强制同步布局(Forced Synchronous Layout)。
常见的强制布局属性包括:
offsetTop、offsetLeft、offsetWidth、offsetHeightscrollTop、scrollLeft、scrollWidth、scrollHeightclientTop、clientLeft、clientWidth、clientHeightgetComputedStyle()、getBoundingClientRect()
窗口尺寸变化
- 浏览器窗口缩放,会触发全局重排
内容变化
- 输入框文字改变、图片加载完成等可能影响尺寸的变化
伪类激活
- 例如
:hover改变了盒子尺寸
18.2.3 重排与重绘的性能影响
重排比重绘的代价高,是因为重排需要从布局阶段开始重新计算,而页面布局往往是一个复杂的层级系统——一个节点的重排可能引发其父节点、兄弟节点乃至整个文档树的连锁反应。这种级联重排被称为回流污染。
举个例子:在页面顶部的某个元素高度增加 10px,可能迫使它下方的所有元素全部向下推移,这些元素的每一个都会经历重排和重绘。如果一个页面布局复杂、DOM 层级很深,这种操作可能在短短几毫秒内触发几百次计算,导致明显的卡顿。尤其是在低端设备上,连续重排造成的帧率下降会严重影响用户体验。
18.2.4 优化策略与最佳实践
既然重排不可避免,我们就需要尽量减少重排的频率、缩小重排的影响范围。
1. 批量修改样式
不要一条一条地改,用 class 一次性切换:
// 不推荐:每条都会引起重排
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
// 推荐:一次修改
element.classList.add('new-style');
2. 使用文档碎片(DocumentFragment)
当需要插入多个新 DOM 节点时,先在内存中的文档碎片上组装完毕,最后一次插入文档:
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const div = document.createElement('div');
div.textContent = i;
fragment.appendChild(div);
}
document.body.appendChild(fragment); // 一次性触发重排
3. 脱离文档流后做复杂操作
有两种常用方法让元素暂时“离线”:
- 设置
display: none:元素被移出渲染树,后续修改不触发重排和重绘。修改完成后再恢复显示,触发一次重排。 - 使用
cloneNode:克隆一个节点在内存里修改,最后替换原节点。
// 离线操作示例
const list = document.getElementById('myList');
list.style.display = 'none';
// 进行大量增删改操作...
list.style.display = 'block';
4. 读写分离,避免强制同步布局
将布局属性的读取和写入逻辑分开,不要交替进行:
// 不推荐:交替读写触发多次强制重排
elements.forEach(el => {
const width = el.offsetWidth; // 读(强制布局)
el.style.width = width + 10 + 'px'; // 写(重排)
});
// 推荐:先批量读,再批量写
const widths = elements.map(el => el.offsetWidth);
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
5. 利用绝对定位或固定定位
对于频繁重排的节点(如动画元素),将它设置为 position: absolute 或 fixed,使其脱离正常文档流。这样它的重排只会影响自身,不会连累其他元素。
6. 使用 CSS 动画代替 JavaScript 动画
CSS 动画通过合成器线程执行,如果只使用 transform 和 opacity,不会触发重排和重绘,只触发合成,性能最优。28.3 节会详细解释合成层与 GPU 加速原理。
7. 虚拟列表与懒渲染
对于超长列表,一次性渲染上千个 DOM 重排开销巨大。使用虚拟列表(只渲染可视区域附近的部分条目)可以大幅减少初始重排和后续滚动重排,具体实现在 31.5 节中介绍。
18.2.5 识别真实场景中的重排问题
浏览器开发者工具的性能面板可以录制一段操作,分析重排的次数和耗时。Chrome DevTools 的“Rendering”选项卡中还有一个Paint Flashing功能,开启后页面重绘区域会闪烁绿色,直观显示哪些区域在被不断绘制。这能帮助你快速定位不合理的重排和重绘。
一句话总结:修改视觉外观用重绘,修改几何尺寸用重排;能不用重排就不用重排,能用一次完成的就不要分多次。养成对DOM操作成本的敏感度,是每一个追求极致性能的前端工程师的必修课。