React useId

深入理解 useId 的基本用法、稳定 ID 生成机制,SSR 水合不匹配问题的解决,以及在 ARIA 无障碍属性中的正确应用。

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 属性和 inputid 属性关联:

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 关联等。


延展阅读