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 需要:
- 生成一个
<style>标签,包含 Button 的 CSS 规则 - 把这个
<style>标签插入到<head>中 - 然后渲染真正的
<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 库来说,这个时机是完美的:
- React 决定要渲染一个
<button class="sc-button-abc"> - 在 button 真正插入 DOM 之前,先触发
useInsertionEffect - CSS-in-JS 库在
useInsertionEffect里注入<style>标签 - 然后 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 有两个重要限制:
- 不能读取 DOM:在
useInsertionEffect执行时,DOM 还没有更新,你不能调用getBoundingClientRect()等读取 DOM 的 API - 不能 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 库。
延展阅读
- React Docs: useInsertionEffect — 官方 useInsertionEffect 文档
- styled-components RFC: useInsertionEffect — styled-components 团队对 useInsertionEffect 的讨论
- Emotion: CSS-in-JS 性能 — emotion 官方文档
- React 18 useInsertionEffect 设计决策 — 相关的 RFC 文档