React 列表 key 必须来自数据中的稳定唯一标识;会插入、删除或排序的列表不要用数组下标,也不要用 Math.random,否则组件状态可能跟错数据或被反复重建。

你渲染一组带输入框的待办项,删除第一条后,第二条输入框的内容却跑到新第一条;或者每次父组件更新,列表里的输入焦点都消失。这往往不是 state 写错,而是 key 没有准确表示组件身份。本文依据 React 官方“在列表中保持身份”的规则,用稳定 ID、数组下标和随机 key 三个版本解释状态错位。本机没有 React 项目,示例为完整组件但未执行,结果按官方协调语义给出。
一、先看结论:三种 key 写法怎么选
| key 写法 | 表示什么 | 重排后状态 | 适用场景 |
|---|---|---|---|
稳定 ID key={item.id} |
数据身份 | 跟着数据走 | 会增删、排序、过滤的列表 |
数组下标 key={index} |
屏幕位置 | 跟着位置走 | 完全静态、永不增删排序 |
随机值 key={Math.random()} |
每次都是新身份 | 状态全部丢失 | 不推荐 |
一句话:key 是组件身份,不是消除警告的装饰;动态列表优先用稳定 ID。
二、React key 表示同级列表中的组件身份
React 在两次渲染之间比较同一层级的元素。type 和 key 一起帮助它判断“这是上次的那个组件,还是一个新组件”。key 不会作为普通 prop 自动传给子组件;业务需要 id 时,应单独传 id。
{items.map(item => (
// key 供 React 识别组件身份
// id 作为业务 prop 单独传给 TodoRow
<TodoRow key={item.id} id={item.id} title={item.title} />
))}
这里 item.id 同时服务两件不同的事:key 供 React 识别组件,id prop 供 TodoRow 处理业务。二者值可以相同,但职责不同。
| 属性 | 作用 | 是否传给组件 |
|---|---|---|
key |
React 内部识别组件身份 | 不会 |
id |
业务逻辑使用 | 会 |
刚接触组件、props 和 state 时,可以先过一遍 React 基础教程。理解 key 的关键不是记“必须写”,而是回答:数据重排后,这个组件还是不是同一个业务实体?
三、用稳定 ID 保持输入状态归属
下面的完整示例允许添加、删除和反转待办项。每条数据在创建时获得一次 id,后续所有渲染都复用它。
import { useState } from "react";
function TodoRow({ id, title, onRemove }) {
// 每行独立的备注状态
const [note, setNote] = useState("");
return (
<li data-id={id}>
<strong>{title}</strong>
{/* 输入框绑定当前行的 note 状态 */}
<input
aria-label={`${title}备注`}
value={note}
onChange={event => setNote(event.target.value)}
/>
{/* 点击删除当前行 */}
<button onClick={onRemove}>删除</button>
</li>
);
}
export default function TodoList() {
// 初始数据:id 稳定唯一
const [items, setItems] = useState([
{ id: "learn-js", title: "学习 JavaScript" },
{ id: "learn-react", title: "学习 React" },
]);
return (
<>
{/* 反转顺序,观察状态是否跟着业务实体走 */}
<button onClick={() => setItems(current => [...current].reverse())}>
反转顺序
</button>
<ul>
{items.map(item => (
<TodoRow
key={item.id} // React 用 key 识别组件身份
id={item.id} // 业务 id 单独传给组件
title={item.title}
onRemove={() =>
setItems(current => current.filter(x => x.id !== item.id))
} // 按 id 删除,而不是按位置删除
/>
))}
</ul>
</>
);
}
预期结果(未在本机执行)是:在“学习 React”输入框写备注后反转列表,备注仍属于“学习 React”。删除另一项也不会改变它的本地 state,因为 key=learn-react 始终对应同一个业务实体。
四、数组下标为什么会让状态跟错行
如果把代码改为 key={index},key 表示的是当前位置,不是数据身份。反转前位置 0 对应“学习 JavaScript”,反转后位置 0 对应“学习 React”;React 可能复用位置 0 的 TodoRow 状态,于是备注留在位置上,却换了标题。
{items.map((item, index) => (
// 数组下标表示位置,不表示数据身份
// 重排后可能把旧状态留在原位置
<TodoRow key={index} title={item.title} />
))}
数组下标并非永远禁止。满足以下全部条件时可以接受:
| 条件 | 说明 |
|---|---|
| 列表完全静态 | 长度和顺序永远不变 |
| 不会插入、删除、过滤、排序 | 没有重排操作 |
| 子组件没有需要保持的本地状态 | 如输入框、展开开关、动画 |
| 未来需求也不会改变这些前提 | 否则应提前用稳定 ID |
只要有一项不确定,就应该使用稳定 ID。
另一个容易忽略的错误是把 key 放在 TodoRow 内部的 li 上。React 比较的是 map 直接返回的元素,因此 key 必须写在 TodoRow 上,而不是写进组件内部。
| 错误写法 | 问题 |
|---|---|
key 写在组件内部的 li 上 |
React 比较的是 TodoRow,不是内部的 li |
key 写在子元素上 |
同级列表身份无法正确匹配 |
key 用可编辑字段 |
字段变化会导致组件重建 |
五、随机 key 会让组件每次都被当成新对象
Math.random()、Date.now() 或每次渲染重新生成 UUID,都会让 key 在两次渲染之间变化。React 无法匹配旧组件,只能卸载旧实例并挂载新实例,本地 state、焦点和未提交输入都会丢失。
{items.map(item => (
// 每次渲染都生成新 key,React 会认为是全新组件
// 本地 state、焦点和动画都会丢失
<TodoRow key={Math.random()} title={item.title} />
))}
预期表现(未在本机执行)是父组件任意更新都可能重建全部 TodoRow。对于昂贵组件,这还会带来额外渲染成本。
| 随机 key 的后果 | 表现 |
|---|---|
| 组件重建 | 旧实例卸载,新实例挂载 |
| 本地状态丢失 | 输入框、展开开关被重置 |
| 焦点丢失 | 正在输入时焦点跳走 |
| 性能成本 | 昂贵组件反复挂载卸载 |
| 动画异常 | 过渡动画重新开始 |
若后端没有 ID,应在数据进入前端时生成一次并存进数据,而不是在 render 内生成。数据库主键、接口稳定 ID、文件路径或业务组合键都可以,但组合键必须保证在同级列表内唯一且不会随可编辑字段改变。
想继续理解渲染、状态和组件生命周期,可以对照 React 进阶教程。key 只要求同级兄弟间唯一,不要求整个应用全局唯一。
六、用三步测试验证 React key 是否正确
不要只消除控制台警告。真正的 React key 验收应覆盖身份变化:
| 步骤 | 操作 | 验证目标 |
|---|---|---|
| 1 | 在中间插入一条数据 | 原行的输入和展开状态不变 |
| 2 | 删除第一条和中间条 | 状态没有转移到邻行 |
| 3 | 反转或按字段排序 | 焦点、动画、子组件缓存跟随业务实体 |
若问题只在开发环境出现两次日志,不要立刻归因于 key,还要区分 Strict Mode 的开发检查。反过来,控制台没有 duplicate key 警告,也不代表数组下标适合动态列表。
把列表渲染问题缩小成最小复现
前置条件是让每一行拥有可观察的局部状态,例如输入框内容或“已展开”开关。先渲染 A、B、C 三条记录,在 B 行输入文字,再分别执行头部插入、删除 A 和反转数组。
| 操作 | 稳定 ID 作为 key | 数组下标作为 key | 随机 key |
|---|---|---|---|
| 在 B 行输入文字 | 文字属于 B | 文字可能停在原屏幕位置 | 所有输入被重置 |
| 头部插入 | B 状态不变 | 状态可能跟错行 | 全部重建 |
| 删除 A | B 状态不变 | 状态可能转移 | 全部重建 |
| 反转数组 | B 状态不变 | 状态可能跟错行 | 全部重建 |
这组操作直接验证组件身份,比只观察 DOM 顺序更可靠。
如果服务端暂时没有主键,应在数据进入前端状态时生成一次 ID 并持久保存,而不是在 render 中调用 randomUUID。分页和虚拟列表还要确认同一业务实体跨页或回收后仍使用同一个 ID。本文示例按 React 组件身份规则检查,但当前环境没有安装 React 测试依赖;接入项目后,应使用 Testing Library 固定输入、重排和断言步骤,并把重复 key 警告视为失败,而不是在控制台中忽略。
在性能边界上,稳定 key 能让 React 复用仍存在的组件,但它不会自动阻止所有重新渲染;是否需要 memo 还要由性能分析决定。适用边界也要写清:真正静态、不会增删和排序的展示列表可以使用下标,但一旦加入交互就应改为稳定 ID。

总结
React 列表 key 的本质是身份,不是消除警告的装饰。稳定 ID 让状态跟着数据移动,数组下标让状态跟着位置移动,随机值则让组件每次都变成新实例。
写列表时先找数据的稳定主键;没有主键就让数据在进入状态时生成一次。最后用插入、删除和重排三组操作测试,才能证明 key 在真实变化中仍然正确。
延伸学习
- 用 React+Redux交互式用户界面 练习列表与状态管理;
- 阅读 Vue 与 React 选择笔记,理解不同框架的组件更新思路;
- 再看 Jotai 状态管理笔记,区分列表身份和全局状态职责。
常见问题
Q:key 可以在整个页面重复吗?
A:可以。key 只需在当前同级列表中唯一;不同列表之间使用相同 id 不会互相影响。
Q:数据库 ID 会变化怎么办?
A:如果 ID 会随编辑改变,它就不适合作为组件身份。应使用不可变主键,或在数据进入前端时生成并持久保存一个本地 ID。
Q:列表完全静态还能用下标吗?
A:可以,但前提是顺序和长度永远不变,子组件也没有需要保持的状态。需求一旦可能演进,稳定 ID 的维护成本更低。
Q:为什么组件收不到 key prop?
A:key 是 React 的保留信息,不会传进组件。组件需要业务 ID 时,要额外写 id={item.id} 或其他明确 prop。

免费 AI IDE



