重排与重绘:触发条件与优化

本节目标

重排(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 就推迟。把这一节和上一节的「走合成」连起来看,你会发现性能优化的招式虽然多,根子就一个——别让主线程做多余的重复劳动。