
一句话:
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 里做重计算(它会被频繁调用)
// ⑫ 认为它会「自动批处理」更新(它是同步保证,不是批量优化)
面试延伸
- 「
useId解决什么问题?」
它生成「服务端与客户端一致的唯一 ID」,用于「无障碍属性关联」(label的htmlFor指向input的id、aria-describedby等)。在 SSR 场景下自己生成 ID 会出问题:Math.random()或不稳定的计数器会让服务端与客户端的 HTML 不一致,导致 hydration mismatch;useId由 React 保证两端一致,从而避免这个问题。 -
「
useId能当key用吗?」
不能。useId的语义是「为无障碍属性提供稳定的关联 ID」,它不保证跨渲染/跨挂载稳定(重挂载后可能变化),也不能用作业务 ID。列表的key必须来自数据本身的唯一标识(数据库 ID、UUID)。此外在循环里调用useId也违反 Hook 规则。 -
「
useId生成的 ID 为什么不能直接当 CSS 选择器?」
因为它包含冒号(形如:r0:)——这在 CSS 选择器里是「伪类」的语法,直接document.querySelector('#:r0:')会报错。需要用它选择元素时应该:① 用ref直接拿元素引用;② 或用属性选择器 + 转义([id="${CSS.escape(id)}"])。所以useId只适合「HTML 属性之间的关联」,不适合「JS 选择」。 -
「
useSyncExternalStore解决什么问题?」
解决「订阅外部数据源时的撕裂(tearing)问题」。在并发渲染下,渲染可以被中断,如果渲染过程中外部数据发生变化,同一次渲染的不同组件可能读到不同版本的数据,界面就撕裂了。useSyncExternalStore让 React 在读取外部数据时保证一致性(必要时强制同步渲染),是「订阅外部 store」的官方正确姿势——Zustand、Jotai、React-Redux 内部都用它。 -
「什么时候用
useSyncExternalStore,什么时候用useEffect?」
判断标准是「这个外部数据要不要参与渲染」:① 要参与渲染(如在线状态、媒体查询结果、外部 store 的值)→useSyncExternalStore(它能在并发渲染下保证一致性);② 只用来做副作用(如数据变化时上报日志、触发 toast)→useEffect(更直观,不需要考虑一致性)。此外「只在挂载时读一次」用useState的惰性初始化即可。 -
「
getSnapshot为什么要返回稳定的引用?」
因为 React 会用Object.is比较「两次getSnapshot()的返回值」来判断数据是否变化。如果每次调用都返回新对象(如() => ({ count: store.count })),React 会认为「数据一直在变」,于是无限重渲染(并有明确报错:The result of getSnapshot should be cached to avoid an infinite loop)。所以要么返回不可变状态本身,要么用「选择器 + 缓存」的方式记忆化结果。 -
「SSR 中用它要注意什么?」
必须提供第三个参数getServerSnapshot——服务端没有浏览器 API(navigator.onLine、window.matchMedia),所以需要一个「服务端使用的默认值」(如() => true、() => false)。如果不提供,服务端渲染会直接报错(提示缺少getServerSnapshot)。同时要注意「服务端的默认值与客户端的真实值不同」可能引起 hydration mismatch——通常的做法是「默认值用中性的安全值,客户端挂载后再更新真实值」。 -
「
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。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!