Hook18.0+

useId

生成唯一的安全 ID,用于无障碍属性和 SSR 兼容

核心概述

HTML 的无障碍属性(如 aria-describedby)需要引用唯一的 ID。 但在 React 组件中硬编码 ID 是不行的,因为组件可能在页面上渲染多次,导致 ID 冲突。 使用 Math.random() 或计数器在 服务端渲染(SSR) 场景下又会导致客户端和服务端生成的 ID 不一致(Hydration Mismatch)。

useId 就是为了解决这个问题而生的。它能生成在客户端和服务端都保持一致的唯一 ID, 并且保证在同一组件的多次实例中互不冲突。

💡 为什么不使用自增计数器?

简单的全局自增计数器(如 let id = 0; id++)在 SSR 中会失败:

  • 服务端处理请求的顺序可能与客户端渲染的顺序不同
  • React 的并发模式(Concurrent Mode)可能会打乱渲染顺序
  • useId 使用组件树的层级结构生成 ID,保证了稳定性和一致性

技术规格

类型签名

TypeScript
const id = useId();

返回值

返回一个唯一的字符串 ID,格式通常为 :r0:, :r1: 等。注意:包含冒号

运行机制

  • SSR 兼容: 只要组件树结构一致,服务端和客户端生成的 ID 就一致
  • 唯一性: 整个 React 应用内部唯一
  • 格式: 包含冒号,用于确保在多根节点应用中的唯一性

实战演练

1. 基础用法:无障碍关联

aria-describedby 与说明文本的 ID 关联。

TypeScript
import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();

  return (
    <div className="flex flex-col gap-2">
      <label className="font-semibold">
        密码:
        <input
          type="password"
          aria-describedby={passwordHintId}
          className="ml-2 border rounded px-2 py-1"
        />
      </label>
      <p id={passwordHintId} className="text-sm text-gray-600">
        密码应包含至少18个字符
      </p>
    </div>
  );
}

2. 为多个元素生成 ID

不需要为每个元素调用一次 useId,可以使用同一个 ID 添加后缀。

TypeScript
import { useId } from 'react';

function NameForm() {
  const id = useId();

  return (
    <form className="p-4 border rounded">
      <div className="mb-4">
        <label htmlFor={id + '-firstName'} className="block mb-1">名字</label>
        <input
          id={id + '-firstName'}
          type="text"
          className="border rounded px-2 py-1 w-full"
        />
      </div>
      <div>
        <label htmlFor={id + '-lastName'} className="block mb-1">姓氏</label>
        <input
          id={id + '-lastName'}
          type="text"
          className="border rounded px-2 py-1 w-full"
        />
      </div>
    </form>
  );
}

避坑指南

❌ 陷阱 1: 用作列表 key

不要试图用 useId 为列表项生成 key。Key 必须基于数据,而 useId 是基于组件在树中的位置。 且 Hooks 不能在循环中调用。

❌ 错误代码

TypeScript
const items = [
  { id: 1, text: 'Item A' },
  { id: 2, text: 'Item B' },
];

function List() {
  const id = useId(); // ❌ 这里生成 ID 是没用的

  return (
    <ul>
      {items.map(item => (
        // ❌ 错误:不要使用 useId 生成 key
        <li key={useId()}>{item.text}</li>
      ))}
    </ul>
  );
}

// 原因:key 必须基于数据,且在其生命周期内保持稳定。
// useId 的返回值虽然稳定,但不应该在循环中调用 Hooks。

✅ 修正代码

TypeScript
const items = [
  { id: 1, text: 'Item A' },
  { id: 2, text: 'Item B' },
];

function List() {
  return (
    <ul>
      {items.map(item => (
        // ✅ 正确:使用数据中的唯一 ID
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

❌ 陷阱 2: 用作 CSS 选择器

useId 返回的字符串包含冒号(如 :r1:),这在 CSS 选择器中是特殊字符, 直接使用 querySelector 或 CSS ID 选择器会失败。

❌ 错误代码

TypeScript
function ValidatedInput() {
  const id = useId();

  // ❌ useId 返回包含冒号的字符串(如 ":r0:"),
  // 在 CSS 选择器中是无效的(除非转义)
  // document.querySelector('#' + id) 会报错
  return <input id={id} />;
}

✅ 修正代码

TypeScript
import { useId, useEffect, useRef } from 'react';

function ValidatedInput() {
  const id = useId();
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // ✅ 正确:使用 ref 直接操作 DOM,不需要 ID 选择器
    const input = inputRef.current;
    if (input) {
      input.focus();
    }
  }, []);

  return <input ref={inputRef} id={id} />;
}

最佳实践

1. 多应用共存配置

如果在同一个页面上有多个 React 根节点(微前端架构),可以通过 identifierPrefix 避免 ID 冲突。

TypeScript
import { createRoot } from 'react-dom/client';

const root1 = createRoot(document.getElementById('root1'), {
  identifierPrefix: 'app1-'
});
root1.render(<App />);

const root2 = createRoot(document.getElementById('root2'), {
  identifierPrefix: 'app2-'
});
root2.render(<App />);

// 结果:
// App1 中的 useId() -> ":app1-r0:"
// App2 中的 useId() -> ":app2-r0:"
// 避免了两个 React 应用在同一页面上的 ID 冲突

2. 封装可复用组件

在基础 UI 组件中使用 useId,确保每次使用都有唯一 ID,无需像传统方式那样手动传入 id prop。

TypeScript
function TextField({ label, ...props }) {
  const id = useId();

  return (
    <div className="mb-4">
      <label htmlFor={id} className="block text-sm font-medium mb-1">
        {label}
      </label>
      <input
        id={id}
        {...props}
        className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
    </div>
  );
}

// 使用
function Form() {
  return (
    <form>
      <TextField label="用户名" name="username" />
      <TextField label="密码" type="password" name="password" />
    </form>
  );
}

3. 处理单选组

对于 RadioGroup 这样的组件,利用前缀生成相关联的一组 ID。

TypeScript
function RadioGroup({ options, name, legend }) {
  const prefix = useId();

  return (
    <fieldset className="border p-4 rounded">
      <legend className="font-semibold mb-2">{legend}</legend>
      {options.map((option, index) => {
        // ✅ 为每个选项生成唯一 ID
        const optionId = `${prefix}-${index}`;
        return (
          <div key={option.value} className="flex items-center gap-2 mb-1">
            <input
              id={optionId}
              name={name}
              type="radio"
              value={option.value}
            />
            <label htmlFor={optionId}>{option.label}</label>
          </div>
        );
      })}
    </fieldset>
  );
}

4. 结合 TypeScript 使用

useId 的类型定义简单直接,返回 string 类型。

TypeScript
function Component() {
  const id: string = useId(); // TypeScript 知道返回类型是 string

  // 可以安全地用于所有需要字符串 ID 的场景
  return (
    <div>
      <label htmlFor={id}>Label</label>
      <input id={id} />
    </div>
  );
}

useId vs 其他 ID 生成方法对比

方法SSR 兼容唯一性稳定性推荐度
useId()✅ 完美兼容✅ 组件树内唯一✅ 生命周期内稳定⭐⭐⭐⭐⭐
Math.random()❌ SSR 错误⚠️ 全局不保证❌ 每次渲染变化
Date.now()❌ SSR 错误⚠️ 同时调用冲突❌ 每次渲染变化
全局计数器❌ SSR 不同步✅ 全局唯一✅ 稳定⭐⭐
UUID 库❌ SSR 错误✅ 全局唯一❌ 每次渲染变化⭐⭐