React 18 useId 实战指南:告别随机 ID,解决 SSR 水合警告

发布时间:2026/10/8 10:16:20
React 18 useId 实战指南:告别随机 ID,解决 SSR 水合警告 写 React 组件写了这么些年我越来越觉得真正容易翻车的地方往往不是复杂状态反倒是一些看起来特别不起眼的细节。比如给一个输入框关联 label、给某个区域设置 aria-describedby、给 SVG 的渐变命名这些场景都需要一个“每次渲染都稳定、同一页面又不重复”的字符串。以前项目里我见过各种野路子Math.random()、Date.now().toString(36)、uuid、自增计数器单页应用里还能勉强糊弄一上 SSR 就出现各种水合警告排查起来相当头疼。直到 React 18 把 useId 内置进核心 API这一类问题才算有了标准解法。这篇文章我会从“为什么要用 useId”讲起再带你实际看它的返回值长什么样、在表单和无障碍组件里怎么用最后把 SSR、测试、CSS 选择器这些边角问题一次说清楚。内容不涉及复杂源码但会把我踩过的坑和同事踩过的坑都整理进来希望能帮还在用随机字符串糊弄 id 的同学少走弯路。1. 先想清楚useId 是用来解决哪一类痛点的1.1 React 18 之前我在项目里是怎么糊弄唯一 ID 的在没有 useId 的时期最常见的做法是像下面这样function EmailField() { const id Math.random().toString(36).slice(2); return ( div label htmlFor{id}邮箱/label input id{id} typeemail / /div ); }单页应用里这段代码看着没什么问题每次渲染都生成不同 idlabel 和 input 反正是一起渲染的对应关系不会乱。但到了 SSR 场景事情就变味了。服务端先渲染出一份 HTML里面是ida3f9x客户端水合时拿到的却是idq7z2kReact 一对比属性不一致直接给你抛 hydration mismatch 警告。严重的还会导致整棵子树被重新渲染用户好不容易填了一半的表单信息被冲掉体验非常崩溃。还有人用过一个看似“稳定”的方案就是模块级自增计数器let globalId 0; function useStableId() { return useMemo(() field-${globalId}, []); }这方案在 React 18 之前还算能跑但它是基于“渲染顺序固定不变”这个假设的。一旦进入并发渲染组件 A 和组件 B 谁先执行、谁后执行就不再稳定。服务端和客户端执行完一轮渲染后的计数值可能完全不同水合照样挂而且这种问题还不是每次必现属于那种“在开发环境好好的上线后偶发报错”的隐形雷。1.2 SSR 水合不匹配到底是什么很多人听到 hydration mismatch 就紧张但又说不太清楚它具体是怎么发生的。我打个比方服务端渲染就像先拍一张照片这张照片是input idfield-1客户端水合就像拿着同一批演员重新演一遍演出来的画面应该是input idfield-1照片和画面必须完全对得上React 才能在此基础上接管事件和后续更新。如果客户端重新演的时候useStableId 跑出来的结果是field-2React 就发现问题了照片里是 field-1我团队里演出来是 field-2这活儿我接不了。于是它要么告警要么干脆把这一段废旧立新重新创建真实 DOM。问题在于用户在服务端渲染出来的页面上已经填了内容这一重建输入框里的内容全没了。所以水合不匹配的根因往往不是 React 自身 bug而是服务端和客户端首次渲染的结果不一致。任何依赖随机数、时间戳、执行顺序的 id 生成手段在这套机制下都不靠谱。1.3 useId 的设计核心按组件树定位而不是按随机数useId 之所以能解决上面所有问题是因为它彻底换了个思路不再生成一个“随机且唯一”的字符串而是根据当前组件在 Fiber 树中的位置和调用序号计算出一个确定性标识。只要组件树的结构固定服务端和客户端各自渲染时都会沿着同一棵树走到同一个位置算出来的 id 自然完全一致。这也解释了为什么官方文档反复强调“useId 只能在组件顶层调用”。它的计算依赖当前正在渲染的组件节点你要是把它塞进循环、回调、条件语句里等于强行改变它的调用位置和次数React 内部的 id 计算顺序就会错乱。理解了这个设计初衷后面很多使用细节就都顺理成章了。2. useId 到底返回什么冒号开头的字符串为什么能直接用在标识符场景2.1 一个组件里多次调用得到的是不同 IDuseId 不需要传任何参数直接调用即可。一个组件内部可以调用多次每次都会拿到一个区别于其他调用的 id。import { useId } from react; function LoginForm() { const inputId useId(); const hintId useId(); const errorId useId(); console.log(inputId, hintId, errorId); // :r0: :r1: :r2: return null; }我第一次在浏览器里看到:r0:这种返回值时其实有点怀疑这玩意儿能当 id 用后来发现完全没问题。HTML 规范对 id 字符的限制很宽松冒号是合法字符浏览器也认可。很多自动化测试工具或 UI 库不要求 id 必须是“人类友好”的它们的核心诉求只是唯一且稳定useId 恰好同时满足这两点。这里有一点需要提醒具体是:r0:还是:r3:取决于当前组件在整棵组件树里的位置以及前面已经调用过多少次 useId。换句话说你换一个父组件包裹它序号很可能会变。所以千万不要在代码里写死某个具体返回值也不要去依赖它里面的数字做逻辑判断。2.2 冒号前缀到底有什么讲究useId 返回的字符串以冒号开头这个细节是刻意设计的。先说结论冒号前缀给用 useId 生成的 id 划分了一个非常清晰的命名空间。正常情况下业务代码里谁也不会手动写一个以冒号开头的 DOM id这就大大降低了 useId id 和手写 id 撞车的概率。即便同一个页面有多个 React 应用只要都用 useId它们默认也不会冲突因为每个应用内部都有自己的一套计数路径。麻烦的地方是 CSS 选择器。冒号在 CSS 语法里是有特殊含义的比如伪类:hover、:focus。如果直接把:r0:塞进querySelector浏览器会认为r0:是一个伪类选择器自然匹配不到任何元素。这个问题我会在第 5 章专门展开现在先记住一句话useId 的返回值可以直接用在getElementById但如果要做选择器匹配必须处理转义。2.3 想在 ID 上拼业务前缀拼前面还是拼后面项目里经常会有人觉得:r0:这串东西在 DOM 里面太难看想加个业务前缀比如表单域叫email-field:r0:。这里我要特别强调不要试图在 useId 返回值前面拼字符串。// 不推荐的做法 const badId email-field-${useId()}; // 更合理的做法 const baseId useId(); const goodId ${baseId}-email-field;为什么会有这个讲究这里有两个现实原因。一是 useId 返回的 id 以冒号开头冒号在 CSS 中会被当作伪类处理如果你硬拼前缀选择器解析时更容易产生歧义二是 React 官方在设计上就是把 useId 返回值当成一个不可分割的“整块标识符”你在后面追加后缀来区分同一组件内的不同元素是常规操作但在前面拼接前缀就破坏了它的命名空间特性。实际操作中我倾向于直接使用 useId 原生返回值不在前面额外加工。如果团队里确实需要可读性更强一点的 id那就统一约定在后缀位置追加语义名比如${id}-label、${id}-error。2.4 identifierPrefix 参数多 React 应用并存时的官方姿势如果你在一个页面里挂载了多个 React 应用比如微前端架构下头部一个应用、主内容区一个应用两个应用都用 useId默认状态下它们也可能出现相同的:r0:。这时候 React 提供了一层保险就是createRoot和hydrateRoot的identifierPrefix选项。// 客户端 const root createRoot(container, { identifierPrefix: admin- }); root.render(App /); // 服务端 import { renderToString } from react-dom/server; const html renderToString(App /, { identifierPrefix: admin- });只要服务端和客户端传的是同一个前缀useId 返回值就会变成带前缀的形式比如:admin-r0:两个应用之间就不会再出现重号问题。这个参数不需要单独传入 useIduseId 本身不接参数前缀是在渲染器层面统一配置的。还有一点很容易被忽略如果项目是 SSR服务端和客户端两边的 identifierPrefix 必须保持一致单边传前缀会导致水合不一致那反而制造了新的 mismatch。3. 实战场景表单、ARIA、SVG 引用这三处最该用 useId3.1 用 label 的 htmlFor 关联输入框表单是 useId 用得最多的场景。我们在写无障碍友好的表单时一定不会只用 placeholder 代替 label而是会用label htmlForxxx和input idxxx建立显式关联。没有 useId 之前这个 xxx 要么由外部传入要么自己随机生成。由外部传容易漏传随机生成在 SSR 下又会水合失败。useId 把这两条路都堵死了。function EmailField() { const id useId(); return ( div label htmlFor{id}邮箱/label input id{id} typeemail nameemail / /div ); }这个组件在页面上渲染多少次都没关系每个实例的 useId 都会得到不同的 idlabel 和 input 的对应关系永远正确。顺带提一个经验id和name是两种完全不同的东西别因为 id 稳定就把name也设置成同样的值。id只服务于 DOM 定位和无障碍关联name才是表单提交到后端的字段名两者混用会让后续的数据处理变得很难受。3.2 aria-labelledby / aria-describedby 多 ID 关联表单控件的可访问性往往不止一个 id。一个输入框可能同时需要 label 文本、帮助提示文本、错误提示文本这些文本元素都要通过aria-describedby关联到输入框。如果每个 id 都是手写的还得绞尽脑汁保证不重名。用 useId 以后问题变得非常简单。function PasswordField({ hasError }) { const inputId useId(); const hintId useId(); const errorId useId(); const describedBy [hintId, errorId].filter(Boolean).join( ); return ( div label htmlFor{inputId}密码/label input id{inputId} typepassword aria-describedby{describedBy} aria-invalid{hasError} / p id{hintId}密码至少需要 8 位/p {hasError p id{errorId}密码强度不足/p} /div ); }这里有个小细节aria-describedby接收的是空格分隔的 id 列表我习惯用数组 filter 再 join避免出现多余的空格和空字符串。如果你用模板字符串硬拼很容易在某个元素不渲染时留下一个尾部空格虽然浏览器通常能容忍但严谨的测试工具可能会给你报 warning。不只是输入框封装 Switch、Checkbox、Tooltip 这些无障碍组件时useId 也是标配。比如一个 switch 组件内部可能需要三个 idlabelId、descriptionId、还有可能用于错误提示的 id。全部用 useId 生成每个实例自己管自己页面里放十个开关都不会串。3.3 SVG 渐变和滤镜的本地引用很多人可能没意识到SVG 渐变、clipPath、filter 这种东西在同一个页面里也经常因为 id 冲突而出现样式错乱。我以前画图表时就踩过两个图表组件都定义了linearGradient idgradient浏览器遇到重复 id 时只会取文档里第一个结果第二个图表的颜色莫名其妙不对。useId 在这里同样适用function DonutChart() { const gradientId useId(); return ( svg viewBox0 0 100 100 defs linearGradient id{gradientId} x10 y10 x21 y21 stop offset0% stopColor#22c55e / stop offset100% stopColor#0ea5e9 / /linearGradient /defs circle cx50 cy50 r40 fill{url(#${gradientId})} / /svg ); }这里要说明一点SVG 里url(#gradientId)后面的片段标识符和 CSS 选择器不是一回事它不需要对冒号做转义。实测fillurl(#:r0:)是可以正常工作的。如果你的图表组件内部还嵌套了多个图元也可以基于同一个gradientId再拼后缀比如${gradientId}-inner但同样建议拼在后面而不是前面。3.4 封装可复用组件时把“唯一 ID”收进组件内部很多 UI 组件库都会暴露一个idprop 给使用者但同时也需要处理“用户没传 id”的情况。里外配合起来最稳的写法是这样的function Tooltip({ id: externalId, title, children }) { const autoId useId(); const id externalId ?? autoId; return div id{id}{children}/div; }注意这里的顺序很关键useId 必须无条件调用然后才判断外部是否传入。如果你图省事写成const id externalId || useId()当 externalId 有值时这次渲染 useId 根本没执行等下一次 externalId 没了useId 又执行了一次React 会判断当前组件的 Hook 数量发生了变化直接给你一个 “Rendered more hooks than during the previous render” 的报错。这种条件式 Hook 调用是 React 所有 Hook 的大忌useId 也不例外。先调用再合并这个模式无论外部传不传 idHook 数量都保持稳定。4. 边界情况SSR、Suspense、微前端和测试环境4.1 SSR 并发渲染下useId 凭什么保持一致React 18 之后引入并发特性渲染过程可以被更高优先级的任务打断再重来。如果 id 生成依赖一个全局计数器一旦某一轮被打断计数器已经自增了重来时就可能跳号甚至错乱。更麻烦的是这种错乱在服务端和客户端还未必一致服务端按顺序 A、B、C 渲染客户端可能先渲染 C再被打断最后 B、A 才跟上两边的计数器状态完全没法对齐。useId 不存在这个问题因为它的计算根本不基于共享状态而是基于 Fiber 树内部的路径信息。组件树结构不变这个路径就不会变。并发渲染只影响某个组件的渲染时机不影响组件树本身的结构所以无论服务端执行得快还是客户端执行得快最终算出来的路径字符串都一样。这在我看来是 useId 最核心的价值它不依赖顺序不依赖时间只依赖结构。4.2 Suspense 挂起不会让 ID 漂移Suspense 是 React 18 里很常用的能力一个组件在数据加载完成前会抛出一个 PromiseReact 先渲染 fallback数据到位后再恢复渲染真正的组件。这里有个让人担心的问题组件挂起前后内部的 useId 返回值会不会变万一变了label 和 input 的关联关系就断了屏幕阅读器也会瞬间失去上下文。实际使用中useId 的返回值在 Suspense 挂起和恢复后是保持稳定的因为它是由组件在树中的位置决定的而不是由“什么时候恢复”决定的。换句话说React 提前就把这个位置信息算好了Suspense 的等待过程不会改变树结构。我自己在写一个懒加载的详情面板时专门验证过面板从 fallback 切换到真实内容后里面的输入框 id 和 label 的 htmlFor 完全没变。4.3 同页面多个 React 根怎么保证不撞 ID现代前端项目里一个页面同时存在多个 React 根应用并不罕见。老的页面框架升级时经常是 header 一个根、侧边栏一个根、正文一个根各自维护。这种情况下如果两个根里都用 useId可能会生成相同的:r0:。这个时候就该用前面提到的identifierPrefix。const root createRoot(document.getElementById(header), { identifierPrefix: header-, }); root.render(Header /); const root2 createRoot(document.getElementById(content), { identifierPrefix: content-, }); root2.render(Content /);这样两个应用里的 useId 返回值就分别变成:header-r0:和:content-r0:。需要特别强调的是如果项目是 SSR服务端渲染阶段也必须传同样的 identifierPrefix否则服务端生成的是:root-r0:客户端水合时却是:content-r0:两端不一致还是会 mismatch。4.4 测试环境里的断言别写死 ID很多团队会在测试里对组件的 DOM 结构做快照useId 这种带计数的字符串很容易让快照频繁失效。你只要在组件树的某一个父节点上多加一个组件所有子组件里的:r0:可能一夜之间变成:r5:快照全飘红。这不能怪 useId因为它的设计目标里就没有“跨树结构保持字面量不变”这一条。在实际测试里我推荐依赖语义查询而不是具体的 id 字面量。比如用testing-library/react就直接用getByLabelText(邮箱)去找输入框它会自动解析 label 和 input 之间的 htmlFor 关联关系完全不 care id 字符串是:r0:还是别的什么。能证明“label 能正确关联 input”就足够了没必要去断言 id 长什么样。如果非要断言 id 属性存在那就断言非空别断言具体值。5. 真正劝退新手的几个坑以及速查表5.1 不要用 useId 充当列表 key这个是我见过最典型的误用。有人看到 useId 能生成唯一 id就顺手把它放进列表渲染的 key 里{items.map((item) ( ListItem key{useId()} item{item} / ))}这至少有两个问题。第一按 Hook 规则useId 不能在 map 回调里调用因为它现在不在组件顶层React 官方建议的调用场景是函数组件或自定义 Hook 的内部map 回调既不是组件函数也不是自定义 Hook这样调用十有八九会触发 Hook 规则告警。第二key 的作用是帮助 React 识别列表项在多次渲染之间的身份它必须和列表数据本身绑定比如item.id。如果 key 只代表“这次渲染随机生成的值”那么数据顺序一变React 就无法判断哪一项是新增、哪一项是删除列表状态会混乱。正确的做法是让数据源带稳定 id真实项目里如果数据源确实没有 id可以用固定的生成规则给数据实体补一个而不是在渲染层用 useId 临时兜底。5.2 CSS 选择器遇到冒号必须转义useId 的返回值强依赖冒号而冒号在 CSS 选择器里有特殊含义所以直接拿它做选择器会翻车。继承自官方文档的经典例子如果 id 是:r0:document.getElementById(:r0:); // 这行能正常工作 document.querySelector(#:r0:); // 这行会匹配不到甚至语法都过不了正确的写法是document.querySelector(#\\:r0\\:)在 CSS 规则文件里写#\:r0\:。由于 JavaScript 字符串里反斜杠本身也是转义符所以你在写 querySelector 参数时会看到双反斜杠这是很正常的。如果你不想记转义规则还有两个更省事的方案一是用getElementById它接收的是原始字符串不需要转义二是用属性选择器[id:r0:]属性选择器的引号内不需要转义[id:r0:] { border: 1px solid red; }这个属性选择器写法我实测过简单好记比#\:r0\:更不容易出错。不过在真实的工程实践里我建议还是别依赖 id 去写业务样式。useId 生成的 id 本来就不是给样式用的给 CSS 提供独立 hook 可以用>const isMobile typeof window ! undefined window.innerWidth 768; return isMobile ? MobileForm / : DesktopForm /;服务端没有 windowisMobile 为 false渲染 DesktopForm客户端 window 存在isMobile 为 true渲染 MobileForm。两棵树结构完全不同useId 就算再稳定在 MobileForm 和 DesktopForm 里算出的 id 也对不上。这种水合错误跟 id 生成策略无关根因是“首次渲染结果不一致”。遇到这种需求正确的做法是把断点判断放到 useEffect 里做让服务端和客户端首次渲染都走同一棵树。5.4 useId 常见问题速查表场景典型现象正确做法表单 label 关联点击文字聚焦不到输入框用 useId 生成 htmlFor 和 idSSR 水合属性 mismatch 警告用 useId 替代随机 id不要在渲染期间生成随机数列表渲染列表项身份不稳定状态错乱key 基于数据生成不要用 useIdCSS 选择器querySelector 命中不了带冒号的 id用 getElementById或用属性选择器[id...]多 React 根两个应用 id 冲突createRoot / hydrateRoot 传 identifierPrefix外部 id prop 缺失组件内部需要 id 兜底先无条件调用 useId再合并外部传参禁止条件调用SVG 渐变/滤镜多个实例样式互相污染用 useId 生成 defs 里的 id测试断言快照里 :r0: 总是变用语义查询不要断言具体 id 字面量我个人现在的习惯已经非常固定任何需要“当前实例唯一”的 DOM id、ARIA 关联 id、SVG fragment id都优先 useId任何需要样式定位的场景不用 id 选择器用基础类名或>