页面卡顿、滚动掉帧,很多时候不是 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 合成]
- 解析:HTML 解析成 DOM 树,CSS 解析成 CSSOM 树。
- 构建渲染树:两者合并,剔除
display:none的节点。 - Layout(布局/重排):计算每个节点的几何信息——位置、大小。
- Paint(绘制/重绘):把节点画成一层层的位图。
- Composite(合成):GPU 把这些图层叠在一起输出到屏幕。
重排 vs 重绘 vs 合成
这三者的代价依次递减:
- 重排(Reflow):几何属性变化(width、top、font-size、增删节点)会触发重新布局,最贵,因为它可能影响其它元素的位置,往往连带重绘。
- 重绘(Repaint):只是外观变化(color、background、visibility),位置不变,跳过布局,比重排便宜。
- 合成(Composite):只用
transform和opacity做动画时,可以只在合成阶段处理,连绘制都跳过,由 GPU 直接搞定,最便宜、最流畅。
这就是为什么动画要优先用 transform: translate() 而不是改 left/top——后者每帧都重排,前者只走合成。
布局抖动(Layout Thrashing)
最常见的性能陷阱,是在循环里「读一下、写一下」交替操作布局:
1 | // 反例:每次读 offsetHeight 都强制同步重排 |
浏览器本来会把多次样式修改批量起来、延迟到下一帧统一重排。但 offsetHeight、getBoundingClientRect()、scrollTop 这类属性需要立即返回准确值,会强制浏览器同步 flush 布局。读-写交替就把批量优化彻底打碎,每次循环都重排一遍。
正确做法是读写分离:先一次性读完所有需要的值,再统一写:
1 | const heights = items.map(el => el.offsetHeight); // 集中读 |
几条实用优化
- 动画用
transform/opacity,避开布局和绘制。 will-change: transform提前提示浏览器把元素提为独立合成层,但别滥用——图层太多反而吃内存。contain: layout paint把一个组件的布局/绘制影响隔离在自身范围内,避免牵一发动全身。- 批量 DOM 操作用
DocumentFragment,或先display:none改完再显示。 - 避免频繁读取布局属性,必须读时缓存下来。
小结
理解「重排 > 重绘 > 合成」的代价梯度,是前端性能优化的基本功。记住两条就能避开大部分坑:动画走 transform/opacity,以及别在循环里读写交替触发布局抖动。剩下的,用 DevTools 的 Performance 面板录一段,看哪里 Layout/Paint 占时间长,按图索骥即可。