页面卡顿、滚动掉帧,很多时候不是 JS 算得慢,而是触发了过多的重排(reflow)。要写出流畅的界面,得先理解浏览器是怎么把代码变成像素的。

渲染管线

从 HTML 到屏幕上的像素,浏览器大致走这几步:

graph LR A[HTML] --> B[DOM] C[CSS] --> D[CSSOM] B --> E[Render Tree] D --> E E --> F[Layout 布局] F --> G[Paint 绘制] G --> H[Composite 合成]
  1. 解析:HTML 解析成 DOM 树,CSS 解析成 CSSOM 树。
  2. 构建渲染树:两者合并,剔除 display:none 的节点。
  3. Layout(布局/重排):计算每个节点的几何信息——位置、大小。
  4. Paint(绘制/重绘):把节点画成一层层的位图。
  5. Composite(合成):GPU 把这些图层叠在一起输出到屏幕。

重排 vs 重绘 vs 合成

这三者的代价依次递减:

  • 重排(Reflow):几何属性变化(width、top、font-size、增删节点)会触发重新布局,最贵,因为它可能影响其它元素的位置,往往连带重绘。
  • 重绘(Repaint):只是外观变化(color、background、visibility),位置不变,跳过布局,比重排便宜。
  • 合成(Composite):只用 transformopacity 做动画时,可以只在合成阶段处理,连绘制都跳过,由 GPU 直接搞定,最便宜、最流畅。

这就是为什么动画要优先用 transform: translate() 而不是改 left/top——后者每帧都重排,前者只走合成。

布局抖动(Layout Thrashing)

最常见的性能陷阱,是在循环里「读一下、写一下」交替操作布局:

1
2
3
4
// 反例:每次读 offsetHeight 都强制同步重排
for (const el of items) {
el.style.height = el.offsetHeight + 10 + 'px';
}

浏览器本来会把多次样式修改批量起来、延迟到下一帧统一重排。但 offsetHeightgetBoundingClientRect()scrollTop 这类属性需要立即返回准确值,会强制浏览器同步 flush 布局。读-写交替就把批量优化彻底打碎,每次循环都重排一遍。

正确做法是读写分离:先一次性读完所有需要的值,再统一写:

1
2
3
4
const heights = items.map(el => el.offsetHeight); // 集中读
items.forEach((el, i) => { // 集中写
el.style.height = heights[i] + 10 + 'px';
});

几条实用优化

  • 动画用 transform / opacity,避开布局和绘制。
  • will-change: transform 提前提示浏览器把元素提为独立合成层,但别滥用——图层太多反而吃内存。
  • contain: layout paint 把一个组件的布局/绘制影响隔离在自身范围内,避免牵一发动全身。
  • 批量 DOM 操作DocumentFragment,或先 display:none 改完再显示。
  • 避免频繁读取布局属性,必须读时缓存下来。

小结

理解「重排 > 重绘 > 合成」的代价梯度,是前端性能优化的基本功。记住两条就能避开大部分坑:动画走 transform/opacity,以及别在循环里读写交替触发布局抖动。剩下的,用 DevTools 的 Performance 面板录一段,看哪里 Layout/Paint 占时间长,按图索骥即可。