有一段时间我特别烦一件事写一个支持 label 的可复用表单组件绕不开那个微不足道的 id。给 input 起个 idlabel 的 htmlFor 要指向它错报提示的 aria-describedby 还要指向它。以前我一般这么干模块级搞个计数器或者 useRef 自增更“现代”一点就 nanoid 生成一个。这些方案在纯客户端页面里勉强能用可一旦涉及 SSR 和 hydration怎么弄都不对劲。React 18 正式带来的 useId就是冲着这个场景来的。这篇文章我不打算只念官方文档而是从原理、应用场景、踩过的坑、以及真实组件改造实录几个维度把 useId 一次说透。适合自己写组件库的人、关注表单可访问性和 SSR 的 React 开发者认真看完。1. 没有 useId 的日子手动生成 ID 的三种土办法1.1 模块级计数器与 useRef 计数器我在三年前的项目里见过这样的代码let uid 0; function MyInput() { const id my-input-${uid}; return ( div label htmlFor{id}姓名/label input id{id} / /div ); }单看一个组件似乎没什么问题。但你想过没有这个 uid 是模块级变量挂在模块作用域里。只要页面里还有别的组件也缺 id大家可能共用同一个计数器如果组件顺序发生变化、渲染被中断重来这个数字就没法稳定了。更麻烦的是 SSR服务端进程里跑了一份累加了 N 次浏览器里又是从 0 开始累加两边完全对不上。后来很多人改用 useReffunction MyInput() { const counter useRef(0); const id my-input-${counter.current}; ... }这比模块级好一点状态挂在组件实例上实例之间不会错乱。但 React 18 的 StrictMode 会在开发环境故意执行两次渲染useRef.current 在第二次 render 时又被加了一次结果你会看到 id 变成 my-input-1、my-input-3 这种跳号。更重要的是服务端和客户端各自维护各自的 refhydrate 时仍然不一致。本质上render 阶段是不应该修改 useRef 的这算是 React 的一条“禁忌”。很多人写的时候没意识到等 StrictMode 一开测试环境里跳号跳得莫名其妙。1.2 随机 ID 库uuid 与 nanoid后来社区流行用随机 IDconst id useMemo(() nanoid(), []);纯客户端渲染模式下这个方案确实简单唯一性极好。但它有个致命问题SSR 下会在 hydration 阶段翻车。服务端 renderToString 生成 HTML 时id 是 A客户端 hydrate 加载组件后useMemo 闭包重新执行生成 id 是 B。React 发现现有 DOM 属性是 A客户端第一次渲染要求的是 B于是警告一堆。更要命的是实际交互label 的 htmlFor 指向 Ainput 的 id 却变成了 B用户点 label 根本没反应。如果你做过 SSR 表单页面一定见过这种诡异 bug——控制台没报语法错误看起来一切正常但点击就是没有聚焦效果。另外nanoid 这类库还会在客户端引入额外的随机算法。虽然在现代浏览器中性能可接受但如果只是为了几个 id这笔成本其实可以省掉。1.3 为什么服务端渲染SSR场景集体翻车Hydration 的过程可以这样理解服务端已经给出一份完整 HTML客户端 JS 加载后照着这份 HTML 做匹配而不是完全重建 DOM。如果组件第一次 render 生成的 id 与服务端已有的 HTML 不一致React 就失去了判断能力。这不只是控制台 warning 那么简单DOM 操作、input 状态、label 点击都可能错乱。我把几种方案放在一起对比方案客户端SSR/hydration并发渲染是否推荐模块计数器可能串号两端不一致不稳定不推荐useRef 计数器出现跳号两端不一致中断后不稳定不推荐uuid/nanoid唯一性好两端随机稳定但冗余不推荐做主力useId唯一性好两端一致专为并发设计推荐useId 的设计目的就是让人不再折腾这些破事。它不需要生成随机数也不需要依赖某个全局变量更不会因为服务端和客户端各自跑一遍而错位。2. useId 的核心原理树上的位置就是身份证2.1 先建立直觉React 怎么知道“同一个位置”Hooks 之所以好用是因为 React 把每次 render 时 Hooks 链表的顺序记了下来。组件里第一个 useId、第二个 useId在代码里的先后顺序天然固定。而每个组件实例在 Fiber 树上的位置也是固定的从根组件往下走走到哪个 children[index]再到哪个子组件。useId 做的事情就是把这些“位置信息”编码成一个字符串。你可以把 useId 等同于问了一句我现在在第几棵树的第几根树枝上。React 只需要看一眼 Fiber 树就能回答出来。这个回答不需要依赖任何外部状态所以服务端和客户端天然一致。当然同一个组件里可能出现多个 useIdReact 还需要一个额外的“区分数字”这就是计数器。位置信息负责标识“我是哪个组件在哪个层级”计数器负责区分“我是同一个组件里的第几个 useId”。两者组合在一起才能保证应用内唯一。2.2 ID 格式与冒号的含义React 18 里第一次调用 useId 返回的是类似:r0:的值第二次:r1:第三次:r2:。数字是全局递增计数器冒号则有两层意思第一把 React 生成的 id 和你业务里手写的普通 id比如name、phone隔离。如果你自己写了一个 id 叫r0它和 React 生成的:r0:不会冲突因为 React 生成的带冒号前缀。第二让 id 在 HTML、URL、SVG fragment 这些地方合法。HTML 的 id 属性理论上接受几乎任何非空字符串冒号不会触发语法问题。有同学第一次看到冒号会慌以为是自己写错了。其实这是 React 故意加的“防撞标识”。就像一段文字里为了区分原创还是引用给某些内容加上了特殊标注。2.3 为什么服务端和客户端能算出一致的 ID服务端渲染最怕什么怕两个用户同时请求服务端里的计数器被并发请求打乱。所以 React 不能在服务端用简单计数器。源码里服务端 useId 采用树路径编码思路从 root 节点开始树每往下一层就拼接一段位置编号。客户端 hydrate 时它也在走这棵由现有 DOM 代表的树按同一套编码规则重新走一遍自然得到一致的 ID。这份 ID 不需要序列化、不需要在 HTML 里额外埋点、不需要客户端去解析服务端传下来的数据。你只要保证服务端和客户端渲染的是同一个组件树结构useId 算出来的就是同一个身份证号。这就是它和所有第三方方案最大的区别。3. 把 useId 用在正确的地方四个高价值场景3.1 表单控件label 与 input 的强关联有了 useId我写表单组件不再需要任何外部 ID 来源。比如一个带错误提示的输入框import { useId } from react; function TextField({ label, error }) { const inputId useId(); const errorId useId(); const hintId useId(); return ( div label htmlFor{inputId}{label}/label input id{inputId} aria-describedby{error ? errorId : hintId} aria-invalid{!!error} / {error ? ( p id{errorId}{error}/p ) : ( p id{hintId}请输入至少 8 位字符/p )} /div ); }label 和 input 永远用同一个 id这比手写计数器省心多了。不管页面里渲染多少个 TextField它们各自的 id 都是唯一的一份。提示如果你需要把某段文字关联给多个组件正确做法是让这些组件共享同一个 useId 值而不是给每个组件单独 useId 再想办法对齐。关联的本质是一个 id 被多个属性引用不是每个组件各拿一个 id。3.2 无障碍a11y属性的一键关联无障碍属性是 useId 的主场。一个自定义 Select 可能同时需要aria-labelledby指向可见标签aria-describedby指向帮助文案aria-controls指向弹层或列表如果靠手写 ID维护关系很容易崩。用 useId 分别生成几个基础 id关联关系一目了然function Combobox() { const labelId useId(); const listboxId useId(); const hintId useId(); return ( span id{labelId}城市/span input aria-labelledby{labelId} aria-controls{listboxId} / ul id{listboxId} rolelistbox.../ul p id{hintId}支持拼音首字母搜索/p / ); }id 可以重复出现在多个 aria 属性里HTML 不反对一个 id 被多个属性引用。这表达的是一个“关系”而不是一份独占的 DOM 资源。3.3 SVG 内部引用渐变与裁剪的 ID 隔离改图标组件时经常遇到一个问题SVG 的 linearGradient 需要一个 id然后 fill 用url(#id)指向它。两个图标实例都叫gradient放在同一个文档里会互相污染。给 linearGradient 传 useId 后每个实例都有自己独立的 idfunction GradientIcon() { const gradientId useId(); return ( svg viewBox0 0 24 24 defs linearGradient id{gradientId} x10 y10 x21 y21 stop offset0% stopColor#6366f1 / stop offset100% stopColor#8b5cf6 / /linearGradient /defs rect width24 height24 fill{url(#${gradientId})} / /svg ); }即使图标被渲染了 10 遍渐变定义也互不干扰。SVG 的url(#:r0:)这种带冒号的引用在现代浏览器是合法的不需要额外转义。这个场景是我觉得 useId 最“隐藏”但最值的用法。3.4 复杂组件内部模块间的“信物”传递像 Tabs、Dialog、Combobox 这类复杂组件内部有多个节点需要通过 id 串联tab 要指向 panelpanel 要再指向 label。如果给每个节点单独 useId代码里需要到处传。更好的做法是组件内只生成一个 rootId然后用字符串拼接派生 idfunction Tabs({ items }) { const rootId useId(); return items.map((item, i) ( div key{item.id} button id{${rootId}-tab-${i}} aria-controls{${rootId}-panel-${i}} {item.label} /button div id{${rootId}-panel-${i}} roletabpanel {item.content} /div /div )); }rootId 唯一之后后缀也唯一。我在这种场景下特别喜欢把 useId 当成“命名空间”用而不是当成单个 id 用。组件内部所有需要配对的属性都从这一个根上长出来。4. 从手工作坊到 useId两个真实组件的改造实录4.1 Switch 组件删掉 ref 计数器的过程最近重构设计系统里的 Switch。老版本长这样let switchId 0; function Switch({ checked, onChange, label }) { const id switch-${switchId}; ... }看起来能跑但有两个问题我在线上观察到了一是和其它组件模块计数器混在一起时id 时而有重号时而有空号二是有一次做 SSR 改版switch 的 label 在页面点不动。改成 useId 后大幅简化function Switch({ checked, onChange, label }) { const id useId(); return ( div classNameswitch-item button id{id} roleswitch aria-checked{checked} onClick{() onChange(!checked)} / label htmlFor{id}{label}/label /div ); }我特意对比了 SSR 输出两个 Switch 实例分别输出:r0:和:r1:浏览器 hydrate 后再验证DOM 里还是这两个值。那个困扰我一天的 label 点击问题就这么消失了。4.2 渐变图标组件解决多实例配色串扰另一个改造对象是我们图标库里的 GradientIcon。之前开发图省事渐变 id 写死gradient。结果图标放在页面上第二个实例的颜色就不对。原因在于 SVGdefs里的元素按 id 在整个文档里查找。第二个图标的 fill 里写着url(#gradient)但它引用的是第一个图标里的linearGradient颜色自然串了。修复方式就是把 gradientId 变成 useId()并让 fill 里的url(#...)使用同一个值。这样每个实例的线性渐变都在自己的defs里查找互不干扰。我后来还特意写了三个 GradientIcon 并排放逐一检查浏览器计算出的渐变端点确认它们完全独立。4.3 列表多实例useId 会不会冲突有读者问过我页面里渲染 100 个 CardCard 内部用了两个 useId会不会出现 id 冲突答案是不会。React 会给每个 Card 实例分配不同的 id 区间。Card1 得到:r0:、:r1:Card2 得到:r2:、:r3:。所以别再给组件传 instanceId 或者 rowIndex 下来拼 id 了。数据 key 自然由数据 id 负责DOM 关联 id 由 useId 负责各管各的。这个分法我在代码评审时至少给新人讲了三遍。5. useId 的红线这些用法千万别学5.1 别拿 useId 当列表 key官方文档明确写了useId 不是用来生成 key 的。key 的作用是在兄弟节点之间标识数据身份它需要的是数据层面的稳定身份。useId 绑定的是组件实例在树中的位置列表一旦插入或删除一项后面所有项的组件位置都发生变化useId 全部换新。// 错误示例 {items.map(item ( Item key{useId()} item{item} / ))}这行代码还违反了 Hooks 调用规则。正确做法key 用item.id没有 id 就给数据补 id。id 不是组件渲染的产物而是数据本身的身份。5.2 Hook 调用规则与条件分支问题useId 就是一个普通 Hookrules of hooks 对它同样生效只能在组件顶层调用不能在 if、for、嵌套函数里调用。function Bad({ show }) { if (show) { const id useId(); // 不允许 } }背后的原理React 靠 Hooks 调用顺序维护 Hook 链表一旦条件出现或消失导致 hook 数量变化后面所有 hook 都会错位。这个规则对任何 Hook 都适用useId 没有豁免权。5.3 冒号带来的转义问题id 属性本身不拒绝冒号但如果你用 CSS 选择器或者某些工具选择元素就要处理转义。工作里遇到过同事写document.querySelector(#:r0:)这行代码会报错因为 CSS 规范里冒号是伪类样式的分隔符号。正确方式是用 CSS.escape或者干脆用 class 定位document.querySelector(#${CSS.escape(id)})如果你的第三方组件库会对 id 做格式校验不能接受冒号可以做一次局部替换const safeId x useId().replace(/:/g, );这样处理后得到类似xr0的值唯一性依然保持。我通常只在必须把 id 传给后端或外部 API 时才这样干内部 DOM 关联场景保留原生冒号格式更好。5.4 多 React 应用实例的唯一性隔离页面里同时挂载两个 React root 的情况不少见一个主应用、一个营销挂件。AppA 的 useId 生成:r0:AppB 也生成:r0:。虽然浏览器不报错但 aria 关联、label 点击这类功能会因为 id 重复而串台。React 18 为此提供了 identifierPrefix 配置createRoot(container1, { identifierPrefix: main }).render(AppA /); createRoot(container2, { identifierPrefix: widget }).render(AppB /);服务端的 renderToString 同样能接受这个选项。下次遇到两个 React 应用共存的页面记得配置它别省这一步。6. 问题排查与实用心得6.1 排查指南表格下面这张表是我在团队内部维护的 useId 排查速查表现象常见原因处理方式Hydration failedlabel 点击没反应用了 Math.random、nanoid 或手写计数器生成 id换成 useIdCSS 选择器选中不了元素id 含冒号未转义CSS.escape 或 className 定位两个 React 应用 DOM 中 id 重复没有配置 identifierPrefix创建 root 时传入 identifierPrefixuseId 当列表 key 导致警告违反 Hooks 规则且 key 不稳定改用数据自身的 idStrictMode 下出现奇怪空号手写 ref 计数器副作用换 useId组件库使用者报 useId is not a functionpeerDependencies 未锁 React 18检查依赖版本另外有一个不起眼但容易踩的现象如果页面里某些拦截器或组件库对 htmlFor 和 id 做字符串校验看到冒号会报错。你无法升级依赖时就用上面 safeId 的思路绕过。6.2 useId 与性能很多人担心 useId 每次渲染生成新字符串会不会有性能问题。实际上 React 会把第一次生成的 id 存在 Hook 的 memoizedState 里后续渲染直接读取不会重新算。所以不需要给它包 useMemo也不需要关心依赖数组。和随机 ID 相比useId 不涉及随机数生成纯靠树路径加计数器几乎零成本。在 SSR 大列表渲染场景我对比过服务端响应时间和之前写死 id 相比没有可见差异。6.3 使用 useId 的三个个人习惯最后分享我在项目里的三个习惯。第一所有需要 DOM 关联的 id 一律优先 useId不自己造计数器。第二useId 只服务于 DOM 关联和无障碍属性不要把它的返回值发到后端或者当业务主键。第三如果自己在维护组件库记得把 peerDependencies 里的 React 锁在 18 以上否则使用方会直接看到 useId is not a function。希望下次你在代码里看到id:r0:的时候能会心一笑而不是一头雾水。