useEffect vs useLayoutEffect

副作用应该什么时候执行?什么时候需要阻塞浏览器绘制?

先记住选择原则:默认使用 useEffect。只有必须在浏览器绘制前读取或修改布局,并且不能接受用户看到中间状态时,才使用 useLayoutEffect

1. 两者解决的问题不同

两者都在组件提交更新后运行,并且都可以返回 cleanup;区别在于它们相对于浏览器绘制的时机不同。

useEffect

useEffect(() => {
  const subscription = subscribe(userId);
  return () => subscription.unsubscribe();
}, [userId]);

useEffect 适合把 React 与外部系统同步,例如:

  • 请求数据或同步网络状态;
  • 订阅事件、WebSocket、定时器;
  • 发送埋点;
  • 调用不影响首屏布局的第三方 API。

它在 Commit 后由 React 调度执行,通常会让浏览器先完成绘制。但“绘制后执行”不是绝对的:如果更新由用户交互触发,React 可能会在浏览器绘制前运行某些 effect,让事件系统或外部观察者能看到 effect 的结果。

所以业务代码不要依赖“useEffect 一定在 paint 后”这种过于绝对的时间顺序。只要逻辑必须在用户看到页面前完成,就不应该交给 useEffect 赌调度时机,而应该考虑 useLayoutEffect 或重新设计布局方案。

useLayoutEffect

useLayoutEffect(() => {
  const rect = elementRef.current.getBoundingClientRect();
  setPosition(rect);
}, []);

useLayoutEffect 会在 DOM 更新后、浏览器绘制前同步执行。它适合:

  • 测量 DOM 尺寸或位置;
  • 根据测量结果同步调整布局;
  • 初始化必须在绘制前完成的 DOM 第三方库;
  • 避免用户看到明显的布局跳动。

它会阻塞浏览器绘制,因此回调必须短小,不能在里面执行昂贵计算、网络请求或长时间循环。

如果在 useLayoutEffect 中调用 setState,React 会在浏览器绘制前同步处理这次更新,并继续执行剩余的 layout effect。这样可以避免用户看到错误位置或中间状态,但代价是首帧绘制会被进一步推迟。

2. 执行顺序

一次更新可以简化理解为:

Render

React 更新 DOM

useLayoutEffect setup

浏览器绘制

useEffect setup(通常)

useLayoutEffect 不是在 render 前执行,也不是 React 真的监听到了浏览器的 paint 时刻。它能发生在绘制前,是因为 React 把它放进了同步的 Commit 流程中。

浏览器绘制需要等待当前 JavaScript 任务结束。一次提交中,React 会先把变更写入真实 DOM,然后立刻同步执行 layout effect;如果 layout effect 里触发了状态更新,React 还会在绘制前继续处理这次更新。等这段同步工作全部结束后,浏览器才有机会进行 layout、paint。

所以可以这样理解:

render 前:不是 useLayoutEffect 的时机
render 中:也不是,render 应保持纯净
commit 后、paint 前:useLayoutEffect 的时机

当依赖变化时,React 会先清理旧 effect,再执行新的 setup:

旧 useLayoutEffect cleanup → 新 useLayoutEffect setup
旧 useEffect cleanup       → 新 useEffect setup

这个顺序是面试时的简化模型。更准确地说,同一种 effect 重新执行前会先清理上一次的 effect;layout effect 属于同步的提交阶段,会在浏览器绘制前处理;passive effect(也就是 useEffect)由 React 之后调度,具体时机可能受更新来源和调度策略影响。

可以稳定依赖的原则是:每个 effect 的 cleanup 应撤销它自己的订阅、定时器或 DOM 外部操作,不要依赖不同组件、不同 effect 之间过于细粒度的全局执行顺序。

3. DOM 测量示例

假设要根据 tooltip 自身尺寸设置位置:

function Tooltip({ children }) {
  const tooltipRef = useRef(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    const element = tooltipRef.current;
    if (!element) return;

    const rect = element.getBoundingClientRect();
    setPosition({
      top: rect.top - rect.height,
      left: rect.left,
    });
  }, []);

  return (
    <div
      ref={tooltipRef}
      style={{ top: position.top, left: position.left }}
    >
      {children}
    </div>
  );
}

如果使用 useEffect,浏览器可能先绘制初始位置,再执行测量和更新,用户可能看到一次闪烁。useLayoutEffect 可以在绘制前完成这次修正。

但更好的方案通常是优先使用 CSS 布局、CSS Anchor Positioning 或成熟的定位库;只有确实需要 JavaScript 测量时,才引入布局 effect。

真实项目还要考虑尺寸变化:窗口 resize、字体加载、内容异步变化、容器滚动都可能让初次测量失效。这时通常需要配合 ResizeObserver、滚动监听、重新测量逻辑,或直接使用 Floating UI、Popper 这类定位库。

4. 哪些场景不该用 useLayoutEffect

以下场景通常使用 useEffect 即可:

useEffect(() => {
  document.title = title;
}, [title]);

useEffect(() => {
  const timer = setInterval(refresh, 30_000);
  return () => clearInterval(timer);
}, [refresh]);

不要仅因为“想让代码更快”就使用 useLayoutEffect。它不会让网络请求更快,也不会减少渲染次数,反而可能延迟绘制,造成卡顿。

一个常见误区是:认为 useLayoutEffectuseEffect “更早执行,所以性能更好”。实际恰好相反,它把工作塞进浏览器绘制前的关键路径中。如果里面读取布局后又写样式,或者连续触发布局读取和写入,还可能造成 forced reflow,使页面更卡。

下面这些行为尤其不应该放在 layout effect 中:

  • 数据请求;
  • 日志和埋点;
  • 大量计算;
  • 可以通过 CSS 完成的样式设置;
  • 与布局无关的订阅。

5. useInsertionEffect 是什么?

useInsertionEffect 的执行时机比 useLayoutEffect 更早,主要为 CSS-in-JS 库插入样式设计。普通业务组件不应使用它:

  • 它不能访问已经更新的 DOM;
  • 不适合读取布局;
  • 不适合替代 useEffectuseLayoutEffect

可以这样记:

useInsertionEffect:注入样式
useLayoutEffect:读取/修正布局
useEffect:与外部系统同步

6. SSR 注意事项

服务端没有真实 DOM,也没有浏览器布局,因此 effect 不会在服务端执行。useLayoutEffect 在服务端渲染时通常会产生警告,因为它要求在绘制前执行,但服务端不存在这个阶段。

仅在回调内部判断 window 不能消除这个警告:

// ❌ 仍然声明了 useLayoutEffect,SSR 阶段仍可能警告
useLayoutEffect(() => {
  if (typeof window !== "undefined") {
    // DOM 操作
  }
}, []);

常见处理方式是让依赖布局的组件只在客户端渲染,或在明确需要同构支持时使用封装:

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

function MeasuredComponent() {
  useIsomorphicLayoutEffect(() => {
    // 客户端测量布局;服务端退化为普通 effect
  }, []);

  return <div />;
}

使用这种封装时仍要确认:服务端输出和客户端首次渲染必须保持一致,避免 hydration mismatch。

在 Next.js 等 SSR 框架中,如果组件本身强依赖真实 DOM 尺寸,通常可以把这部分拆成 client-only 组件,或先渲染一个服务端和客户端一致的占位状态,等客户端挂载后再测量和修正。

7. Strict Mode 与 cleanup

开发环境 Strict Mode 可能额外执行一次 setup → cleanup → setup,用来发现副作用是否可重复初始化、是否正确清理。这不是 useLayoutEffectuseEffect 的异常行为。

useLayoutEffect(() => {
  const element = ref.current;
  element?.addEventListener("scroll", handleScroll);

  return () => {
    element?.removeEventListener("scroll", handleScroll);
  };
}, [handleScroll]);

无论使用哪一种 effect,都应该让 setup 和 cleanup 成对出现。不要依赖“只执行一次”的假设,也不要在 cleanup 中执行与旧资源无关的新业务操作。

8. 选择流程

可以按下面的顺序判断:

  1. 这段逻辑是否在与外部系统同步?如果不是,先确认是否根本不需要 effect。
  2. 如果需要 effect,是否必须在绘制前读取或修改布局?
  3. 如果不需要,使用 useEffect
  4. 如果需要,使用 useLayoutEffect,并尽量缩短同步工作。
  5. 如果只是 CSS-in-JS 样式注入,才考虑由库使用 useInsertionEffect

9. 面试深挖点

为什么 useEffect 不能解决闪烁?

因为 useEffect 通常不会阻塞浏览器绘制。组件先按初始状态提交到 DOM,浏览器可能已经把这个中间状态画出来,然后 effect 才测量 DOM、更新位置,用户就会看到一次跳动。

useLayoutEffect 的区别不是“更快”,而是“更早且同步”:它能在绘制前读取布局并触发修正更新,让用户只看到修正后的结果。

为什么不能全部换成 useLayoutEffect

因为它会阻塞绘制。浏览器必须等 layout effect 执行完,甚至等其中触发的同步更新完成后,才能把页面画出来。少量 DOM 测量可以接受,网络请求、日志、订阅、大计算放进去就会拖慢首帧和交互响应。

它们和类组件生命周期怎么类比?

可以粗略类比:

  • useLayoutEffect 接近 componentDidMount / componentDidUpdate 中同步读取 DOM、调整布局的场景;
  • useEffect 更适合 componentDidMount / componentDidUpdate 中异步订阅、请求、埋点等不阻塞绘制的场景。

但 Hook 不应该机械等同于生命周期。effect 描述的是“这次渲染后如何与外部系统同步”,依赖数组决定它响应哪些渲染数据,而不是单纯模拟 mount/update。

总结

Hook大致时机是否阻塞绘制典型用途
useEffect提交后由 React 调度,通常在绘制后通常不阻塞请求、订阅、埋点、定时器
useLayoutEffectDOM 更新后、绘制前同步执行会阻塞DOM 测量、布局修正
useInsertionEffect更早的提交阶段会影响提交CSS-in-JS 插入样式

面试时可以这样回答:默认使用 useEffect 与外部系统同步;如果必须在浏览器绘制前测量或修正布局,使用 useLayoutEffectuseLayoutEffect 中触发的更新会在绘制前同步处理,所以能避免闪烁,但也会阻塞绘制、拖慢首帧,并且在 SSR 中需要额外处理。useInsertionEffect 主要供 CSS-in-JS 库使用,不是普通业务 effect 的替代品。