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 的最佳实践

在实际项目中,我一般会这样组合使用:

  1. 静态页面:直接用读写分离,两个循环搞定。
  2. 滚动事件:用 rAF 节流,在回调外读取滚动位置,回调内更新样式。
  3. 动画循环:在 rAF 回调开始前批量读取,回调内批量写入。
  4. 复杂交互:使用 FastDOM 这类工具库,自动帮你做读写调度。

说到 FastDOM,它本质上就是一个读写分离的调度器。你调用它的 .read() 和 .write() 方法,它会自动把操作排队,在合适的时机批量执行。我个人习惯在大型项目里直接引入它,省心。

本章知识体系

下面这张图总结了布局抖动的成因和优化路径,你可以对照着梳理一下思路:

布局抖动(Layout Thrashing)知识体系 布局抖动 强制同步布局 读写交替执行 布局缓存失效 方案一:读写分离 方案二:requestAnimationFrame 批量读取 → 批量写入 rAF 外读 → rAF 内写 使用 FastDOM 工具库 布局次数从 N 次降为 1 次 核心原则:永远不要让「写样式」和「读布局」交替执行

嗯,布局抖动这个问题,说白了就是「读写顺序没管好」。你只要记住一个原则:读归读,写归写,别混在一起。配合 rAF 做时机调度,性能就能稳稳地保持在 60fps。

下次你在 Chrome DevTools 的 Performance 面板里看到大片的红色「Layout」标记时,别慌。先检查一下代码里有没有读写交替的情况,八成就是它了。