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 在一次渲染中得到的是一棵元素树。可以把一个元素的有效身份近似表示为:
其中:
父节点表示它在哪个父元素的子节点集合中;层级表示它位于哪一层;类型是组件函数、类组件或宿主元素类型,例如UserCard、div;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>
);
}
当 show 从 false 变为 true 时,Counter 是首次出现;当 show 从 true 变为 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 看到的是:
- 旧 key
0对应的ItemEditor仍然存在,于是把原来编辑Alpha的状态复用于Xray; - 旧 key
1对应的状态被复用于Alpha; Beta得到新 key2,原来的编辑状态丢失。
这不是 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 集合满足:
这里的唯一性只要求在同一个兄弟列表中成立。不同列表可以使用同样的 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-describedby、aria-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 的重要属性不是“看起来像业务主键”,而是:
- 同一个组件实例在重新渲染时保持稳定;
- 服务端渲染与客户端首次渲染能够生成相互匹配的值;
- 可以用来连接同一组件内部的多个 DOM 元素;
- 不需要开发者自行维护全局计数器。
生成的字符串可能包含冒号等内部格式字符,例如 :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()结果保持不变; - 它的
label、input和描述节点之间的 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 不参与 List 对 Row 子节点的匹配,因此仍会收到缺少 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 树的第一次相遇
什么是水合
服务端渲染通常经历两个阶段:
- 服务端执行 React 渲染,生成 HTML;
- 浏览器收到 HTML 后,客户端 React 执行对应组件,并将事件处理器和 React 管理能力接到现有 DOM 上。
第二步通常称为水合(hydration)。它不是简单地“重新生成一份 HTML 再替换”,而是尝试复用服务端已经产生的 DOM。
可以把水合的初始一致性条件写成:
其中:
- 是组件树的渲染过程;
- 是相同的 props 和路由数据;
- 、 分别是服务端和客户端环境;
- 表示至少在 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 -> 新建
因此:
t1的draft状态不会被t0夺走;t2的draft状态仍属于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 把它视为新的 ProfileEditor,draft 会重新初始化。
这和使用 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 选择器上下文。
开发环境出现水合警告
诊断时分别比较:
- 服务端返回的初始 HTML;
- 客户端第一次渲染使用的数据和条件;
- 是否读取了
window、localStorage、时间、随机数或浏览器尺寸; useId是否只在组件顶层调用;- 多根应用的
identifierPrefix是否在服务端和客户端完全一致; - 是否在服务端和客户端使用了不同的列表项集合或排序结果。
不要通过简单地隐藏警告来判断问题已解决。水合不一致可能导致事件处理器挂接到错误元素,或者使局部 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React useSyncExternalStore:外部状态、一致快照、订阅和 SSR
- 下一篇:React 表单与 Action:原生提交、状态、乐观更新和错误
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论