3. Layout Thrashing:布局抖动——强制同步布局与读写分离
布局抖动,英文叫 Layout Thrashing。我当年第一次在性能面板里看到这个词时,还以为是浏览器出了什么奇怪的 bug。后来才发现,这其实是咱们自己写出来的性能杀手。
说白了,布局抖动就是浏览器被迫反复计算元素位置和尺寸,导致主线程被塞满,帧率直接崩盘。你想想看,一个 60fps 的流畅页面,突然掉到 20fps,滚动起来像幻灯片——八成就是布局抖动在作祟。
什么是强制同步布局?
正常情况下,浏览器会等 JavaScript 执行完,再统一做样式计算和布局。这叫异步布局,效率很高。
但如果你在 JavaScript 里先改了样式,然后又立刻去读取布局属性——比如 offsetHeight、clientWidth、getBoundingClientRect()——浏览器就没办法了。它必须马上停下来,把刚才的样式变更先布局一遍,才能给你一个准确的值。
这个过程,就叫强制同步布局(Forced Synchronous Layout)。
核心定义:强制同步布局 = 在样式变更后立即读取布局属性,导致浏览器被迫提前执行布局计算。
我举个例子你就明白了:
// 坏代码 —— 强制同步布局
const boxes = document.querySelectorAll('.box');
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = '200px'; // 写:修改样式
console.log(boxes[i].offsetHeight); // 读:强制布局!
boxes[i].style.height = '100px'; // 写:又改样式
console.log(boxes[i].clientWidth); // 读:又强制布局!
}
这段代码跑一次循环,浏览器就要做两次强制布局。如果页面有 100 个元素,那就是 200 次布局计算。你说卡不卡?
布局抖动的连锁反应
强制同步布局本身已经够糟糕了,但更可怕的是它的连锁反应。一次强制布局会清空浏览器的布局缓存,导致后续所有读取操作都得重新计算。这就是「抖动」这个名字的由来——布局像筛子一样抖个不停。
| 操作模式 | 布局次数 | 耗时(1000 个元素) | 帧率影响 |
|---|---|---|---|
| 正常异步布局 | 1 次 | ~2ms | 无影响 |
| 读写交替(强制同步) | 2000 次 | ~80ms | 严重卡顿 |
| 读写分离优化后 | 1 次 | ~3ms | 流畅 |
我在项目中遇到过最夸张的一次,是一个表格排序功能。每次点击表头,代码都会循环读取每一行的 offsetTop 来做排序动画。结果呢?一个只有 200 行的表格,排序耗时 300 多毫秒。用户点一下,页面卡半秒。后来改成读写分离,耗时直接降到 5 毫秒。
如何通过读写分离避免布局抖动?
解决方案其实很简单:把所有的「写」操作集中在一起,所有的「读」操作也集中在一起。不要让它们交替出现。
看这段优化后的代码:
// 好代码 —— 读写分离
const boxes = document.querySelectorAll('.box');
const heights = [];
// 第一阶段:批量读取
for (let i = 0; i < boxes.length; i++) {
heights.push(boxes[i].offsetHeight); // 只读,不写
}
// 第二阶段:批量写入
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = heights[i] + 'px'; // 只写,不读
boxes[i].style.height = heights[i] + 'px'; // 只写,不读
}
这样浏览器只需要做一次布局计算,性能差距是数量级的。
我的习惯:在写代码前,我会先问自己——这段逻辑里「读」和「写」能不能分开?如果能,就拆成两个循环。如果不行,就用 requestAnimationFrame 来调度。
requestAnimationFrame 的正确用法
有些场景下,读写分离还不够。比如你需要在动画循环里同时读写布局属性。这时候就要请出 requestAnimationFrame(简称 rAF)了。
rAF 的回调会在浏览器下一次重绘之前执行。利用这个时机,我们可以把「写」操作推迟到「读」操作之后,让浏览器有足够的时间做批量布局。
// 使用 rAF 避免布局抖动
function animate() {
// 第一阶段:在 rAF 回调之前读取
const box = document.getElementById('box');
const currentLeft = box.offsetLeft; // 读
// 第二阶段:在 rAF 回调中写入
requestAnimationFrame(() => {
box.style.left = (currentLeft + 10) + 'px'; // 写
});
}
这里有个关键点:读操作要放在 rAF 回调外面,写操作放在里面。如果你反过来,在 rAF 里又读又写,那强制同步布局照样会发生。
我曾经踩过的坑:有一次我在 rAF 回调里同时读取 scrollTop 和修改元素高度,以为用了 rAF 就万事大吉了。结果性能面板一看,满屏的红色强制布局标记。后来才意识到,rAF 只保证执行时机,不保证读写顺序。读写分离的原则在任何场景下都不能丢。
读写分离 + rAF 的最佳实践
在实际项目中,我一般会这样组合使用:
- 静态页面:直接用读写分离,两个循环搞定。
- 滚动事件:用 rAF 节流,在回调外读取滚动位置,回调内更新样式。
- 动画循环:在 rAF 回调开始前批量读取,回调内批量写入。
- 复杂交互:使用 FastDOM 这类工具库,自动帮你做读写调度。
说到 FastDOM,它本质上就是一个读写分离的调度器。你调用它的 .read() 和 .write() 方法,它会自动把操作排队,在合适的时机批量执行。我个人习惯在大型项目里直接引入它,省心。
本章知识体系
下面这张图总结了布局抖动的成因和优化路径,你可以对照着梳理一下思路:
嗯,布局抖动这个问题,说白了就是「读写顺序没管好」。你只要记住一个原则:读归读,写归写,别混在一起。配合 rAF 做时机调度,性能就能稳稳地保持在 60fps。
下次你在 Chrome DevTools 的 Performance 面板里看到大片的红色「Layout」标记时,别慌。先检查一下代码里有没有读写交替的情况,八成就是它了。