重绘与回流

在开发复杂的前端交互时,你大概率遇到过这样的挫败感:明明只写了几十行 JS 去实现一个列表项的拖拽或者滚动加载动画,结果整个页面卡得像播 PPT 一样。打开 Chrome 的 Performance 面板一录制,长长的红色预警赫然标示着两个单词:Layout Thrashing (布局抖动)

导致网页卡顿的头号杀手,往往不是你的业务逻辑写得不够精妙,而是你无意中疯狂地触发了浏览器的重绘(Repaint)回流(Reflow/Layout)。要写出丝滑的 60fps 动画和流畅的页面,我们必须深入了解这两个渲染底层的核心概念。

浏览器渲染的流水线

当浏览器拿到 HTML 和 CSS 后,想要把它渲染成屏幕上的像素,大体会经历以下几个步骤这四个字构成的流水线: 构建 DOM 树 -> 构建 CSSOM 树 -> 生成渲染树 (Render Tree) -> 布局 (Layout/Reflow) -> 绘制 (Paint) -> 合成 (Composite)。

我们常说的“回流”和“重绘”,就是在页面发生更新时,浏览器不得不退回并重新走一遍最后这几个极其耗费性能的步骤。

1. 回流 (Reflow / 重排) —— 牵一发而动全身

当页面中元素的几何尺寸、位置或者结构发生改变时,浏览器需要重新计算整个文档树中受影响的元素的几何信息,并重新排列它们。这个过程极其昂贵,因为它不仅计算自己,通常还要连带着重新计算父元素、子元素甚至兄弟元素的位置。

触发回流的高危动作:

  • 增删可见的 DOM 元素。
  • 改变元素的尺寸或位置(修改 widthheightpaddingmarginbordertopleft 等)。
  • 改变浏览器窗口尺寸 (resize)。
  • 内容发生变化(比如文本替换、图片加载完成导致撑开容器高度)。
  • 最隐层的杀手:查询某些布局属性。

🚨 致命杀手:强制同步布局 (Layout Thrashing)

很多新手不知道,仅仅是“读取”元素的某些属性,也可能强迫浏览器立即执行一次昂贵的回流! 由于浏览器对回流是有优化机制的(它会把修改操作放进一个队列里,等积攒够了或者到下一帧时一起执行),但是!如果你在 JS 中刚刚修改了样式,紧接着又去请求读取 offsetWidthclientHeightscrollTopgetComputedStyle() 等属性,为了能够准时返回给你最精确的最新数值,浏览器不得不立刻清空渲染队列,强行、立刻进行一次完整的回流。如果你把这种“读+写”的操作放进一个 for 循环里,那就是性能灾难。

2. 重绘 (Repaint) —— 换汤不换药

当元素的外观、风格发生改变,但**并没有改变它的几何布局(没有改变占地面积)**时,浏览器只需要把该元素的新外观重新画一遍即可,这个过程耗费的性能相对较小。

触发重绘的操作:

  • 修改 colorbackgroundvisibility
  • 修改 box-shadowborder-radius

💡 核心定律回流必将引起重绘!但重绘不一定引起回流。 这就像拆墙重建必定需要重新刷漆,但如果只是换个壁纸,并不需要把墙推倒。

实战演练:告别布局抖动狂魔

这不仅是一道理论题,更是实实在在的工程踩坑点。让我们亲眼看看“读写混合”(Layout Thrashing)是如何引发性能灾难,而“读写分离”又是如何拯救世界的。

Bad Case vs Good Case
import React, { useRef, useState } from "react";

export default () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [logs, setLogs] = useState<string[]>([]);
  const blockCount = 500; // 模拟大量DOM

  // ❌ 灾难写法:在循环中交替读写(触发 500 次独立的回流!)
  const triggerBadLayout = () => {
    const parent = containerRef.current;
    if (!parent) return;
    const children = parent.children as HTMLCollectionOf<HTMLElement>;
    const startTime = performance.now();

    for (let i = 0; i < children.length; i++) {
      const el = children[i];
      // 读:立刻强迫浏览器回流以获取最新宽度
      const width = el.offsetWidth;
      // 写:修改样式,将渲染队列弄脏
      el.style.width = width + 1 + "px";
    }

    const elapsed = (performance.now() - startTime).toFixed(2);
    setLogs((prev) => [`[读写混合] 耗时: ${elapsed} ms`, ...prev]);
  };

  // ✅ 黄金法则:把所有的读操作集中,再把写操作集中(只触发 1 次回流!)
  const triggerGoodLayout = () => {
    const parent = containerRef.current;
    if (!parent) return;
    const children = parent.children as HTMLCollectionOf<HTMLElement>;
    const startTime = performance.now();

    // 先集中批量读取(不脏缓存,极快)
    const widths = new Array(children.length);
    for (let i = 0; i < children.length; i++) {
      widths[i] = children[i].offsetWidth;
    }

    // 后集中批量写入(脏了缓存,但统一放进队列由浏览器稍后统一回流)
    for (let i = 0; i < children.length; i++) {
      children[i].style.width = widths[i] + 1 + "px";
    }

    const elapsed = (performance.now() - startTime).toFixed(2);
    setLogs((prev) => [`[读写分离] 耗时: ${elapsed} ms`, ...prev]);
  };

  return (
    <div style={{ border: "1px solid #ddd", padding: "20px", borderRadius: "8px" }}>
      <div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
        <button
          onClick={triggerBadLayout}
          style={{ background: "#ff4d4f", color: "#fff", padding: "5px 15px", border: "none", borderRadius: "4px", cursor: "pointer" }}
        >
          运行:读写混合 ()
        </button>
        <button
          onClick={triggerGoodLayout}
          style={{ background: "#52c41a", color: "#fff", padding: "5px 15px", border: "none", borderRadius: "4px", cursor: "pointer" }}
        >
          运行:读写分离 (极快)
        </button>
      </div>

      <div style={{ display: "flex", gap: "20px" }}>
        <div style={{ flex: 1, height: "150px", background: "#f5f5f5", overflowY: "auto", padding: "10px" }}>
          <h4>执行日志</h4>
          {logs.map((log, i) => (
            <div key={i} style={{ fontSize: "13px", color: log.includes("读写分离") ? "#52c41a" : "#ff4d4f" }}>
              {log}
            </div>
          ))}
        </div>

        <div ref={containerRef} style={{ flex: 1, background: "#fafafa", maxHeight: "150px", overflow: "hidden" }}>
          {/* 生成 500 个用于测试的色块 */}
          {Array.from({ length: blockCount }).map((_, i) => (
            <div key={i} style={{ width: "10px", height: "2px", background: "#1890ff", marginBottom: "1px" }} />
          ))}
        </div>
      </div>
    </div>
  );
};

(你可以多次点击按钮对比毫秒数,读写混合在大量 DOM 或低性能设备上往往比读写分离慢一个数量级以上。)

其他高级优化抗性装备

除了代码层面上严格的“读写分离”(通常可以借助像 fastdom 等库来统一收口外),我们还能在 CSS 层面做出以下努力:

  1. “离线”操作 DOM:如果需要对某一块 DOM 进行几十上百项的极度复杂计算和样式修改,最好的办法是先用 display: none 把这块 DOM 彻底藏起来(此时回流 1 次)。等在背后把几百次修改都做完后,再 display: block 把它请出来(再回流 1 次)。
  2. 硬件加速(合成层 Composite):现代浏览器架构中,对于部分属性的动画,压根就不会走主线程的回流或重绘阶段,而是直接推送到 GPU 的“合成线程”进行处理,性能爆表。
    • 尽量使用 transform (translate, scale, rotate) 来代替 top/left/width 去做动画位移和缩放。
    • 使用 opacity 来代替 visibility/display 去做透明度渐变动画。
    • 你可以通过给元素增加 will-change: transformtransform: translateZ(0) 来显式将其提拔为一个独立的 GPU 合成层。

高频面试题剖析

Q:说说你对重绘和回流的理解?在 React/Vue 项目中如果遇到动画或循环修改样式严重卡顿,你会如何排查并解决?

回答思路:

  1. 定义与区别:回流是改变了元素的几何尺寸触发重新布局排版;重绘是只改变了外观(如背景色)只重新上色。紧接着总结“回流必定引起重绘,重绘不一定引起回流,回流极其昂贵”。
  2. 点出隐蔽元凶(拿高分):提到浏览器有内部的渲染队列优化机制,能批量更新。但如果开发者在 JS 循环里同时发生了“读操作(获取 offsetWidth 等属性)”和“写操作(更改样式)”,就会打破这套优化,触发致命的“强制同步布局(Layout Thrashing)”。
  3. 给出解决法宝
    • 保证代码中的 DOM 读操作和写操作隔离(通过变量缓存读取值,再统一遍历写入)。
    • 将频繁修改的复杂 DOM 脱离文档流(display: none 或使用虚拟片段 DocumentFragment),修改完再塞回去。
    • 进行位移或形变动画时,严禁修改 top/left,全盘改用 transform 并开启硬件加速交由 GPU 处理重构,彻底绕过主线程的回流深坑。