React useInsertionEffect

深入理解 useInsertionEffect 的执行时机,为什么它主要是给 CSS-in-JS 库用的,以及普通业务组件几乎不需要它的原因。

React useInsertionEffect

问题的起点:CSS-in-JS 的挑战

在 React 中渲染动态样式有一个微妙的问题:如果你用 JavaScript 来生成和插入 CSS(比如 CSS-in-JS 方案),CSS 样式表需要在对应的 DOM 元素被插入到 DOM 树之前就准备好。否则用户会看到「无样式内容闪烁」(FOUC — Flash of Unstyled Content)。

考虑 styled-components 或 emotion 这样的库是怎么工作的:

import styled from 'styled-components';

const Button = styled.button`
  background: blue;
  color: white;
`;

Button 组件首次渲染时,styled-components 需要:

  1. 生成一个 <style> 标签,包含 Button 的 CSS 规则
  2. 把这个 <style> 标签插入到 <head>
  3. 然后渲染真正的 <button> DOM 元素

问题是:步骤 2 和步骤 3 的顺序很关键。如果 <button> 先渲染,然后 <style> 才插入,用户会先看到无样式的按钮,然后才看到蓝色按钮。

这就是 useInsertionEffect 要解决的问题。


useEffect、useLayoutEffect、useInsertionEffect 的执行顺序

React 有三个「effect」Hooks,它们的执行时机各不相同:

flowchart TD
    A[React render] --> B[Virtual DOM diff]
    B --> C[DOM 更新]
    C --> D[useInsertionEffect 执行<br/>DOM 插入前]
    D --> E[浏览器 layout 计算]
    E --> F[useLayoutEffect 执行<br/>DOM 更新后,绘制前]
    F --> G[浏览器 paint 绘制]
    G --> H[useEffect 执行<br/>绘制完成后]

关键区别在于:

Hook 执行时机 能否读取 DOM 用途
useInsertionEffect DOM 变更,React 发现需要插入新元素时 不能 CSS-in-JS 库注入样式
useLayoutEffect DOM 变更,浏览器绘制 DOM 测量、同步布局
useEffect 浏览器绘制完成后 异步副作用、数据获取

实际验证

function Component() {
  useEffect(() => {
    console.log('useEffect');
  }, []);

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

  useInsertionEffect(() => {
    console.log('useInsertionEffect');
  }, []);

  return <div>Test</div>;
}

// 输出顺序:
// 1. useInsertionEffect
// 2. useLayoutEffect
// 3. useEffect

为什么 useInsertionEffect 主要是给 CSS-in-JS 库用的

useInsertionEffect 的设计目标是在 DOM 元素被插入之前 执行,此时:

  • 新的 DOM 元素还没有被创建
  • React 知道要插入什么元素,但还没有真正插入

对于 CSS-in-JS 库来说,这个时机是完美的:

  1. React 决定要渲染一个 <button class="sc-button-abc">
  2. 在 button 真正插入 DOM 之前,先触发 useInsertionEffect
  3. CSS-in-JS 库在 useInsertionEffect 里注入 <style> 标签
  4. 然后 button 才被插入 DOM,此时样式已经存在

styled-components 的实现思路

styled-components(以及其他 CSS-in-JS 库)内部大致是这样使用 useInsertionEffect 的:

// styled-components 内部伪代码
function useStyledComponent(comp) {
  useInsertionEffect(() => {
    // 1. 生成 CSS 规则
    const css = generateCSS(comp);

    // 2. 创建 style 标签
    const styleEl = document.createElement('style');
    styleEl.textContent = css;

    // 3. 插入到 head
    document.head.appendChild(styleEl);

    // 4. 返回清理函数
    return () => {
      styleEl.remove();
    };
  }, [comp]);

  // 返回组件的 props
  return comp.props;
}

这样确保了:当 <button> 被插入 DOM 时,对应的 <style> 标签已经存在,样式立即生效。

emotion 的类似实现

emotion 的实现思路类似,但可能用不同的优化策略(比如批量插入样式、缓存等)。


为什么普通业务组件几乎不需要 useInsertionEffect

这是最重要的理解点:useInsertionEffect 是库作者才需要关心的 API,不是业务代码应该用的

理由一:大多数样式不需要动态注入

如果你在写业务组件,你的 CSS 通常是:

  • CSS 文件中的静态样式
  • CSS Modules
  • Tailwind CSS

这些场景下,样式在构建时就存在,不需要在运行时动态注入。

理由二:useEffect 已经足够

如果你的组件需要在渲染后做一些和样式相关的事情,useEffect 通常已经足够:

// 业务代码:正确用法
function Tooltip() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (isOpen) {
      // 添加 visible class
      document.body.classList.add('tooltip-open');
    }
    return () => {
      document.body.classList.remove('tooltip-open');
    };
  }, [isOpen]);

  return isOpen ? <div className="tooltip">Content</div> : null;
}

理由三:useInsertionEffect 有严格限制

useInsertionEffect 有两个重要限制:

  1. 不能读取 DOM:在 useInsertionEffect 执行时,DOM 还没有更新,你不能调用 getBoundingClientRect() 等读取 DOM 的 API
  2. 不能 setState:在 useInsertionEffect 中调用 setState 会报错,因为这个阶段不能触发更新
// 错误:在 useInsertionEffect 中 setState
function Component() {
  useInsertionEffect(() => {
    setState({ loaded: true }); // 报错!不能在 insertion effect 中 setState
  }, []);

  return <div>Content</div>;
}

// 错误:在 useInsertionEffect 中读取 DOM
function Component() {
  const ref = useRef(null);

  useInsertionEffect(() => {
    const width = ref.current?.getBoundingClientRect().width; // 可能不准确
  }, []);

  return <div ref={ref}>Content</div>;
}

如果你在写业务组件,正确选择是 useEffect 或 useLayoutEffect

场景 应该用
数据获取、订阅 useEffect
DOM 测量(宽高、位置) useLayoutEffect
动态 CSS 注入 用 CSS 文件、CSS Modules、或已经封装好的 CSS-in-JS 库
清理副作用 useEffect 的 cleanup 函数

useInsertionEffect 的限制详解

限制一:不能读取 DOM

useInsertionEffect 运行在 DOM 更新之前,这意味着你不能依赖它来做 DOM 测量:

// 不推荐的用法
function Component() {
  const containerRef = useRef(null);

  useInsertionEffect(() => {
    // containerRef.current 还没有对应的 DOM 元素
    // 或者即使有,DOM 还没有被插入到 document 中
    console.log(containerRef.current?.getBoundingClientRect());
  }, []);

  return <div ref={containerRef}>Content</div>;
}

限制二:不能 setState

React 不允许在 useInsertionEffect 中调用 setState,因为这个阶段不能安全地触发更新:

function Component() {
  useInsertionEffect(() => {
    // 这会抛出错误
    // "Cannot update state during render"
  }, []);

  return <div>Content</div>;
}

限制三:只在客户端执行

useInsertionEffect 只在客户端执行,在服务器端渲染时不会运行。如果你需要 SSR 兼容的样式注入逻辑,需要用其他方式。


面试中的表达

面试官问 useInsertionEffect,通常是想确认你理解 React 的渲染机制和不同 effect 的使用场景:

useInsertionEffect、useLayoutEffect 和 useEffect 三个 Hook 的执行时机不同。useInsertionEffect 在 DOM 元素被插入之前执行,此时 DOM 还没有真正更新;useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,可以读取 DOM;useEffect 在浏览器绘制完成后执行,适合异步的副作用。

useInsertionEffect 主要是给 CSS-in-JS 库用的,比如 styled-components 或 emotion。这些库需要在 DOM 元素插入之前就把 CSS 注入到 <head> 里,否则会出现无样式内容闪烁。普通业务组件不需要关心 useInsertionEffect,因为你的样式是静态的或者已经通过 CSS 文件加载好了。

如果你在写业务组件,需要同步读取 DOM 用 useLayoutEffect,需要异步副作用用 useEffect。几乎不需要用 useInsertionEffect,除非你在写一个 CSS-in-JS 库。


延展阅读