React useId 实战:治好 SSR 水合警告、表单无障碍与唯一 ID 生成
React useId 实战:治好 SSR 水合警告、表单无障碍与唯一 ID 生成
你给一个输入框配 label,想用htmlFor关联,于是随手写了个 id:
function EmailField() { return ( <> <label htmlFor="email">邮箱</label> <input id="email" /> </> ); }单个用没问题。可这个组件一旦在页面里出现两次,DOM 里就有两个id="email"——点第二个 label 会聚焦到第一个 input,无障碍读屏也乱套。你想到用随机数生成 id,结果 Next.js 服务端渲染立刻甩你一脸红色警告:Hydration failed。React 18 的useId就是来解决这一串问题的。
为什么 Math.random() 会炸水合
先看错误示范:
function EmailField() { const id = `email-${Math.random()}`; // 每次渲染都变 return ( <> <label htmlFor={id}>邮箱</label> <input id={id} /> </> ); }问题的根源是 SSR 的两段式渲染:服务端渲染一次 HTML(比如 id 是email-0.123),浏览器加载后 React 再"水合"(hydrate)一次,又跑一遍Math.random()得到email-0.789。服务端和客户端生成的 HTML 对不上,React 检测到不一致就报Hydration failed,严重时整棵子树被丢弃重渲染,首屏闪一下。
用模块级自增计数器(let seq = 0; const id = seq++)也不行——服务端和客户端的自增起点、渲染顺序未必一致,同样水合不上。
useId 的正确姿势
useId生成的 id 在服务端和客户端保证一致,专为水合设计:
import { useId } from "react"; function EmailField() { const id = useId(); // SSR 与 CSR 生成同一个值,水合不再报错 return ( <> <label htmlFor={id}>邮箱</label> <input id={id} /> </> ); }现在同一个组件渲染多次,每个实例拿到的 id 都不同(React 根据组件在树中的位置生成,类似:r0:、:r1:),label 和 input 精确配对,读屏软件也能正确关联。
一个组件里多个 id:加后缀,别调多次
一个表单项经常需要好几个关联 id:input 本身、错误提示、帮助文本。别为每个都调一次useId,而是调一次、拼后缀:
function PasswordField({ error }) { const id = useId(); // 一个 useId 派生出一组稳定关联的 id const inputId = `${id}-input`; const errId = `${id}-error`; const hintId = `${id}-hint`; return ( <div> <label htmlFor={inputId}>密码</label> <input id={inputId} type="password" aria-describedby={`${hintId} ${errId}`} aria-invalid={!!error} /> <p id={hintId}>至少 8 位,含字母和数字</p> {error && <p id={errId}>{error}</p>} </div> ); }aria-describedby把帮助文本和错误提示都关联到输入框,读屏软件聚焦时会一起念出来。这就是useId最典型的价值:无障碍属性需要稳定、唯一、SSR 安全的 id。
三个必须记住的边界
第一,useId 不是给列表 key 用的。这是最常见的误用:
// 错误!useId 只能在组件顶层调用,不能在 map 循环里调 {items.map((item) => { const id = useId(); // 违反 Hooks 规则,直接报错 return <li key={id}>{item.name}</li>; })}列表 key 要用数据本身的稳定标识(item.id),useId是给"渲染无关的 DOM 关联 id"用的,两者场景完全不同。
第二,别拿它当数据库主键或请求参数。useId生成的值形如:r0:,带冒号,只保证在当前这次渲染的组件树里唯一且 SSR 一致。它会随组件在树中的位置变化,刷新页面也可能变,拿去当业务 id 提交给后端必然出乱子。
第三,同一个 id 别跨组件传来传去当"全局唯一标识"。它的设计目标就是"就近关联 DOM 元素",超出这个范围就是误用。
配合 CSS-in-JS 的 id 选择器坑
useId默认输出带冒号(:r0:),而冒号在 CSS 选择器里有特殊含义(伪类)。如果你想用生成的 id 写document.querySelector('#' + id)或 CSS#:r0:,会直接语法出错。解决办法是给它套个合法前缀,或用CSS.escape:
const id = useId(); const safeId = `f${id.replace(/:/g, "")}`; // 去掉冒号,前面加字母保证是合法选择器多数场景直接把useId()的值塞进id/htmlFor/aria-*属性即可(这些属性对冒号无所谓),只有当你要拿它做 CSS/JS 选择器时才需要清洗。
小结
useId生成SSR/CSR 一致的唯一 id,专治Math.random()和自增计数器导致的Hydration failed。- 典型场景是表单无障碍:
htmlFor/aria-describedby/aria-invalid需要稳定唯一的关联 id。 - 一个组件多个 id 时,调一次
useId再拼后缀,不要循环里调,也不要为每个属性各调一次。 - 三条红线:不做列表 key、不做业务主键/请求参数、不跨组件当全局标识。
- 值带冒号,做 CSS/JS 选择器前要清洗;直接塞属性则无需处理。
- 记忆点:useId 只干一件事——给同一处 DOM 的关联属性发一个 SSR 安全的唯一号。
