陌上人如玉
公子世无双

2.11 useId 与 useSyncExternalStore

2.11 useId 与 useSyncExternalStore

一句话:useId 生成「服务端与客户端一致的唯一 ID」(用于无障碍属性关联),useSyncExternalStore 提供「订阅外部数据源」的官方姿势(避免并发渲染下的撕裂问题)。

一、useId

1) 为什么需要它

// ❌ 问题:自己生成 ID 会导致服务端与客户端不一致(hydration mismatch)
function Form() {
  const id = Math.random().toString(36);       // 服务端与客户端生成的不同
  return (
    <>
      <label htmlFor={id}>用户名</label>
      <input id={id} />
    </>
  );
}
// 报错:Hydration failed because the server rendered HTML didn't match the client

// ❌ 用递增计数器也有问题(渲染顺序变化时不一致)
let counter = 0;
const id = `field-${counter++}`;

// ✅ useId:React 保证「同一个组件在服务端与客户端得到相同的 ID」
function Form2() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>用户名</label>
      <input id={id} aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>请输入 2~20 个字符</p>
    </>
  );
}

2) 特点与用法

// ① 生成的 ID 形如 ':r0:'(含冒号,是【不是 CSS 选择器合法】的字符)
const id = useId();     // ':r0:'

// ⚠️ 所以不能这样用
document.querySelector(`#${id}`);          // ❌ CSS.escape 才行
// ✅ 通过 ref 或直接操作,不要把它当 CSS 选择器

// ② 一个组件可以生成多个 ID(前缀区分)
const id = useId();
<input id={`${id}-email`} />
<input id={`${id}-password`} />

// ③ 用于「同一组件多实例」的 ID 隔离(列表里每项都有独立的 label/input 关联)
function Field({ label }: { label: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

3) 适用与不适用

用途 是否适合
htmlFor / id 关联(label↔input) ✅ 主要用途
aria-describedby / aria-labelledby ✅
<dialog> 与触发按钮的关联 ✅
作为列表的 key ❌(应该用数据的 ID)
作为 CSS 选择器 ❌(含冒号,不合法)
作为请求参数 / 数据库 ID ❌(它只在本次渲染期间稳定)
缓存 key、日志标识 ❌
// ❌ 常见误用:把 useId 当 key
{items.map((it) => {
  const id = useId();          // ❌ 在循环里调 Hook 也是违规的
  return <li key={id}>{it.name}</li>;
})}

// ✅
{items.map((it) => <li key={it.id}>{it.name}</li>)}

二、useSyncExternalStore

1) 为什么需要它

问题背景(并发渲染下的「撕裂 / tearing」):

假设有一个「外部数据源」(不是 React state):
  ① 组件 A 在渲染时读了 store.value = 1
  ② 渲染还没结束,外部数据源被改成 2
  ③ 组件 B 在同一个渲染过程中读了 store.value = 2
  → 同一个界面里,A 显示 1、B 显示 2 → 状态「撕裂」

React 18 之前这不容易发生(渲染是同步的),
但有了可中断的并发渲染后,「渲染过程中外部数据变化」就变得可能。

useSyncExternalStore 的作用是:让 React 在读取外部数据时
【保证一致性】——必要时强制同步渲染,从而避免撕裂。
// 签名
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);

// 参数:
// ① subscribe(callback) → 订阅变化,返回「取消订阅」函数
// ② getSnapshot() → 返回当前的值(必须是「不可变」的快照,否则会无限循环)
// ③ getServerSnapshot() → 服务端渲染时用的值(可选,SSR 需要)

2) 手写一个 store 并订阅

// ① 一个最小可用的「外部 store」
function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<() => void>();

  return {
    getState: () => state,
    setState: (next: T | ((prev: T) => T)) => {
      state = typeof next === 'function' ? (next as (p: T) => T)(state) : next;
      listeners.forEach((l) => l());          // 通知所有订阅者
    },
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener); // ⭐ 返回取消订阅函数
    },
  };
}

const counterStore = createStore({ count: 0 });

// ② 用 useSyncExternalStore 订阅
function useStore<T>(store: ReturnType<typeof createStore<T>>) {
  return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}

function Counter() {
  const { count } = useStore(counterStore);
  return (
    <>
      <p>{count}</p>
      <button onClick={() => counterStore.setState((s) => ({ count: s.count + 1 }))}>+1</button>
    </>
  );
}
// ⚠️ getSnapshot 必须返回「不可变的快照」
// ❌ 每次返回新对象 → React 认为「一直在变」→ 无限循环
const getSnapshot = () => ({ count: store.state.count });   // ❌
// 报错:The result of getSnapshot should be cached to avoid an infinite loop

// ✅ 返回稳定的引用
const getSnapshot = () => store.state;                      // ✅(状态本身是不可变替换)

// ✅ 或者用「选择器 + 记忆化」
function useStoreSelector<T, S>(store: Store<T>, selector: (s: T) => S): S {
  const cacheRef = useRef<{ value: S; state: T } | null>(null);

  const getSnapshot = useCallback(() => {
    const state = store.getState();
    const cached = cacheRef.current;
    if (cached && Object.is(cached.state, state)) return cached.value;   // 复用
    const value = selector(state);
    cacheRef.current = { value, state };
    return value;
  }, [store, selector]);

  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}

3) 三个经典应用

// ① 订阅浏览器 API:在线状态
function useOnlineStatus() {
  return useSyncExternalStore(
    (cb) => {
      window.addEventListener('online', cb);
      window.addEventListener('offline', cb);
      return () => {
        window.removeEventListener('online', cb);
        window.removeEventListener('offline', cb);
      };
    },
    () => navigator.onLine,        // 客户端读取
    () => true                     // 服务端默认值(SSR)
  );
}

function StatusBar() {
  const online = useOnlineStatus();
  return <div>{online ? '在线' : '离线'}</div>;
}

// ② 订阅媒体查询
function useMediaQuery(query: string) {
  const subscribe = useCallback(
    (cb: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', cb);
      return () => mql.removeEventListener('change', cb);
    },
    [query]
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => false                    // SSR 默认值
  );
}

function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return isMobile ? <MobileLayout /> : <DesktopLayout />;
}

// ③ 订阅浏览器主题偏好
function usePreferredTheme() {
  return useSyncExternalStore(
    (cb) => {
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      mql.addEventListener('change', cb);
      return () => mql.removeEventListener('change', cb);
    },
    () => window.matchMedia('(prefers-color-scheme: dark)').matches,
    () => false
  );
}

4) 什么时候该用它

✅ 适用:
  ① 数据源在 React 之外(浏览器 API、外部 store、第三方库)
  ② 需要在「并发渲染」下保证一致性(避免撕裂)
  ③ 编写状态管理库(Zustand / Jotai / Redux 内部都用它)

❌ 不适用:
  ① React 自己的 state(用 useState / useReducer 就好)
  ② 只想「订阅一次并在变化时做副作用」(用 useEffect 更直观)
  ③ 简单的「挂载时读一次」(用 useState 的惰性初始化)
需求 方案
组件自己的状态 useState / useReducer
订阅外部数据并渲染它 useSyncExternalStore
订阅外部数据并做副作用(不渲染) useEffect
挂载时读一次外部值 useState(() => readExternally())

三、常见坑

// ===== useId =====
// ① 把 useId 当 key
<li key={useId()}>                          // ❌

// ② 用 useId 作为 CSS 选择器
document.querySelector(`#${id}`);           // ❌ 含冒号
document.querySelector(`[id="${CSS.escape(id)}"]`);   // ✅(或用 ref)

// ③ 用 useId 生成「业务 ID」(如订单号、请求参数)
fetch(`/api/user/${useId()}`);              // ❌ 它的语义只是「渲染期唯一」

// ④ 在循环/条件里调用 useId
items.map(() => useId());                   // ❌ 违反 Hook 规则

// ⑤ 期待它在「多次渲染之间不变」并在别处保存
const id = useId();
localStorage.setItem('key', id);            // ⚠️ 它在重挂载后可能变化

// ===== useSyncExternalStore =====
// ⑥ getSnapshot 返回「新对象」→ 无限循环
() => ({ value: store.value })              // ❌

// ⑦ 忘记返回「取消订阅」函数
(cb) => { store.subscribe(cb); }            // ❌ 内存泄漏

// ⑧ subscribe 函数「每次渲染都是新的」→ 反复订阅/退订
const subscribe = (cb) => { ... };          // ❌ 内联函数每次都是新引用
// ✅ 用 useCallback 稳定,或定义在模块作用域

// ⑨ SSR 时忘记 getServerSnapshot
// → 服务端渲染会报错(require the getServerSnapshot arg)
useSyncExternalStore(sub, getSnapshot);             // ⚠️
useSyncExternalStore(sub, getSnapshot, () => null);  // ✅

// ⑩ 用它订阅「React state」→ 多此一举
// ⑪ 在 getSnapshot 里做重计算(它会被频繁调用)
// ⑫ 认为它会「自动批处理」更新(它是同步保证,不是批量优化)

面试延伸

  1. 「useId 解决什么问题?」
    它生成「服务端与客户端一致的唯一 ID」,用于「无障碍属性关联」(label 的 htmlFor 指向 input 的 id、aria-describedby 等)。在 SSR 场景下自己生成 ID 会出问题:Math.random() 或不稳定的计数器会让服务端与客户端的 HTML 不一致,导致 hydration mismatch;useId 由 React 保证两端一致,从而避免这个问题。

  2. 「useId 能当 key 用吗?」
    不能。useId 的语义是「为无障碍属性提供稳定的关联 ID」,它不保证跨渲染/跨挂载稳定(重挂载后可能变化),也不能用作业务 ID。列表的 key 必须来自数据本身的唯一标识(数据库 ID、UUID)。此外在循环里调用 useId 也违反 Hook 规则。

  3. 「useId 生成的 ID 为什么不能直接当 CSS 选择器?」
    因为它包含冒号(形如 :r0:)——这在 CSS 选择器里是「伪类」的语法,直接 document.querySelector('#:r0:') 会报错。需要用它选择元素时应该:① 用 ref 直接拿元素引用;② 或用属性选择器 + 转义([id="${CSS.escape(id)}"])。所以 useId 只适合「HTML 属性之间的关联」,不适合「JS 选择」。

  4. 「useSyncExternalStore 解决什么问题?」
    解决「订阅外部数据源时的撕裂(tearing)问题」。在并发渲染下,渲染可以被中断,如果渲染过程中外部数据发生变化,同一次渲染的不同组件可能读到不同版本的数据,界面就撕裂了。useSyncExternalStore 让 React 在读取外部数据时保证一致性(必要时强制同步渲染),是「订阅外部 store」的官方正确姿势——Zustand、Jotai、React-Redux 内部都用它。

  5. 「什么时候用 useSyncExternalStore,什么时候用 useEffect?」
    判断标准是「这个外部数据要不要参与渲染」:① 要参与渲染(如在线状态、媒体查询结果、外部 store 的值)→ useSyncExternalStore(它能在并发渲染下保证一致性);② 只用来做副作用(如数据变化时上报日志、触发 toast)→ useEffect(更直观,不需要考虑一致性)。此外「只在挂载时读一次」用 useState 的惰性初始化即可。

  6. 「getSnapshot 为什么要返回稳定的引用?」
    因为 React 会用 Object.is 比较「两次 getSnapshot() 的返回值」来判断数据是否变化。如果每次调用都返回新对象(如 () => ({ count: store.count })),React 会认为「数据一直在变」,于是无限重渲染(并有明确报错:The result of getSnapshot should be cached to avoid an infinite loop)。所以要么返回不可变状态本身,要么用「选择器 + 缓存」的方式记忆化结果。

  7. 「SSR 中用它要注意什么?」
    必须提供第三个参数 getServerSnapshot——服务端没有浏览器 API(navigator.onLine、window.matchMedia),所以需要一个「服务端使用的默认值」(如 () => true、() => false)。如果不提供,服务端渲染会直接报错(提示缺少 getServerSnapshot)。同时要注意「服务端的默认值与客户端的真实值不同」可能引起 hydration mismatch——通常的做法是「默认值用中性的安全值,客户端挂载后再更新真实值」。

  8. 「subscribe 函数为什么不稳定会导致问题?」
    因为 useSyncExternalStore 会在「subscribe 引用变化时」退订旧的、订阅新的。如果每次渲染都传入新的 subscribe(内联箭头函数),就会频繁地订阅/退订——不仅浪费性能,还可能在「订阅与退订之间」漏掉通知。所以 subscribe 应该用 useCallback 稳定引用,或定义在模块作用域(不依赖组件的任何值)——这也是 useOnlineStatus、useMediaQuery 这类 Hook 的常见写法。

一句话速记

useId 生成「服务端与客户端一致的 ID」用于无障碍关联(label/input、aria-*);它含冒号不能当 CSS 选择器、不能当 key、不能当业务 ID;useSyncExternalStore 是「订阅外部数据源」的官方姿势,解决并发渲染下的撕裂问题(Zustand/Redux 内部都用它);它的 getSnapshot 必须返回稳定引用(否则无限循环)、subscribe 要稳定(否则反复订阅)、SSR 必须给 getServerSnapshot;「要参与渲染的外部数据」用它,「只做副作用」用 useEffect。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.11 useId 与 useSyncExternalStore

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫