布局抖动(Layout Thrashing):强制同步布局的成因与检测方法

上一章我们聊了卡顿的宏观分类,这一章咱们来啃一块硬骨头——布局抖动。英文叫 Layout Thrashing,翻译过来就是“布局被反复鞭尸”。

我在做性能优化之前,一直以为页面卡顿就是 JS 算得太慢。直到有一次,我接手一个表格组件,每次滚动都像幻灯片一样……一查 Performance 面板,好家伙,一秒内触发了上百次强制同步布局。嗯,从那以后,我再也不敢小看这个“布局抖动”了。

什么是强制同步布局?

先看一个最简单的例子。你写了一段 JS:

// 先写样式
element.style.width = '200px';
// 马上读位置
let width = element.offsetWidth;

这段代码有什么问题?

浏览器渲染是有流水线的。正常情况下,JS 执行完,浏览器会攒一批样式变更,然后统一做布局计算。这叫异步布局,效率高。

但你一旦在 JS 里写了样式,紧接着就读布局属性,浏览器就没办法了。它必须立刻停下来,把刚才的样式变更先算一遍布局,才能给你一个准确的值。这就叫强制同步布局(Forced Synchronous Layout)。

核心定义:在 JS 执行阶段,因读取布局属性而被迫提前执行的布局计算。

布局抖动是怎么发生的?

一个强制同步布局不可怕,可怕的是循环。

你想想看,如果在循环里反复“写样式 → 读布局 → 写样式 → 读布局”,浏览器就得一遍又一遍地做布局计算。这就是布局抖动。

// 典型的布局抖动代码
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = i * 10 + 'px';
  // 下面这行强制同步布局
  let height = boxes[i].offsetHeight;
  boxes[i].style.height = height + 'px';
}

这段代码跑一次循环,浏览器就做一次布局。100 个元素就是 100 次布局。而正常情况,只需要 1 次。

哪些操作会触发强制同步布局?

我整理了一个清单,方便你对照排查:

类别 具体属性/方法 触发条件
尺寸读取 offsetWidth, offsetHeight, clientWidth, clientHeight, scrollWidth, scrollHeight 读取前有未生效的样式变更
位置读取 offsetTop, offsetLeft, scrollTop, scrollLeft, getBoundingClientRect() 同上
计算样式 getComputedStyle() 读取的属性值受布局影响
滚动相关 scrollIntoView(), focus() 触发滚动后立即读取布局

我的经验:最常见的“元凶”就是 offsetHeight 和 getBoundingClientRect()。尤其是动画循环里,一不留神就踩坑。

如何检测布局抖动?

光靠肉眼看不出来。你得用工具。

方法一:Chrome Performance 面板

  1. 打开 DevTools → Performance 面板
  2. 点击录制,复现卡顿场景
  3. 停止录制,查看 Main 线程的火焰图
  4. 找到紫色的 Layout 任务
  5. 如果 Layout 任务旁边有红色三角形警告,那就是强制同步布局

点开那个 Layout 任务,下面会显示“Forced reflow”的堆栈信息。你能直接看到是哪一行代码触发的。

方法二:使用 Performance API 打点

有时候线上环境不方便开 DevTools,可以用代码检测:

// 在可疑代码前后打点
performance.mark('before-layout');
let rect = element.getBoundingClientRect();
performance.mark('after-layout');

performance.measure('layout-cost', 'before-layout', 'after-layout');
console.log(performance.getEntriesByName('layout-cost')[0].duration);

如果这个 duration 超过几毫秒,而且频繁出现,基本就是布局抖动了。

方法三:使用专门的检测库

我个人习惯用 Layout Thrash Detector 这个工具。它会在控制台打印出所有强制同步布局的调用栈。

// 引入后自动检测
import 'layout-thrash-detector';
// 控制台会输出类似:
// [Layout Thrash] offsetHeight read after style write at line 42

注意:检测库只建议在开发环境使用。线上开启会影响性能,而且会暴露内部实现细节。

布局抖动的代价有多大?

我曾经在一个数据看板项目里做过测试。一个包含 500 个 DOM 节点的列表,用循环读写布局的方式渲染,耗时约 120ms。改成批量读写后,耗时降到 8ms。差了 15 倍。

为什么代价这么大?因为布局计算本身就很重。浏览器要遍历 DOM 树,计算每个元素的位置和尺寸。而且布局是全局性的——你改一个元素的宽度,可能影响它后面的所有兄弟元素,甚至父元素。

一张图看懂布局抖动

下面这张图展示了正常渲染流程和布局抖动的区别:

布局抖动 vs 正常渲染流程对比 正常渲染流程 vs 布局抖动 ✅ 正常流程(异步布局) JS 执行 样式计算 布局计算 绘制 → 一次流水线,高效 ❌ 布局抖动(强制同步布局) 写样式 读布局 写样式 读布局 写样式 → 每次读写都触发一次布局,循环 N 次 💡 关键区别 正常流程:攒一批样式变更 → 统一布局 → 绘制(1次布局) 布局抖动:写样式 → 读布局 → 写样式 → 读布局 → ...(N次布局)

避坑指南

我曾经在写一个拖拽排序组件时,每拖拽一次就调用 getBoundingClientRect() 来获取所有元素的位置。结果拖到第 10 个元素时,页面直接卡死。后来改成在拖拽开始前一次性读取所有位置,拖拽过程中只做样式变更,问题就解决了。

这里总结几个我踩过的坑:

  • 不要在 requestAnimationFrame 里读写交替——rAF 回调里最容易出现布局抖动
  • 不要在 resize 事件里读布局属性——resize 触发频率高,叠加布局抖动就是灾难
  • 不要在循环里交替读写——把读操作提到循环外面,或者用 requestAnimationFrame 分批处理
  • 注意第三方库——有些 UI 库内部会频繁读写布局,集成时最好做一次性能审计

快速自查口诀:写后即读,布局抖哭;先读后写,性能起飞。

小结

布局抖动的本质,就是打破了浏览器的异步渲染流水线。检测方法不难,难的是养成“读写分离”的编码习惯。下一章我会讲怎么用 FastDOM 和 批量读写 来根治这个问题。

嗯,先消化一下今天的内容。打开你的项目,跑一遍 Performance 面板,看看有没有红色警告的 Layout 任务。有的话,恭喜你,找到优化点了。