React useId
为什么需要 useId
在 React 中,生成稳定的、唯一的 ID 曾经是一个令人头疼的问题。常见的解决方案有:
// 方案一:全局计数器
let idCounter = 0;
function generateId() {
return `id-${++idCounter}`;
}
// 方案二:Math.random()
function generateId() {
return `id-${Math.random().toString(36).substr(2, 9)}`;
}
// 方案三:Date.now()
function generateId() {
return `id-${Date.now()}`;
}
这些方案都有严重的问题:
- 全局计数器:在服务器端渲染(SSR)时,每个请求共享同一个计数器。如果两个请求同时渲染组件,ID 会冲突
- Math.random():每次渲染都生成新 ID,组件更新时 ID 会变,导致 Focus 管理失效、测试失败
- Date.now():高精度计时器在高并发下可能产生相同的时间戳
还有一个更隐蔽的问题:React 的 hydration(服务端水合)机制。在 SSR 中,服务器生成 HTML 时会创建组件树,客户端 hydration 时需要「确信」服务器生成的 HTML 和客户端渲染的 DOM 能对应上。如果客户端生成的 ID 和服务器不一致,就会出现 hydration mismatch。
useId 是 React 18 专门为解决这些问题而引入的 API。
useId 的基本用法
useId 的用法非常简单:
import { useId } from 'react';
function PasswordInput() {
const passwordId = useId();
const confirmId = useId();
return (
<div>
<label htmlFor={passwordId}>Password</label>
<input id={passwordId} type="password" name="password" />
<label htmlFor={confirmId}>Confirm Password</label>
<input id={confirmId} type="password" name="confirm" />
</div>
);
}
useId 返回一个字符串类型的唯一 ID。这个 ID 是稳定的——在服务端渲染和客户端 hydration 时生成相同的值。
多次调用 useId
同一个组件内可以多次调用 useId,每次调用都会得到一个不同的 ID:
function FormFields() {
const nameId = useId(); // ":r0:"
const emailId = useId(); // ":r1:"
const phoneId = useId(); // ":r2:"
return (
<form>
<div>
<label htmlFor={nameId}>Name</label>
<input id={nameId} type="text" />
</div>
<div>
<label htmlFor={emailId}>Email</label>
<input id={emailId} type="email" />
</div>
<div>
<label htmlFor={phoneId}>Phone</label>
<input id={phoneId} type="tel" />
</div>
</form>
);
}
为什么不能用 Math.random() 或 Date.now()
Math.random() 的问题
Math.random() 在每次调用时生成一个新的随机数。如果你用它来生成 ID:
function Component() {
const id = Math.random().toString(36).substr(2, 9);
return <input id={`input-${id}`} />;
}
在 React 的 Strict Mode 或 concurrent rendering 下,组件可能会被渲染多次(故意渲染两次来检测副作用问题)。这会导致生成的 ID 不一致。
更重要的是,在测试中这会导致问题:
// 测试代码
const input = screen.getByLabelText('Name');
fireEvent.change(input, { target: { value: 'test' } });
// 重新渲染后,ID 变了!
expect(screen.getByLabelText('Name')).toBeInTheDocument(); // 可能失败
Date.now() 的问题
Date.now() 返回自 Unix epoch 以来的毫秒数。在高并发的 SSR 环境中,多个请求可能在同一毫秒内到达,导致生成相同的 ID。
// 服务器同时处理两个请求
Request A: Date.now() = 1712500000000 -> id = "input-1712500000000"
Request B: Date.now() = 1712500000000 -> id = "input-1712500000000" // 冲突!
useId 的解决方案
useId 生成的 ID 格式类似 :r0:、:r1:、:r2:。但关键不是格式,而是它如何保证稳定性:
useId 的值是基于组件在组件树中的层级路径生成的,而不是基于时间或随机数。具体来说,React 会记录从根组件到当前组件的「路径」,并基于这个路径生成 ID。
这样,即使组件被渲染多次(Strict Mode)、或者在 SSR 和客户端都渲染,只要组件在树中的位置不变,生成的 ID 就一样。
SSR 场景下的水合不匹配问题
问题描述
在 React 17 及之前的 SSR 应用中,水合不匹配是一个常见问题:
// React 17 的 SSR
function Example() {
return <input id={`input-${Math.random()}`} />;
}
服务器渲染时生成 id="input-0.123",客户端 hydration 时生成 id="input-0.456",React 发现不一致,会在控制台报错:
Warning: Expected server HTML to contain a matching <input> in <div>.
更严重的是,水合不匹配会导致一些功能失效,比如 Focus 管理、label 关联。
useId 如何解决
useId 通过把 ID 序列化到服务端生成的 HTML 中来解决这个问题:
<!-- 服务器端渲染的 HTML -->
<input id=":#r1:" value="">
<!-- 客户端 hydration 时 -->
// useId() 返回 ":r1:",和服务器生成的一致
React 在 SSR 时会把 useId 的值嵌入到 HTML 中,客户端 hydration 时读取这个值,而不是重新生成。这样两边就一致了。
实现细节
React 18 的 useId 内部维护一个「counter tree」。每个 useId 调用对应树中的一个节点,有全局唯一的递增索引。
在 SSR 时,React 把这个计数器序列化为 HTML 的一部分(比如作为 data-reactid 属性)。客户端 hydration 时,React 读取这个计数器状态,恢复到和水合前的服务器渲染相同的状态。
在 ARIA 属性中的应用
useId 最常见的用途是生成 ARIA 无障碍属性所需的 ID。
label 和 input 的关联
HTML 的 label 元素通过 for 属性和 input 的 id 属性关联:
function NameField() {
const id = useId();
return (
<div>
<label htmlFor={id}>Name</label>
<input id={id} type="text" />
</div>
);
}
没有 useId 之前,你需要维护一个全局的 ID 计数器,或者使用第三方库(如 uuid)。useId 让这个模式变得简单。
多个相关联的表单字段
function RatingInput({ maxStars = 5 }) {
const ratingId = useId();
return (
<fieldset>
<legend id={ratingId}>Rate this product</legend>
<div role="radiogroup" aria-labelledby={ratingId}>
{Array.from({ length: maxStars }, (_, i) => (
<label key={i}>
<input
type="radio"
name="rating"
value={i + 1}
/>
{i + 1} star{i > 0 ? 's' : ''}
</label>
))}
</div>
</fieldset>
);
}
错误提示和 aria-describedby
表单验证失败时,需要把错误信息和输入框关联起来:
function EmailField({ error }) {
const emailId = useId();
const errorId = useId();
return (
<div>
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
/>
{error && <span id={errorId} style={{ color: 'red' }}>{error}</span>}
</div>
);
}
一个组件多次使用 useId 的场景
当一个组件内部需要多个 ID 时,每次调用 useId 都会生成一个新的 ID:
function Modal() {
const titleId = useId(); // 模态框标题 ID
const descId = useId(); // 模态框描述 ID
const closeId = useId(); // 关闭按钮 ID
return (
<div
role="dialog"
aria-labelledby={titleId}
aria-describedby={descId}
>
<h2 id={titleId}>Confirm Action</h2>
<p id={descId}>Are you sure you want to proceed?</p>
<button id={closeId} aria-label="Close">X</button>
</div>
);
}
hooks 模式下的问题
在 React 18 之前,如果你想把 useId 封装到一个 hook 里来复用,需要格外小心:
// 错误:计数器是共享的,导致 ID 冲突
let globalCounter = 0;
function useUniqueId() {
return `id-${++globalCounter}`;
}
useId 解决了这个问题,因为它是基于组件树的位置,而不是全局计数器。
useId 的格式和长度
useId 生成的 ID 格式在不同 React 版本中可能不同:
React 18.0 生成的 ID 格式是 :r随机数:,例如:
:r0::r1::r2:
React 18.2+ 改进了格式,使用更长的字符串来避免冲突:
:r0::r1k::r2j:
这些格式的具体细节是 React 内部实现,不应该在代码中依赖。useId 的语义是「唯一的、稳定的 ID」,不管具体格式是什么。
ID 的长度会增长吗
在深层嵌套的组件树中,useId 的长度会增长,因为它需要记录完整的路径。例如:
组件层级 1: useId() -> ":r0:"
组件层级 2: useId() -> ":r0:"
组件层级 3: useId() -> ":r0:"
等等,这不对。让我重新解释:useId 的路径是嵌套的,所以如果组件 A 调用了 useId 得到 :r1:,组件 A 内部的组件 B 调用 useId 可能得到 :r1:r0:。
实际上,useId 的格式大约是 :r1: 这样的短字符串,但 React 内部会维护完整的组件树路径信息。
面试中的表达
面试官问 useId,通常想确认你理解 SSR 的 hydration 机制:
useId 是 React 18 引入的 API,专门解决 SSR 场景下 ID 生成的问题。传统的 Math.random() 或 Date.now() 在 SSR 时会有问题:服务器和客户端生成的值不一样,导致 hydration mismatch。
useId 的核心是:它不是基于时间或随机数生成 ID,而是基于组件在树中的位置。服务器渲染时,React 把这个位置信息序列化到 HTML 里;客户端 hydration 时,React 读取这个信息,恢复到相同的状态。这样两边生成的 ID 就一致了。
useId 主要用在需要给表单元素生成唯一 ID 的场景,特别是和 ARIA 属性配合时。比如 label 的 htmlFor 和 input 的 id 关联,aria-describedby 和错误提示的 id 关联等。
延展阅读
- React Docs: useId — 官方 useId 文档
- React 18 Beta: SSR Improvements — React 18 SSR 改进
- React 18 useId Implementation — useId 的 RFC 文档,包含详细的设计决策
- Understanding React 18 useId — React 18 working group 对 useId 的讨论