陌上人如玉
公子世无双

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 (
    <>
      
      
    
  );
}
// 报错: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 (
    <>
      
      
      

请输入 2~20 个字符

); }

2) 特点与用法

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

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

// ② 一个组件可以生成多个 ID(前缀区分)
const id = useId();



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

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 
  • {it.name}
  • ; })} // ✅ {items.map((it) =>
  • {it.name}
  • )}

    二、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(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(store: ReturnType>) {
      return useSyncExternalStore(store.subscribe, store.getState, store.getState);
    }
    
    function Counter() {
      const { count } = useStore(counterStore);
      return (
        <>
          {count}
          
        
      );
    }
    
    // ⚠️ 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(store: Store, 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 
    {online ? '在线' : '离线'}
    ; } // ② 订阅媒体查询 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 ? : ; } // ③ 订阅浏览器主题偏好 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
    
  • // ❌ // ② 用 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

    评论 抢沙发

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

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

    微信扫一扫

    支付宝扫一扫