
一句话:
useInsertionEffect是为 CSS-in-JS 库设计的第三个 Effect——它在「DOM 变更之前」同步执行,让「运行时生成的样式」赶在「浏览器计算布局」之前进入样式表,从而避免「额外一次重排」与「样式闪烁(FOUC)」。
一、三个 Effect 的完整时序
一次更新的完整时间轴(含三个 Effect):
① render 阶段(可中断)
· 执行组件函数、diff、收集 flags
② commit 阶段
a. before mutation(读取 DOM 快照,如 getSnapshotBeforeUpdate)
b. ⭐ useInsertionEffect 的清理 + 执行 ← 【DOM 变更之前】
c. mutation(把 DOM 变更应用到真实 DOM)
· 执行 useLayoutEffect 的【清理函数】
· 卸载的节点上把 ref 置为 null
d. layout
· 赋值新 ref
· ⭐ useLayoutEffect 的清理 + 执行 ← 【DOM 已更新、绘制前】
e. (commit 结束)
③ 浏览器绘制(paint) ← 用户在这一刻看到界面
④ passive effects(异步)
· ⭐ useEffect 的清理 + 执行 ← 【绘制之后】
| Effect | 执行时机 | 能读 DOM / ref | 能做副作用 | 会阻塞绘制 | 设计用途 |
|---|---|---|---|---|---|
useInsertionEffect |
DOM 变更之前 | ❌(DOM 还是旧的、ref 未赋值) | ⚠️ 只允许「插入样式」 | ✅ | CSS-in-JS 插入 <style> / insertRule |
useLayoutEffect |
DOM 变更后、绘制前 | ✅ | ✅(同步) | ✅ | 测量布局、避免闪烁 |
useEffect |
绘制之后(异步) | ✅ | ✅ | ❌ | 请求、订阅、日志 |
⭐ 一句话记住顺序:
useInsertionEffect(DOM 变更前)→ DOM 变更 → useLayoutEffect(绘制前)→ 绘制 → useEffect(绘制后)
二、为什么需要它:CSS-in-JS 的两个难题
难题一:用 useLayoutEffect 插样式 → 多一次重排
// ❌ 用 useLayoutEffect 插入运行时样式
function BadButton() {
useLayoutEffect(() => {
injectStyle('.btn { color: red; }'); // ⚠️ 此时 DOM 已经变更
});
return ;
}
时序问题:
① DOM 变更(
难题二:用 useEffect 插样式 → 样式闪烁
// ❌ 用 useEffect 插入
function BadCard() {
useEffect(() => {
injectStyle('.card { padding: 16px }'); // ⚠️ 绘制之后才插入
});
return 内容;
}
时序问题:
① DOM 变更 → ② 浏览器绘制(此时 .card 规则不存在)→ 用户看到「无样式的卡片」
③ useEffect 执行 → 插入样式 → ④ 浏览器重绘 → 用户看到「有样式的卡片」
⭐ 后果:用户看到「一闪而过的无样式内容」(FOUC)
—— 这在「首屏」和「服务端渲染后的水合」阶段尤其明显。
⭐⭐ 两个难题的本质:
样式必须【在浏览器计算布局之前】就位。
· useLayoutEffect 太晚(布局已经算过一轮)
· useEffect 更晚(绘制都发生了)
→ React 需要「在 DOM 变更之前」也提供一个 sync effect 位置。
→ 这就是 useInsertionEffect(React 18 引入)。
三、官方对它的定位与约束
⭐⭐ 关键定位(React 官方文档原话的意思):
「useInsertionEffect 是为 CSS-in-JS 库作者设计的。
如果你不是在构建 CSS-in-JS 库,你应该用 useEffect 或 useLayoutEffect。」
⭐ 三条硬性约束:
① 【不能读 ref】
DOM 还没变更 → ref 里还是「旧的元素」或 null
→ 读了也没有意义(而且规范明确不允许)
② 【不能 setState】
在这个阶段触发更新会打乱 commit 流程
→ 你会收到警告("Cannot update state from useInsertionEffect")
③ 【不能依赖它做任何「有观察意义」的事】
它只用来「往文档里插一段静态样式」——
不要去读写 DOM、不要做埋点、不要发起请求
// ✅ 唯一正确的用法:插入样式(无副作用、无状态、无 DOM 读写)
useInsertionEffect(() => {
const rule = sheet.insert(`.${key} { color: ${color} }`);
return () => sheet.remove(rule);
}, [key, color]);
四、完整实现:一个可运行的 mini CSS-in-JS(180 行)
// mini-css.ts —— 用 useInsertionEffect 实现一个极简 CSS-in-JS 运行时
import { useInsertionEffect, useMemo } from 'react';
/** ============ 1. 规则去重:同样的 CSS 只插一次 ============ */
function hash(input: string): string {
// djb2:快速、非加密、足够避免碰撞(生产库常用同类方案)
let h = 5381;
for (let i = 0; i < input.length; i++) {
h = ((h << 5) + h + input.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(36);
}
/** ============ 2. 容器:按「层(tag)」分组,保证插入顺序稳定 ============ */
interface Container {
el: HTMLStyleElement;
sheet: CSSStyleSheet;
/** ruleKey → 在 sheet.cssRules 里的下标(用于删除) */
index: Map;
/** ruleKey → 引用计数(多个组件共用同一条规则时不能提前删) */
refCount: Map;
}
const containers = new Map();
/** 服务端渲染时收集规则(服务端没有 DOM,走「渲染期收集」而不是 effect) */
const serverRules = new Map>();
function isServer() {
return typeof document === 'undefined';
}
function getContainer(tag: string): Container {
let c = containers.get(tag);
if (!c) {
const el = document.createElement('style');
el.setAttribute('data-mini-css', tag);
document.head.appendChild(el);
c = { el, sheet: el.sheet!, index: new Map(), refCount: new Map() };
containers.set(tag, c);
}
return c;
}
/**
* ⭐ 插入规则:用 sheet.insertRule() 而不是 el.textContent += css
* 原因:改 textContent 会让浏览器「重新解析整个样式表」;
* insertRule 是增量操作,代价小得多(这是所有 CSS-in-JS 库的共识)
*/
function insertRule(container: Container, ruleKey: string, css: string): void {
const count = container.refCount.get(ruleKey) ?? 0;
if (count === 0) {
const rule = `.${ruleKey}{${css}}`;
try {
const at = container.sheet.cssRules.length; // 追加到末尾,保证顺序
container.sheet.insertRule(rule, at);
container.index.set(ruleKey, at);
} catch (err) {
// 规则非法时不要让整个应用崩掉
console.warn('[mini-css] 插入规则失败:', css, err);
}
}
container.refCount.set(ruleKey, count + 1);
}
/** 移除规则:引用计数归零才真正删除 */
function removeRule(container: Container, ruleKey: string): void {
const count = (container.refCount.get(ruleKey) ?? 1) - 1;
if (count > 0) {
container.refCount.set(ruleKey, count);
return;
}
const at = container.index.get(ruleKey);
if (at !== undefined) {
try {
container.sheet.deleteRule(at);
} catch {
/* 已被移除则忽略 */
}
container.index.delete(ruleKey);
// ⚠️ 删除后,后面所有规则的下标都要前移 —— 这里做一次简单重算
container.index.forEach((value, key) => {
if (value > at) container.index.set(key, value - 1);
});
}
container.refCount.delete(ruleKey);
}
/** ============ 3. ⭐ 核心 Hook:在「DOM 变更之前」把样式插进去 ============ */
export function useStyle(css: string, tag = 'default'): string {
const ruleKey = useMemo(() => `c${hash(css)}`, [css]);
// 服务端:在「渲染期」收集(effect 在服务端不执行)
if (isServer()) {
const bucket = serverRules.get(tag) ?? new Map();
if (!bucket.has(ruleKey)) bucket.set(ruleKey, css);
serverRules.set(tag, bucket);
return ruleKey;
}
// 客户端:⭐ 用 useInsertionEffect(不是 useLayoutEffect / useEffect)
useInsertionEffect(() => {
const container = getContainer(tag);
insertRule(container, ruleKey, css);
return () => removeRule(container, ruleKey);
}, [ruleKey, css, tag]);
return ruleKey;
}
/** ============ 4. SSR:把收集到的规则吐成 `);
});
serverRules.clear();
return parts.join('');
}
/** ============ 5. 一个组件级 API:类似 styled 的极简版 ============ */
export function useClassNames(rules: Record, tag = 'default'): Record {
const keys: Record = {};
// ⚠️ 注意:这里不能用 map 里调 Hook(Hook 规则),所以要「展开成固定调用」
// 真实库会把「一次渲染的规则」收集起来,用「一个 useStyle(聚合后的 css)」处理
Object.keys(rules).forEach((name) => {
keys[name] = `pending-${name}`;
});
return keys;
}
⭐⭐ 这个实现里最值得注意的四个设计(都是「库级细节」):
① 用 `sheet.insertRule()` 而不是改 `textContent`
→ 前者是「增量插入」,后者会「重新解析整张样式表」
② 引用计数(refCount)
→ 同一个 CSS 规则被 100 个组件用到时,只能插一次、
且要等「最后一个使用者卸载」才能删
③ 按「层(tag)」分多个 `