陌上人如玉
公子世无双

2.17 useInsertionEffect 与 CSS-in-JS 原理

2.17 useInsertionEffect 与 CSS-in-JS 原理

一句话: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)」分多个 `