React useLayoutEffect

深入理解 useLayoutEffect 与 useEffect 的本质区别,同步 vs 异步的执行时机,以及在 DOM 测量、SSR 等场景下的正确使用方式。

React useLayoutEffect

问题的起点:为什么 useEffect 会闪烁

想象一个场景:你需要根据某个元素的宽度来动态决定另一个元素的尺寸。如果用 useEffect 来获取这个宽度,可能会看到明显的闪烁——元素先以默认尺寸渲染,然后突然跳转到正确尺寸。

function ResizableBox() {
  const [width, setWidth] = useState(100);
  const [boxWidth, setBoxWidth] = useState(200);

  // 用 useEffect 获取宽度
  useEffect(() => {
    const handleResize = () => {
      const containerWidth = containerRef.current.offsetWidth;
      setWidth(containerWidth);
      setBoxWidth(containerWidth * 0.8); // box 宽度是容器的 80%
    };

    window.addEventListener('resize', handleResize);
    handleResize(); // 初始获取

    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return (
    <div ref={containerRef} style={{ width: '100%' }}>
      <div style={{ width: boxWidth, height: 100, background: 'blue' }}>
        Box
      </div>
    </div>
  );
}

用户可能会看到 box 先以 200px 渲染,然后跳转到正确的尺寸(如果是 400px 的话)。这个闪烁是因为 useEffect 在浏览器完成绘制(paint)之后才执行。

useLayoutEffect 就是用来解决这个问题的。


useEffect vs useLayoutEffect:本质区别

执行时机的差异

flowchart TD
    A[React render] --> B[Virtual DOM diff]
    B --> C[DOM 更新]
    C --> D[浏览器 layout 计算]
    D --> E[浏览器 paint 绘制]
    E --> F{使用 useLayoutEffect?}
    F -->|是| G[同步执行 effect<br/>阻塞浏览器渲染]
    F -->|否| H[useEffect 执行<br/>异步,不阻塞渲染]
    G --> I[继续后续渲染]
    H --> I

关键区别在于:

  • useEffect:在 React 完成 DOM 更新并且浏览器完成绘制之后异步执行
  • useLayoutEffect:在 React 完成 DOM 更新但在浏览器绘制之前同步执行

时序图对比

                    useEffect                          useLayoutEffect
                    --------                          ---------------

React render    |--------|
                |        |
DOM update      |        |--------|
                              |    |
Browser paint   |        |         |--------|
                              |         |     |
useEffect fires |        |              |-----|
(after paint)   |        |                   |
                              |              |
                              |              |
useLayoutEffect fires         |--------------|
(before paint)              |              |
                              |              |

代码验证

function TimingExample() {
  const [value, setValue] = useState('initial');

  useEffect(() => {
    console.log('useEffect fires:', value);
  }, [value]);

  useLayoutEffect(() => {
    console.log('useLayoutEffect fires:', value);
  }, [value]);

  return (
    <button onClick={() => setValue('updated')}>
      Click me
    </button>
  );
}

// 点击按钮后的输出顺序:
// 1. useLayoutEffect fires: updated  (在 paint 之前)
// 2. useEffect fires: updated         (在 paint 之后)

useLayoutEffect 在 DOM 测量中的应用场景

获取 DOM 元素尺寸和位置

useLayoutEffect 最常见的用途是在 DOM 更新后立即获取元素的精确尺寸,这在需要基于其他元素尺寸做布局时特别有用:

function Tooltip({ children, text }) {
  const [isOpen, setIsOpen] = useState(false);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef(null);

  // 必须在 paint 之前执行,否则会看到 tooltip 先出现在错误位置再跳转到正确位置
  useLayoutEffect(() => {
    if (isOpen && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPosition({
        top: rect.bottom + 8,
        left: rect.left
      });
    }
  }, [isOpen]);

  return (
    <>
      <span ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
        {children}
      </span>
      {isOpen && (
        <div
          style={{
            position: 'fixed',
            top: position.top,
            left: position.left
          }}
        >
          {text}
        </div>
      )}
    </>
  );
}

获取滚动位置

在某些场景下,你可能需要在 DOM 更新后获取滚动位置来恢复或同步滚动状态:

function ChatMessages({ messages }) {
  const containerRef = useRef(null);
  const [autoScroll, setAutoScroll] = useState(true);

  useLayoutEffect(() => {
    if (autoScroll && containerRef.current) {
      // 在 paint 之前滚动,否则用户会看到滚动过程
      containerRef.current.scrollTop = containerRef.current.scrollHeight;
    }
  }, [messages, autoScroll]);

  return (
    <div
      ref={containerRef}
      style={{ height: 400, overflowY: 'auto' }}
      onScroll={() => {
        // 用户手动滚动时,禁用自动滚动
        const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
        if (scrollTop + clientHeight < scrollHeight - 10) {
          setAutoScroll(false);
        }
      }}
    >
      {messages.map(msg => (
        <div key={msg.id}>{msg.text}</div>
      ))}
    </div>
  );
}

Focus 管理

当 DOM 更新后需要立即 focus 某个元素时,useLayoutEffect 防止 focus 导致布局变化时的闪烁:

function AutoFocusInput() {
  const inputRef = useRef(null);

  useLayoutEffect(() => {
    // 立即 focus,不给用户看到光标在错误位置的瞬间
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}

同步 vs 异步:性能影响

useLayoutEffect 会阻塞浏览器渲染

这是使用 useLayoutEffect 时最重要的考量:只要 useLayoutEffect 的回调没有执行完,浏览器就无法开始绘制

// 糟糕的例子:useLayoutEffect 中执行耗时操作
function SlowComponent() {
  useLayoutEffect(() => {
    // 假设这是一个复杂的计算或数据处理
    const result = heavyComputation(); // 阻塞!

    // 在此期间,用户什么都看不到——浏览器无法绘制任何东西
    doSomethingWith(result);
  }, []);

  return <div>Content</div>;
}

这与 useEffect 形成对比:useEffect 不会阻塞浏览器,浏览器可以在 effect 执行期间并行地进行其他工作。

什么时候绝对不应该用 useLayoutEffect

1. 数据获取(Data Fetching)

// 错误:在 useLayoutEffect 中发起请求
useLayoutEffect(() => {
  fetchData().then(setData); // 阻塞?不必要!useEffect 足够了
}, []);

// 正确
useEffect(() => {
  fetchData().then(setData);
}, []);

2. 订阅/事件监听(Subscriptions/Event Listeners)

// 错误
useLayoutEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

// 正确:这些不会影响 DOM 测量,用 useEffect
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

3. 定时器(Timers)

// 错误
useLayoutEffect(() => {
  const id = setInterval(() => {
    doSomething();
  }, 1000);
  return () => clearInterval(id);
}, []);

// 正确
useEffect(() => {
  const id = setInterval(() => {
    doSomething();
  }, 1000);
  return () => clearInterval(id);
}, []);

判断标准:什么时候该用 useLayoutEffect

用 useLayoutEffect 当且仅当:

  1. 需要读取 DOM 布局信息(如 getBoundingClientRectoffsetWidthoffsetHeight
  2. 需要在 DOM 更新后立即执行某个操作,而这个操作的视觉效果需要在下一次 paint 之前呈现

其他所有场景,用 useEffect。

一个简单的判断方法是问自己:**如果我在这里用 useEffect,用户会看到闪烁(flicker)或跳帧(jank)吗?**如果答案是肯定的,就用 useLayoutEffect


SSR 中 useLayoutEffect 的注意事项

问题:useLayoutEffect 在 SSR 中不存在

在服务器端渲染时,windowdocument 等浏览器 API 是不存在的。React 的 useEffect 在 SSR 环境下是安全的,因为它的回调永远不会在服务器上同步执行。

useLayoutEffect 有一个不同的行为:它在 SSR 时会抛出警告,因为它无法在服务器上工作

Warning: useLayoutEffect does nothing on the server, because its effect cannot
be encoded in the server renderer's output format. This will happen on the
client. Use useEffect instead if you need to use useLayoutEffect.

解决方案:使用 useEffect 或条件渲染

方案一:如果可能,用 useEffect 代替

大多数情况下,useEffect 都可以替代 useLayoutEffect。只有在需要同步读取 DOM 布局的场景下才需要 useLayoutEffect

方案二:只在客户端执行

function ClientOnlyLayoutEffect({ children }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  // SSR 时不渲染需要 useLayoutEffect 的内容
  if (!mounted) return null;

  return children;
}

function MyComponent() {
  return (
    <ClientOnlyLayoutEffect>
      <ComponentWithLayoutEffect />
    </ClientOnlyLayoutEffect>
  );
}

方案三:使用 SSR 兼容的替代方案

如果确实需要 useLayoutEffect 的功能(如动画同步),考虑使用 CSS 动画或 requestAnimationFrame 来避免对 useLayoutEffect 的依赖。


useLayoutEffect 与 useEffect 的选择决策树

                    需要执行副作用吗?
                         |
                         |
          +--------------+--------------+
          |                              |
         是                              否
          |                              |
          v                              v
    需要读取 DOM                 考虑是否需要
    布局信息吗?                 useCallback/useMemo
          |                              |
    +-----+-----+                        |
    |           |                        |
   是          否                        |
    |           |                        |
    v           v                        v
useLayout    useEffect              不需要 Hook
  Effect

常见误区

误区一:useLayoutEffect 总是"更好"

初学者可能会认为,既然 useLayoutEffect 执行得更早,它一定"更好"。这是错误的。

  • useEffect 不阻塞渲染,有更好的用户体验
  • 在大多数场景下,useEffect 都能正确工作
  • useLayoutEffect 只在特定场景下才是必需的

误区二:在 useLayoutEffect 中做数据获取

这是非常糟糕的做法,因为:

  1. 它会阻塞浏览器渲染
  2. SSR 时会出问题
  3. useEffect 完全够用

误区三:混淆 useLayoutEffect 和 componentDidMount

虽然 useLayoutEffect 的调用时机类似于 componentDidMount + componentDidUpdate(同步执行),但它有 cleanup 函数,在 props 变化时 cleanup 会先执行,然后新的 effect 执行。这与 class component 的生命周期不完全一致。


面试中的表达

面试官问 useLayoutEffect,通常是想确认你理解 React 的渲染机制和性能优化原则:

useLayoutEffect 和 useEffect 的核心区别是执行时机。useLayoutEffect 在 DOM 更新后、浏览器绘制之前同步执行,会阻塞浏览器渲染;useEffect 在浏览器绘制完成之后异步执行,不阻塞渲染。

什么时候用 useLayoutEffect?当需要读取 DOM 布局信息(比如 getBoundingClientRect)并且需要基于这个信息做视觉更新时,用 useLayoutEffect 可以避免闪烁。其他所有场景,useEffect 都是更好的选择,因为它不会阻塞浏览器渲染。

在 SSR 环境下,useLayoutEffect 会发出警告,因为它在服务器上无法工作。如果在 SSR 场景下需要用到 useLayoutEffect 的功能,需要做条件渲染或者使用客户端包装组件。


延展阅读