useEffect vsuseLayoutEffect

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

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

1. 两者解决的问题不同

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

useEffect

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

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

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

它在 Commit 后由 React 调度执行,通常会让浏览器先完成绘制。但“绘制后执行”不是绝对的:交互触发的更新可能有不同调度时机。业务代码不应依赖一个过于绝对的时间顺序。

useLayoutEffect

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

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

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

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

2. 执行顺序

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

Render
React 更新 DOM
useLayoutEffect setup
浏览器绘制
useEffect setup(通常)

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

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

具体 passive effect 的调度可能受 React 更新来源影响,但可以稳定依赖的原则是:每个 effect 的 cleanup 应撤销它自己的订阅、定时器或 DOM 外部操作。

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。

4. 哪些场景不该用useLayoutEffect

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

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

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

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

下面这些行为尤其不应该放在 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。

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

总结

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

面试时可以这样回答:默认使用 useEffect 与外部系统同步;如果必须在浏览器绘制前测量或修正布局,使用 useLayoutEffect,但要注意它会阻塞绘制并可能在 SSR 中产生警告。useInsertionEffect 主要供 CSS-in-JS 库使用,不是普通业务 effect 的替代品。