React 基础体系 · 第 34/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 身份与 useId:Key、DOM ID、水合和列表稳定性

React 中有几种容易被混为一谈的“身份”:

  • 组件实例在 React 树中的身份;
  • 列表项参与协调(reconciliation)时的身份,也就是 key
  • DOM 元素的 id,供标签关联、锚点、脚本和 CSS 使用;
  • useId 生成的、可在服务端与客户端对齐的 ID。

它们都可能表现为“一个字符串”,但解决的是不同问题。把 DOM id 当成 key,或把 useId 当成列表 key,通常不是语法错误,而是模型错误,因此问题往往要到列表重排、SSR 水合或辅助功能检查时才暴露。

本文以 React 19、现代 TypeScript 和支持 SSR 的主流框架为背景。除非特别说明,代码同时适用于客户端组件;涉及服务端渲染和水合的部分,会明确标出客户端与服务端边界。


先建立四种身份

React 组件身份:类型、位置和 key 共同决定

React 在一次渲染中得到的是一棵元素树。可以把一个元素的有效身份近似表示为:

I=(父节点,层级,类型,key)I = (\text{父节点}, \text{层级}, \text{类型}, \text{key})

其中:

  • 父节点 表示它在哪个父元素的子节点集合中;
  • 层级 表示它位于哪一层;
  • 类型 是组件函数、类组件或宿主元素类型,例如 UserCarddiv
  • key 是同一父节点的子列表中用于匹配的标识。

这个表达式不是 React 对外公开的精确内部数据结构,而是理解状态保留规则的模型。React 不会仅凭“这个组件函数以前出现过”就保留状态,而是要判断新旧渲染中的位置是否代表同一个节点。

例如:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

function App({ show }: { show: boolean }) {
  return (
    <main>
      {show && <Counter />}
    </main>
  );
}

showfalse 变为 true 时,Counter 是首次出现;当 showtrue 变为 false 时,它被移除;再次变为 true 时,会创建一个新的 Counter,原来的状态不会自动恢复。

而下面的两个分支:

function App({ compact }: { compact: boolean }) {
  return compact ? <Counter /> : <Counter />;
}

在 React 的元素树中,两个分支都产生同一父节点下、同一位置、同一类型的 Counter。切换 compact 通常会保留 Counter 的状态。条件表达式本身不会自动导致组件重置。

如果类型发生变化,身份也会变化:

function App({ compact }: { compact: boolean }) {
  return compact ? <Counter /> : <section><Counter /></section>;
}

当根节点从 Counter 变成 section 时,原来的子树被拆除,新的子树创建,状态边界发生变化。

因此,“组件身份”描述的是 React 是否把两次渲染中的节点视为同一个节点;它不是 DOM id,也不是组件收到的普通属性。


key 是列表协调的身份,不是 DOM 属性

key 解决什么问题

考虑一个可编辑列表:

type Item = {
  id: string;
  name: string;
};

function ItemEditor({ item }: { item: Item }) {
  const [draft, setDraft] = useState(item.name);

  return (
    <li>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
    </li>
  );
}

如果父组件这样渲染:

function EditorList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item, index) => (
        <ItemEditor key={index} item={item} />
      ))}
    </ul>
  );
}

初始数据为:

[
  { id: "a", name: "Alpha" },
  { id: "b", name: "Beta" }
]

列表中两个子节点的 key 是:

位置 数据 key
0 a 0
1 b 1

用户在第二行输入 Beta edited。随后服务端或用户在头部插入一项:

[
  { id: "x", name: "Xray" },
  { id: "a", name: "Alpha" },
  { id: "b", name: "Beta" }
]

新旧 key 变为:

新位置 数据 key
0 x 0
1 a 1
2 b 2

React 看到的是:

  1. 旧 key 0 对应的 ItemEditor 仍然存在,于是把原来编辑 Alpha 的状态复用于 Xray
  2. 旧 key 1 对应的状态被复用于 Alpha
  3. Beta 得到新 key 2,原来的编辑状态丢失。

这不是 React 把数据弄错了,而是 key={index} 声明了一个错误的身份规则:列表位置被当成了数据身份。只要列表可能插入、删除、排序、过滤或分页,位置就不稳定。

正确写法是使用数据中稳定且同级唯一的标识:

function EditorList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <ItemEditor key={item.id} item={item} />
      ))}
    </ul>
  );
}

此时插入 x 后:

新位置 数据 key
0 x x
1 a a
2 b b

React 可以识别出:

  • x 是新节点;
  • a 仍是原来的节点,只是位置变了;
  • b 仍是原来的节点,只是位置变了。

于是 b 对应的 ItemEditor 状态随数据一起移动,而不是随数组位置错位。

key 的约束是“同一父节点下唯一且稳定”

对一个父节点的直接子列表,理想的 key 集合满足:

ij,keyikeyj\forall i \neq j,\quad key_i \neq key_j

这里的唯一性只要求在同一个兄弟列表中成立。不同列表可以使用同样的 key:

function Page() {
  return (
    <>
      <ul>
        <li key="1">左侧</li>
      </ul>
      <ul>
        <li key="1">右侧</li>
      </ul>
    </>
  );
}

这两个 1 不冲突,因为它们不属于同一个父节点的同一个子列表。

稳定性则要求:只要数据项还是同一个实体,它在后续渲染中就应得到相同 key。下面这些值通常不满足稳定性:

key={Math.random()}
key={Date.now()}
key={`${item.name}-${index}`}

Math.random()Date.now() 会导致每次渲染都像是出现了一批全新的组件,输入框焦点、组件状态和 DOM 局部状态都可能被重置。name 如果可编辑或可重复,也不能作为可靠实体标识。index 只有在列表严格静态、永不重排且没有中间插入删除时才可能安全。

key 不会传给组件,也不会出现在 DOM 中

function Row(props: { item: Item; key?: string }) {
  console.log(props.key); // undefined
  return <div>{props.item.name}</div>;
}

<Row key="user-42" item={item} />;

key 是 React 用于协调的特殊属性,不会作为普通 prop 传入组件,也不会生成:

<div key="user-42">

如果组件也需要数据身份,必须显式传入:

<Row key={item.id} item={item} itemId={item.id} />

其中:

  • key 给 React 的列表协调使用;
  • itemId 是组件业务逻辑使用的数据。

把同一个字符串同时用于二者是可以的,但它们的职责仍然不同。


DOM id 是文档身份,不是 React 身份

DOM id 属于浏览器文档:

<input id="email" />
<label htmlFor="email">邮箱</label>

浏览器使用它处理:

  • <label htmlFor="..."> 与表单控件的关联;
  • aria-describedbyaria-labelledby 等辅助功能关系;
  • document.getElementById()
  • URL 片段,例如 /#section-2
  • 某些 CSS 选择器和脚本逻辑。

它回答的是:“文档中的哪个元素具有这个标识?”而 key 回答的是:“同一父节点下,旧树中的哪个子节点对应新树中的哪个子节点?”

因此下面的写法并不能让 React 正确识别列表项:

{items.map((item) => (
  <div id={item.id}>{item.name}</div>
))}

这里没有 key,即使 DOM 中有唯一 id,React 仍会发出缺少 key 的警告,并可能按位置进行不理想的协调。正确写法需要分别提供:

{items.map((item) => (
  <div key={item.id} id={`item-${item.id}`}>
    {item.name}
  </div>
))}

反过来,key 也不能代替 DOM id

{items.map((item) => (
  <Fragment key={item.id}>
    <label htmlFor={`input-${item.id}`}>{item.name}</label>
    <input id={`input-${item.id}`} />
  </Fragment>
))}

key 只存在于 React 元素层面;htmlFor 必须匹配真实 DOM 上的 id

DOM id 需要在文档范围内避免冲突

key 只要求兄弟列表内唯一,而 DOM id 通常应在整个文档中唯一。下面的组件单独使用时没有问题:

function SearchBox() {
  return (
    <>
      <label htmlFor="search">搜索</label>
      <input id="search" />
    </>
  );
}

但渲染两次后就出现重复 DOM ID:

<>
  <SearchBox />
  <SearchBox />
</>

两个 label 都指向同一个字符串 "search"。浏览器对重复 ID 的行为可能表现为 getElementById 返回第一个匹配元素,辅助技术也可能无法得到预期关系。

这正是 useId 适合解决的问题之一。


useId 生成什么,以及它不生成什么

useId 是 React 提供的 Hook:

import { useId } from "react";

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

  return (
    <>
      <label htmlFor={inputId}>密码</label>
      <input id={inputId} type="password" />
    </>
  );
}

每个 PasswordField 实例都会得到一个与其他实例不同的 ID。这个 ID 的重要属性不是“看起来像业务主键”,而是:

  1. 同一个组件实例在重新渲染时保持稳定;
  2. 服务端渲染与客户端首次渲染能够生成相互匹配的值;
  3. 可以用来连接同一组件内部的多个 DOM 元素;
  4. 不需要开发者自行维护全局计数器。

生成的字符串可能包含冒号等内部格式字符,例如 :R1: 一类的值。应用不应依赖具体格式、长度或是否可读。它是 React 管理的标识,不是数据库 ID,也不是给用户展示的业务编号。

一个完整的可复用表单组件可以这样写:

import { useId } from "react";

type FieldProps = {
  label: string;
  description?: string;
  invalid?: boolean;
};

export function TextField({
  label,
  description,
  invalid = false,
}: FieldProps) {
  const id = useId();
  const descriptionId = `${id}-description`;
  const errorId = `${id}-error`;

  const describedBy = [
    description ? descriptionId : null,
    invalid ? errorId : null,
  ]
    .filter(Boolean)
    .join(" ") || undefined;

  return (
    <div>
      <label htmlFor={id}>{label}</label>

      <input
        id={id}
        aria-invalid={invalid || undefined}
        aria-describedby={describedBy}
      />

      {description && <p id={descriptionId}>{description}</p>}
      {invalid && (
        <p id={errorId} role="alert">
          输入值无效
        </p>
      )}
    </div>
  );
}

假设页面中有两个实例:

<>
  <TextField label="用户名" description="用于登录" />
  <TextField label="邮箱" description="用于接收通知" />
</>

逻辑上会得到如下关系:

第一个 label --htmlFor--> 第一个 input
第一个 input --aria-describedby--> 第一个 description

第二个 label --htmlFor--> 第二个 input
第二个 input --aria-describedby--> 第二个 description

每个实例只使用自己的 ID 派生值,不会与另一个实例的描述节点混淆。

为什么不能在渲染期间自己递增全局计数器

下面的实现看似简单,但不可靠:

let nextId = 0;

function BadField() {
  const id = `field-${nextId++}`;

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

它的问题包括:

  • 服务端和客户端的渲染顺序可能不同;
  • 并发渲染、流式渲染或多根应用会改变计数过程;
  • 组件重新渲染时可能再次消耗编号;
  • 测试之间的全局状态会互相影响;
  • 组件被丢弃、重试或重新挂载时,编号序列不再代表文档结构。

useId 的设计目标正是让 ID 与 React 的树结构和渲染上下文对齐,而不是依赖一个进程级可变变量。


useId 为什么不能用作列表 key

这条限制不是任意规定,而是由 Hook 的调用规则和列表身份的时序要求共同决定的。

错误示例:

function UserList({ users }: { users: { id: string; name: string }[] }) {
  const ids = users.map(() => useId()); // 错误
  // ...
}

Hook 不能在循环、条件或嵌套函数中调用。组件每次渲染时,Hook 调用顺序必须保持一致;而列表长度变化会改变 useId 的调用次数和后续 Hook 的位置。

即使把调用移到子组件中,也不应把 useId 当成数据项的 key:

function UserRow({ user }: { user: User }) {
  const id = useId();

  return <li id={id}>{user.name}</li>;
}

function UserList({ users }: { users: User[] }) {
  return users.map((user) => (
    <UserRow key={/* 不能由 UserRow 内部的 useId 得到 */} user={user} />
  ));
}

正确的组件边界是:

type User = {
  id: string;
  name: string;
};

function UserRow({ user }: { user: User }) {
  const domId = useId();

  return (
    <li id={domId}>
      {user.name}
    </li>
  );
}

function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <UserRow key={user.id} user={user} />
      ))}
    </ul>
  );
}

此时有两条独立的身份链:

user.id
  └── key:决定 UserRow 的 React 状态是否随用户移动

useId() in UserRow
  └── domId:决定该 UserRow 内部 DOM 关联使用什么 id

如果某个用户从列表第 2 位移动到第 1 位:

  • key={user.id} 使原来的 UserRow 实例随用户移动;
  • 该实例的 useId() 结果保持不变;
  • 它的 labelinput 和描述节点之间的 DOM 关系仍然有效。

如果使用索引作为 key,组件实例可能先被错误复用于另一个用户,useId 的稳定性也无法修复这个身份错配。useId 只保证“这个 React 实例”的 ID 稳定,不保证“某个数据实体”在错误 key 下仍能得到正确组件实例。


列表、Fragment 和多层组件中的 key

key 应放在产生列表元素的那一层

下面的 key 放置位置正确:

function List({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <Row key={item.id} item={item} />
      ))}
    </ul>
  );
}

下面则不正确:

function Row({ item }: { item: Item }) {
  return <li key={item.id}>{item.name}</li>;
}

function List({ items }: { items: Item[] }) {
  return items.map((item) => <Row item={item} />);
}

React 需要比较的是 List 返回的直接子元素。Row 内部 <li> 上的 key 不参与 ListRow 子节点的匹配,因此仍会收到缺少 key 的警告。

多节点列表需要给 Fragment key

不能把短 Fragment 语法写成:

items.map((item) => (
  <>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </>
));

因为 <>...</> 语法不能接收 key。应该使用显式 Fragment

import { Fragment } from "react";

items.map((item) => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.definition}</dd>
  </Fragment>
));

这里 key 代表整个 dt/dd 对,而不是其中某一个 DOM 元素。Fragment 本身通常不产生额外 DOM 节点,但仍可以作为 React 子树的协调边界。

嵌套列表的 key 作用域彼此独立

groups.map((group) => (
  <section key={group.id}>
    <h2>{group.name}</h2>
    {group.items.map((item) => (
      <div key={item.id}>{item.name}</div>
    ))}
  </section>
));

外层 key 只在 groups 这个兄弟列表中比较,内层 key 只在某个 section 的子列表中比较。不同分组中重复的 item.id 不一定是问题,因为它们不在同一兄弟列表中。


水合:服务端 HTML 与客户端 React 树的第一次相遇

什么是水合

服务端渲染通常经历两个阶段:

  1. 服务端执行 React 渲染,生成 HTML;
  2. 浏览器收到 HTML 后,客户端 React 执行对应组件,并将事件处理器和 React 管理能力接到现有 DOM 上。

第二步通常称为水合(hydration)。它不是简单地“重新生成一份 HTML 再替换”,而是尝试复用服务端已经产生的 DOM。

可以把水合的初始一致性条件写成:

Rserver(P,Es)Rclient(P,Ec)R_{\text{server}}(P, E_s) \approx R_{\text{client}}(P, E_c)

其中:

  • RR 是组件树的渲染过程;
  • PP 是相同的 props 和路由数据;
  • EsE_sEcE_c 分别是服务端和客户端环境;
  • \approx 表示至少在 React 需要匹配的结构、文本和属性上等价。

如果服务端输出:

<label for="field-1">邮箱</label>
<input id="field-1">

而客户端第一次渲染尝试匹配:

<label for="field-2">邮箱</label>
<input id="field-2">

就产生了水合不一致。具体修复、警告和是否重新生成某个子树取决于框架与 React 的处理路径,但开发者不能把这种不一致当成正常更新机制。

useId 如何参与水合一致性

useId 不依赖浏览器随机数,也不要求服务端和客户端共享一个手写的全局计数器。React 会根据渲染树上下文生成可匹配的 ID,使下面的组件能在 SSR 后正确关联:

function LoginForm() {
  const emailId = useId();
  const passwordId = useId();

  return (
    <form>
      <label htmlFor={emailId}>邮箱</label>
      <input id={emailId} type="email" />

      <label htmlFor={passwordId}>密码</label>
      <input id={passwordId} type="password" />
    </form>
  );
}

服务端和客户端都应得到同一组逻辑对应关系:

email label -> email input
password label -> password input

这并不意味着任何情况下都能自动消除水合问题。组件树的结构、条件分支和 Hook 调用顺序仍必须一致。例如:

function BadField({ browserOnly }: { browserOnly: boolean }) {
  const id = useId();

  if (browserOnly) {
    return <input id={id} />;
  }

  return <textarea id={id} />;
}

如果服务端和客户端对 browserOnly 的初始值不同,DOM 元素类型就不同,仍然会水合失败。useId 只解决 ID 生成的一致性,不解决任意的服务端/客户端数据分歧。

同样,以下写法会制造风险:

function BadField() {
  const id = `field-${Math.random()}`;
  return <input id={id} />;
}

服务端和客户端各自调用 Math.random(),几乎必然得到不同字符串。类似地,直接读取不稳定的时间、浏览器随机值或只在客户端存在的数据,也可能破坏初始输出一致性。


key 与水合的关系:它不是 HTML 中的水合标记

key 不会被序列化为 DOM 属性:

<div key="user-42">...</div>

服务端 HTML 中不会因为这个 key 自动出现 key="user-42"。所以不能通过查看页面源代码确认 key 是否正确,也不能期待浏览器用 key 来定位元素。

在客户端 React 树中,key 仍然参与子节点身份匹配;但在首次水合时,React 面对的是已经存在的 DOM,首先必须让客户端初始渲染与服务端输出在结构和属性上对得上。因此下面的错误不能靠 key 修复:

function List({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

如果服务端的 items 是:

[{ id: "a", name: "Alpha" }]

而客户端首次渲染时是:

[{ id: "b", name: "Beta" }]

即使两边都使用了稳定 key,文本内容仍不一致。key 只能说明不同数据项在 React 模型中的身份,不能让两份不同数据变成同一份 HTML。

一个典型的正确时序是:

sequenceDiagram
    participant S as 服务端
    participant B as 浏览器 DOM
    participant C as 客户端 React

    S->>S: 使用初始数据渲染 React 树
    S-->>B: 发送 HTML
    B->>B: 展示服务端 HTML
    C->>C: 使用相同初始数据首次渲染
    C->>B: 水合现有 DOM
    C->>C: 读取客户端数据或浏览器状态
    C->>B: 作为水合后的正常更新提交

如果客户端必须读取 localStorage、窗口宽度或浏览器 API,常见的安全边界是:首次客户端渲染先使用与服务端相同的初始值,水合完成后再在 Effect 或其他明确的客户端阶段读取并更新。

function ThemeLabel() {
  const [theme, setTheme] = useState("light");

  useEffect(() => {
    const stored = window.localStorage.getItem("theme");
    if (stored === "dark" || stored === "light") {
      setTheme(stored);
    }
  }, []);

  return <span>{theme}</span>;
}

这个例子不是为了让所有浏览器状态都必须放进 Effect,而是说明边界:服务端和客户端的第一次输出要一致;客户端专属数据应在水合后进入正常更新路径,或者由框架提供的客户端边界明确隔离。


多个 React 根节点与 identifierPrefix

在一个文档中挂载多个独立 React 根节点时,每个根内部的 useId 需要避免跨根冲突。React 提供 identifierPrefix 选项来区分根。

客户端:

import { hydrateRoot } from "react-dom/client";
import App from "./App";

hydrateRoot(
  document.getElementById("app-a")!,
  <App />,
  { identifierPrefix: "app-a-" }
);

另一个根:

hydrateRoot(
  document.getElementById("app-b")!,
  <App />,
  { identifierPrefix: "app-b-" }
);

服务端渲染时也必须使用对应前缀,并且同一个根的服务端和客户端前缀必须一致。以 Node.js 流式 API 为例:

import { renderToPipeableStream } from "react-dom/server";
import App from "./App";

const stream = renderToPipeableStream(<App />, {
  identifierPrefix: "app-a-",
  onShellReady() {
    // 将 stream.pipe(res) 接到 HTTP 响应
  },
  onShellError(error) {
    console.error("无法生成初始页面", error);
  },
  onError(error) {
    console.error("服务端渲染错误", error);
  },
});

这里的关键条件是:

同一个 React 根:
server identifierPrefix === client identifierPrefix

多个根使用不同前缀可以降低生成相同 DOM ID 的风险。但前缀不是业务实体 ID,也不会替代列表 key。若一个页面由框架负责 SSR 和水合,应按照该框架暴露的配置方式设置;不要在框架内部随意创建第二个根或绕过其水合入口。


完整示例:SSR 安全的可编辑列表

下面的示例展示三条身份同时存在:

  • item.id 作为 key
  • useId() 作为每个行组件内部的 DOM ID 基础;
  • item.id 作为业务数据身份传入组件。
import { useId, useState } from "react";

type Task = {
  id: string;
  title: string;
  details: string;
};

function TaskRow({ task }: { task: Task }) {
  const titleId = useId();
  const detailsId = `${titleId}-details`;
  const [draft, setDraft] = useState(task.title);

  return (
    <li>
      <label htmlFor={titleId}>任务标题</label>
      <input
        id={titleId}
        value={draft}
        aria-describedby={detailsId}
        onChange={(event) => setDraft(event.target.value)}
      />
      <p id={detailsId}>{task.details}</p>
    </li>
  );
}

export function TaskList({ tasks }: { tasks: Task[] }) {
  return (
    <ul>
      {tasks.map((task) => (
        <TaskRow key={task.id} task={task} />
      ))}
    </ul>
  );
}

输入初始数据:

[
  { id: "t1", title: "修复登录问题", details: "优先处理生产环境错误" },
  { id: "t2", title: "更新文档", details: "补充水合说明" }
]

第一次渲染时可以抽象为:

TaskList
├── TaskRow key="t1"
│   ├── useId() -> :R1:
│   ├── input id=":R1:"
│   └── p id=":R1:-details"
└── TaskRow key="t2"
    ├── useId() -> :R2:
    ├── input id=":R2:"
    └── p id=":R2:-details"

当服务端在头部插入任务 t0 后,客户端收到:

[
  { id: "t0", title: "部署监控", details: "确认告警规则" },
  { id: "t1", title: "修复登录问题", details: "优先处理生产环境错误" },
  { id: "t2", title: "更新文档", details: "补充水合说明" }
]

React 根据 key 进行匹配:

旧 t1 -> 新 t1
旧 t2 -> 新 t2
新 t0 -> 新建

因此:

  • t1draft 状态不会被 t0 夺走;
  • t2draft 状态仍属于 t2
  • 每个 TaskRow 实例自己的 DOM ID 关系保持有效;
  • DOM 元素可能移动或被重新排列,但业务实体与 React 状态的对应关系不变。

如果改成:

<TaskRow key={index} task={task} />

则插入 t0 后会出现:

旧位置 0 的 TaskRow -> 新 t0
旧位置 1 的 TaskRow -> 新 t1
旧位置 2 的 TaskRow -> 新 t2

此时 useId 仍可能对“这些组件实例”保持稳定,但组件实例已经被错误地分配给不同任务。这个例子说明:useId 的正确性建立在 React 组件身份已经由正确 key 维护的基础上。


什么时候应该使用哪一种标识

数据实体有稳定 ID:用它作为 key

{products.map((product) => (
  <ProductCard key={product.id} product={product} />
))}

这是列表身份最直接的来源。数据库主键、后端生成的 UUID 或客户端创建后持久化的实体 ID 都可以,只要它对同一个实体保持稳定,并且在当前兄弟列表中唯一。

需要连接 label、input、描述和错误信息:用 useId

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

  return (
    <>
      <label htmlFor={id}>金额</label>
      <input id={id} inputMode="decimal" />
    </>
  );
}

如果一个组件需要多个相关 ID,可以基于同一个 useId 结果派生后缀:

const baseId = useId();
const inputId = `${baseId}-input`;
const errorId = `${baseId}-error`;

这些 ID 的关系在组件内部是确定的。

需要在数据库、URL 或 API 中引用:使用业务 ID

type User = {
  id: string;
};

fetch(`/api/users/${user.id}`);

不要把 useId() 的结果当成业务主键。React 生成的 ID 的用途是渲染树中的 DOM 关联;它不应作为数据库记录标识、权限对象标识或跨请求契约。

只有静态列表且不会重排:index key 才可能成立

例如固定的导航项:

const labels = ["首页", "设置", "帮助"];

function Navigation() {
  return (
    <nav>
      {labels.map((label, index) => (
        <a key={index} href="#">
          {label}
        </a>
      ))}
    </nav>
  );
}

这里之所以风险较低,是因为集合、顺序和元素含义都不会变化。不过这是一条由数据约束推导出的例外,不是“index key 通常没问题”。一旦导航项支持动态配置、权限过滤、拖拽排序或异步插入,就应改用稳定的业务 key。


用 key 显式控制状态重置

key 不仅用于列表,也可以有意改变单个组件的身份,从而重置其状态:

function ProfileEditor({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");

  return (
    <textarea
      value={draft}
      onChange={(event) => setDraft(event.target.value)}
    />
  );
}

function Screen({ userId }: { userId: string }) {
  return <ProfileEditor key={userId} userId={userId} />;
}

userId"u1" 变为 "u2" 时,key 改变,React 把它视为新的 ProfileEditordraft 会重新初始化。

这和使用 index key 造成的“意外状态错配”是同一个机制的有意使用:

key 不变 -> 尝试保留同一组件身份和状态
key 改变 -> 创建新的组件身份,旧状态被丢弃

因此,修改 key 是状态生命周期操作,不应把它当成普通的性能提示或消除警告的手段。


常见失败表现和诊断路径

看到“Each child in a list should have a unique key”

先检查产生数组 JSX 的位置,而不是只检查最终的 DOM:

function Parent() {
  return items.map((item) => <Child item={item} />);
}

应修改为:

function Parent() {
  return items.map((item) => (
    <Child key={item.id} item={item} />
  ));
}

如果数组经过了封装函数、条件表达式或 Fragment,确认 key 位于该数组返回的直接元素上。

输入内容跑到另一行

优先检查:

  • 是否使用了 key={index}
  • 是否使用了 Math.random() 或时间作为 key;
  • 数据是否发生了头部插入、删除、排序或过滤;
  • key 是否在不同实体间重复;
  • 是否把 key 误认为会传入子组件,从而没有显式传递业务 ID。

可以临时把实体 ID和渲染位置同时输出:

{items.map((item, index) => (
  <div key={item.id}>
    {index}: {item.id} - {item.name}
  </div>
))}

如果界面中的组件局部状态没有跟随 item.id 移动,通常是 key 规则与数据身份不一致。

label 点击后聚焦错误的 input

检查真实 DOM:

console.log(document.querySelectorAll("[id]"));

重点确认:

  • 每个 id 是否唯一;
  • label.htmlFor 是否精确匹配目标 input.id
  • 是否在组件中写死了 "email""search" 等通用 ID;
  • 是否把 useId 放在列表父组件中并试图手动分发,导致 Hook 规则被破坏;
  • 是否使用了包含特殊字符的 ID 作为 CSS 选择器而未转义。

例如:

document.querySelector(`#${id}`);

id 来自 useId() 且包含 CSS 需要特殊处理的字符时,不应直接拼接选择器。可以使用:

document.querySelector(`#${CSS.escape(id)}`);

或者优先使用 getElementById(id)id 在 HTML 中合法,不等于它未经处理就适合所有 CSS 选择器上下文。

开发环境出现水合警告

诊断时分别比较:

  1. 服务端返回的初始 HTML;
  2. 客户端第一次渲染使用的数据和条件;
  3. 是否读取了 windowlocalStorage、时间、随机数或浏览器尺寸;
  4. useId 是否只在组件顶层调用;
  5. 多根应用的 identifierPrefix 是否在服务端和客户端完全一致;
  6. 是否在服务端和客户端使用了不同的列表项集合或排序结果。

不要通过简单地隐藏警告来判断问题已解决。水合不一致可能导致事件处理器挂接到错误元素,或者使局部 DOM 被重新处理;应修正产生不同初始树的原因。


规范保证、实现细节与工程选择

React 保证的核心语义是:

  • key 用于同级列表子节点的身份匹配;
  • key 不作为普通 prop 传递,也不会自动渲染到 DOM;
  • Hook 必须遵守稳定调用顺序;
  • useId 用于生成可在服务端与客户端对齐的 ID,适合 DOM 关联;
  • useId 不应作为列表 key。

不应依赖的实现细节包括:

  • useId 字符串的具体格式;
  • key 是否按某种特定算法扫描;
  • 某次渲染中未使用的 DOM 节点是否恰好被复用;
  • 状态“看起来”是否保留,而没有明确分析类型、位置和 key;
  • 服务端输出中是否能看到 key。

工程上最重要的取舍是先确定实体边界,再分别选择标识:

业务实体身份       -> item.id
React 子节点身份   -> key={item.id}
组件内部 DOM 关联  -> const id = useId()
服务端/客户端根区分 -> identifierPrefix

当这四层职责分离时,列表重排不会破坏组件状态,多个表单实例不会产生 DOM ID 冲突,SSR 水合也不会依赖随机或进程级计数器。反之,如果用位置、随机值或写死字符串同时承担多种身份,问题会在状态、辅助功能和服务端渲染边界上分别出现,且往往彼此叠加。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。