React key 的语义边界:从隐式索引到稳定身份的类型安全治理
React 官方文档把 key 描述为“帮助 React 识别哪些元素发生了变化”,但工程实践中它常被降级为一句消除警告的补丁。本文要谈的不是“怎么消掉那个黄色 warning”,而是:key 本质上是列表项的身份(identity),把身份选型交给运行时的隐式索引,等于让 React 的 reconciler 在错误的前提上做 diff。
为什么索引是一种身份错觉
先看一个反面案例——用数组下标做 key:
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo.title}</li>
))}
</ul>
);
}
这串代码在只追加(append)时看起来完全正常:新元素拿到新下标,旧元素下标不变,React 能正确复用。问题出在头部插入、删除或重排时。假设列表从 [A, B, C] 变为 [X, A, B, C],下标 0、1、2、3 对应的元素全部改变:
- React 认为下标
0的节点从A换成了X,销毁旧 DOM 重建; - 下标
1从B换成A,再次重建…… - 所有带状态的子组件(受控输入、动画、焦点)全部错位。
更隐蔽的是 useState 状态与 DOM 焦点会被“嫁接到”错误的下标上。一个经典复现:
function Row({ label }: { label: string }) {
const [checked, setChecked] = useState(false);
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{label}
</label>
);
}
function BadList({ items }: { items: string[] }) {
return (
<>
{items.map((item, i) => (
<Row key={i} label={item} />
))}
</>
);
}
当你勾选第一项再在头部插入一项,勾选状态会“平移”到新插入项上——因为 React 复用了下标 0 对应的组件实例。这不是 bug,是把身份定义错了。
正确姿势:让数据自带稳定身份
修正方式很简单,让 key 来自数据域的唯一标识:
function GoodList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
这里 todo.id 是稳定、唯一、可预测的身份。只要 id 不变,无论列表如何增删重排,React 都能精确复用对应组件实例,状态与 DOM 始终跟随正确的数据。
类型系统如何抓住身份选型错误
靠 Code Review 让人“别用索引做 key”是不可持续的。我们能用 TypeScript 把“key 必须来自稳定身份”固化为编译期约束。核心思路是:让 key 的类型退化为一个不允许下标出现的集合。
第一步,定义一个携带稳定 ID 的泛型约束:
type Identifiable = { id: string | number };
第二步,封装一个受约束的列表渲染工具,强制 key 只能取 id:
function KeyedList<T extends Identifiable>({
items,
render,
}: {
items: T[];
render: (item: T) => React.ReactNode;
}) {
return (
<>
{items.map((item) => (
<Fragment key={item.id}>{render(item)}</Fragment>
))}
</>
);
}
这样一来,任何没有 id 字段的数据都无法通过 KeyedList 的编译,直接用下标绕过 key 的路径被类型系统堵死。开发者被迫在上游为数据补充稳定身份,而不是在渲染层临时凑一个下标。
反例:对“无状态临时数组”过度设计
需要警惕另一个方向——把稳定身份教条化。对于静态、从不变更顺序的数组,索引 key 并非不可接受:
// 这列数据只在挂载时生成,且永不重排
const STEPS = ['概述', '环境', '部署'];
function Steps() {
return (
<ol>
{STEPS.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>
);
}
此类场景用 i 无害,因为身份从未被挑战。真正的问题是“不知道何时会重排却默认用索引”。工程判断的落点是:数据是否具有可变顺序——可变,就必须有业务唯一 ID。
用 ESLint 兜底,形成双重防线
类型系统管住“身份来源”,Lint 管住“漏网的 map”。eslint-plugin-react 的 jsx-key 规则能在静态层面拦截缺失的 key:
{
"rules": {
"react/jsx-key": ["error", { "checkFragmentShorthand": true }]
}
}
再叠加一个团队约定:禁止在 key 使用 index 的表达式,除非通过 // eslint-disable-next-line 显式豁免并附上“为何顺序不可变”的注释。这样每处索引 key 都需要一次有意识的豁免决策,而非无意识默认。
小结
key 从来不是消除 warning 的占位符,而是 reconciler 做身份匹配的唯一依据。把“key = 稳定身份”这条隐性约定,通过 TypeScript 泛型约束 + ESLint 规则 显式地写进工程,才能在列表重排时保住状态、焦点与 DOM 复用。记住这条判断标准:数据会重排吗?会,就必须有业务唯一 ID;不会,索引才勉强可用。
评论区
登录 后参与评论