重排与重绘:触发条件与优化
本节目标
- 区分「重排(Reflow)」与「重绘(Repaint)」
- 认识最经典的陷阱「读改写分离」(强制同步布局 / Layout Thrashing)
- 用一段浏览器可运行的代码,实测「先读后写」与「读改写分离」的耗时差距
重排(Reflow / Layout):元素的几何信息变了(位置、尺寸),浏览器必须重新计算布局。代价最大,且常常影响子树甚至整页。
重绘(Repaint):只有外观变了(颜色、背景),几何没变,只需重新绘制。代价较小。
触发重排的典型操作:
el.style.width = '100px' // 改几何 → 重排
const h = el.offsetHeight // 读几何 → 强制浏览器先重排(同步布局)
// 增删 DOM、改 class、窗口 resize、字体加载完,都可能触发经典陷阱:读改写分离(强制同步布局)。下面这段代码在浏览器里能直接跑,并显示两种写法的耗时差:
<!-- 运行环境:任意现代浏览器 -->
<!-- 步骤:存为 brw-l6.html,用浏览器打开,看页面上打印的两种耗时 -->
<!-- 你会看到:循环里「改一下读一下」明显比「先全读再全改」慢 -->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<style>.item{width:50px;height:20px;background:#3178c6;margin:2px}</style>
</head>
<body>
<div id="out"></div>
<script>
const box = document.createElement('div')
document.body.appendChild(box)
for (let i = 0; i < 200; i++) {
const d = document.createElement('div')
d.className = 'item'
box.appendChild(d)
}
const list = [...box.children]
// ❌ 错误写法:循环里「读→写」交错,每次读 offsetWidth 都会强制浏览器先重排上一轮的写
let t1 = performance.now()
for (const el of list) {
el.style.width = (el.offsetWidth + 1) + 'px' // 读 offsetWidth(触发同步布局)后写样式
}
const bad = performance.now() - t1
// 重置
list.forEach(el => el.style.width = '50px')
// ✅ 正确写法:先全部读,再统一改(batch read then write)
let t2 = performance.now()
const widths = list.map(el => el.offsetWidth)
list.forEach((el, i) => { el.style.width = (widths[i] + 1) + 'px' })
const good = performance.now() - t2
document.getElementById('out').innerHTML =
'读改写混合(坏): ' + bad.toFixed(2) + ' ms<br>先读后写(好): ' + good.toFixed(2) + ' ms'
</script>
</body>
</html>优化手段清单:
1. 批量改样式:合并成一次 class 切换,而不是逐个改 style
2. 读改写分离:先集中读几何,再集中写样式
3. rAF 批量写:把 DOM 写入放进 requestAnimationFrame 回调,浏览器下一帧前统一处理
4. 离线操作:用 DocumentFragment 批量插入、或用 display:none 包裹后再改
5. CSS containment:contain: layout paint 声明「本元素不影响外部」,限制重排范围判断口诀:属性影响几何→重排;只影响外观→重绘;只影响图层/透明度(transform/opacity)→只合成(见上一节)。
名词解释
强制同步布局(Forced Synchronous Layout / Layout Thrashing):当你在 JS 里「写入样式后又立刻读取几何属性」时,浏览器为了给你准确的数值,必须立刻停下手里的事、先完成一次布局计算。如果这在循环里发生,每一轮都重排一次,性能雪崩。它的「触发方法」就是你刚刚跑的那段 el.style.width = ...; el.offsetWidth——写后立刻读。
重排(Reflow):见上一节。这里补充它的「范围方法」:document.body.offsetHeight 这类读取会触发整页强制布局;而给某个元素加 contain: layout 能把它「圈」起来,重排只发生在它内部。
requestAnimationFrame(rAF):浏览器提供的「下一帧绘制前」回调钩子。方法:requestAnimationFrame(callback) 返回一个 id,cancelAnimationFrame(id) 可取消。它用来把 DOM 写入「对齐到帧」,避免一帧内多次零散重排。
课后练习
练习 1:下面代码在一个 1000 个子元素的列表里跑,为什么可能很慢?怎么改?
for (const el of items) {
el.style.height = el.scrollHeight + 10 + 'px'
}答案:循环里每次
el.scrollHeight读取几何,都会触发一次强制同步布局(因为上一行刚改了style.height)。1000 次就是 1000 次重排。改法:把读取和写入分开——先const hs = items.map(el => el.scrollHeight),再items.forEach((el,i) => el.style.height = hs[i]+10+'px'),把 1000 次重排降到约 2 次。
练习 2:contain: layout paint 到底解决了什么?
答案:它告诉浏览器「这个元素内部的布局/绘制变化,不会影响到它外面的世界」。于是重排重绘被「圈」在该元素内部,浏览器不用去重新计算整页或父级,范围更小、更快。适合用在频繁变动的局部容器(如聊天列表、虚拟滚动区)上。
总结
我觉得「重排重绘」这一节最该刻进肌肉记忆的,不是定义,而是那句**「先读后写」。绝大多数前端性能 bug 不是算法差,而是「在循环里改一下又读一下」,无意间触发了成百上千次强制同步布局。浏览器很老实:你问它「现在多宽」,它就真的先排一遍再回答你。所以优化的本质,往往是调整你提问和改写的顺序**,让浏览器一次排完、批量改完。这背后还是那条主线:主线程上的同步布局是昂贵的,能合并就合并,能推迟到 rAF 就推迟。把这一节和上一节的「走合成」连起来看,你会发现性能优化的招式虽然多,根子就一个——别让主线程做多余的重复劳动。