React List Keys
问题的起点:一个常见的错误
假设你有一个待办事项列表,用户可以添加、删除、重新排序。如果你在渲染列表时使用 index 作为 key,很可能会遇到奇怪的 bug:某一项的内容明明没变,却莫名其妙地重新渲染了;或者删除一项后,错误的项目消失了。这种 bug 很难调试,因为它不是每次都出现——只有在列表发生变化时才暴露。
理解这些 bug 的根源,需要从 React 的 reconciliation(协调)算法 说起。
React Reconciliation:key 存在的上下文
当 React 更新 DOM 时,它需要决定哪些部分需要变化。React 最初使用的是简单的启发式算法:比较新旧的 children 列表,按顺序逐个对比。如果两个 children 的类型相同,就认为可以复用 DOM 节点。
这个策略在列表场景下会遇到问题:
// 初始渲染
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
// 在头部插入一个新项后
<ul>
<li>New Item</li>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
如果没有 key,React 会认为所有 <li> 类型相同,于是尝试复用现有 DOM 节点:
- 第一个
<li>的内容从 "Item 1" 变成 "New Item" - 第二个从 "Item 2" 变成 "Item 1"
- 以此类推...
结果是整个列表的 DOM 节点都被更新了,即使实际上只有一项新增——这是巨大的性能浪费。更糟糕的是,如果这些列表项是有状态的(比如输入框),用户在一个输入框里输入的内容会莫名其妙地跑到另一个输入框里。
key 的作用就是给 React 提供一个身份标识,让它能精确地知道哪个 DOM 节点对应哪个数据项。
key 的作用:diff 算法的关键输入
React 使用的是一种基于 key 的协调策略。Dan Abramov 在 reconciliation 文档 中解释了这个机制的核心:
当你给列表项指定一个稳定的 key 时,React 可以精确地追踪每一项在前后两次渲染之间的对应关系。key 不变的项,React 会认为它还是同一个项,只更新必要的属性;key 消失的项,React 会卸载它;新的 key 出现,React 会创建新的 DOM 节点。
const todoItems = [
{ id: 'todo-1', text: 'Learn React' },
{ id: 'todo-2', text: 'Build an app' },
{ id: 'todo-3', text: 'Deploy it' }
];
function TodoList() {
return (
<ul>
{todoItems.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
在这个例子里,每个 <li> 都有一个稳定的 id 作为 key。当列表顺序变化时,React 通过 key 能精确知道:
- 哪些项是同一个项(只是位置变了)
- 哪些项是新加的
- 哪些项被删除了
这样 React 只需要移动 DOM 节点,而不是销毁再创建。
为什么不能用 index 作为 key
这是 React 开发者最常犯的错误之一。让我们通过具体场景分析为什么 index 作为 key 会出问题。
场景一:列表头部插入
// 初始列表
const items = [{ id: 0, text: 'A' }, { id: 1, text: 'B' }, { id: 2, text: 'C' }];
// 使用 index 作为 key 渲染后:
// key=0 -> <li>A</li>
// key=1 -> <li>B</li>
// key=2 -> <li>C</li>
// 在头部插入新项 'D' 后
items.unshift({ id: 3, text: 'D' });
// 新的 key=0 对应 <li>D</li>, key=1 对应 <li>A</li>, ...
// React 看到 key=0 的内容从 'A' 变成了 'D',认为这是同一个 DOM 节点的更新
// React 看到 key=1 的内容从 'B' 变成了 'A'...
// 实际上 React 做的是更新所有现有节点的文本内容,而不是简单地在头部加一个新节点
用 index 作为 key 时,插入操作会导致所有现有项的 key 发生变化(因为它们的索引都 +1 了)。React 看到 key 变化,就会认为是新的项,需要更新 DOM 内容。而真正的"移动"操作被当成了"更新",效率大打折扣。
场景二:列表项有内部状态
这是一个更严重的场景。考虑一个包含输入框的列表:
function TodoList() {
const [items, setItems] = useState([
{ id: 0, text: 'Task A' },
{ id: 1, text: 'Task B' }
]);
return (
<ul>
{items.map((item, index) => (
<li key={index}>
<input defaultValue={item.text} />
</li>
))}
</ul>
);
}
用户如果在第一个输入框里输入了 "Hello",然后在列表头部插入一个新项:
// 插入后,index=0 的项变成了新项,之前的 "Task A" 变成了 index=1
// 但 React 认为 key=0 的节点还在(只是内容变了),key=1 的节点也还在
// 用户之前在 "Task A" 输入框里输入的内容,会莫名其妙地跑到新的 index=0 输入框里
这是因为 defaultValue 对应的 DOM 节点被复用了——React 看到 key 没变(都是 0, 1),就复用了整个 DOM 子树,包括输入框里的值。
场景三:删除中间项
// 初始: [A(0), B(1), C(2)]
// 删除 B 后: [A(0), C(1)]
// 用 index 作 key 时:
// React 看到 key=0 没变,认为是 A(复用)
// React 看到 key=1 存在(之前是 C),认为这是同一个节点,更新内容为 "C"
// React 看到 key=2 消失了,卸载 key=2 对应的节点(C)
// 但 React 认为的 "C" 其实是之前的 C 节点,内容是对的
// 问题是:如果 C 有状态,这个状态会丢失吗?
删除场景下 index 作为 key 有时表现正确(当被删除项后面的所有项 key 都变化时,React 会更新它们)。但这依赖于具体的实现细节,不是可靠的行为。
key 的唯一性范围:兄弟节点之间
React 的 key 只需要在兄弟节点之间唯一,不需要全局唯一。
function App() {
return (
<div>
{/* 这两个 key="1" 不冲突,因为它们在不同的 <ul> 下 */}
<ul>
<li key="1">Item A</li>
<li key="2">Item B</li>
</ul>
<ul>
<li key="1">Another Item A</li>
<li key="2">Another Item B</li>
</ul>
</div>
);
}
这个设计是合理的——React reconciliation 是在同层级兄弟节点之间进行的,所以 key 只需要在同一组兄弟节点中唯一。
key 变化不会引起 DOM 更新:key 是身份标记
这是一个容易混淆的点:改变一个元素的 key,React 不会更新 DOM,而是会卸载旧节点、创建新节点。
function Component({ itemId }) {
return <div key={itemId}>{itemId}</div>;
}
当 itemId 变化时,React 会认为这是完全不同的组件实例。旧实例会被 unmount,新实例会被 mount。这意味着:
- 旧的 DOM 节点被移除
- 新的 DOM 节点被创建
- 任何本地状态都会丢失
这与 React 期望的 key 行为是一致的:key 是身份标识,不是"版本号"。如果 identity 变了,就不是同一个东西了。
所以,key 应该在一个列表的生命周期内保持稳定。你不应该用会变化的东西作为 key——比如数组的 index,或者每次渲染都会变的随机数。
生成稳定 key 的正确方式
使用数据自带的唯一标识
最好的 key 是数据本身的唯一标识:
// 数据库记录通常有 id
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
crypto.randomUUID()
现代浏览器提供了 crypto.randomUUID() API,可以生成符合 RFC 4122 标准的 UUID:
const newId = crypto.randomUUID();
// 例如: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
这适合在创建新数据项时生成 key:
function AddTodo() {
const [todos, setTodos] = useState([]);
function addTodo(text) {
setTodos([
...todos,
{ id: crypto.randomUUID(), text }
]);
}
return (
<>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<button onClick={() => addTodo('New task')}>Add</button>
</>
);
}
nanoid
nanoid 是一个更轻量的 ID 生成库,被很多现代框架(包括 Next.js)采用:
import { nanoid } from 'nanoid';
const id = nanoid();
// 生成 21 字符的 URL-safe 字符串,如 "V1StGXR8_Z5jdHi6B-m"
// 也可以指定长度
const shortId = nanoid(10);
nanoid 的优势是体积小(只有几百字节)、速度快、ID 碰撞概率极低。
组合使用:服务端 ID + 客户端生成
在真实应用中,你可能会同时处理服务端数据(已有 ID)和客户端新建的数据(需要生成 ID):
function generateStableKey(item) {
// 服务端数据通常有 id
if (item.id) return item.id;
// 客户端新建的数据用 nanoid 生成
// 注意:这种 key 在组件重新挂载后会变化
// 如果需要持久化的 key,应该在创建时就生成并存储
return item.tempId || (item.tempId = `temp-${nanoid()}`);
}
列表重新排序时的 key 选择策略
最佳实践:使用稳定的业务 ID
排序场景是对 key 机制的真正考验。考虑一个拖拽排序的列表:
function SortableList({ items, onReorder }) {
return (
<ul>
{items.map(item => (
<li key={item.id} draggable>
{item.name}
</li>
))}
</ul>
);
}
使用稳定的业务 ID 时,React 能精确追踪每个节点的位置变化:
- 移动 "Item B" 从 index 1 到 index 0
- React 只需要移动 DOM 节点,不需要更新内容
什么时候用 index 作为 key 是可接受的
在某些特定场景下,用 index 作为 key 是可以接受的:
- 静态列表:列表永远不会变化(不增删、不排序)
- 列表项没有本地状态:项内没有输入框、没有用户交互产生的数据
- 列表不会很长:性能影响可控
// 一个可以接受 index 作为 key 的场景
function CategoryList() {
const categories = ['Electronics', 'Clothing', 'Books', 'Home'];
return (
<ul>
{categories.map((cat, index) => (
<li key={index}>{cat}</li>
))}
</ul>
);
}
但严格来说,即使在这些场景下,使用 index 也不是最佳实践——它只是「错误代价较低」。
性能优化的关键点
避免在 render 中创建新 key
// 错误:在 render 中每次都创建新的 key
{items.map(item => (
<Component key={Math.random()} data={item} />
))}
// 正确:key 应该在数据创建时就确定
{items.map(item => (
<Component key={item.id} data={item} />
))}
Math.random() 每次都生成不同的值,React 会认为每次都是新组件,导致不必要的挂载/卸载。
key 应该放在最外层列表项上
key 应该放在直接映射列表的组件上:
// 正确:key 在 map 的直接子元素上
<ul>
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
// 错误:key 在 ListItem 内部
<ul>
{items.map(item => (
<ListItem item={item}>
<span key={item.id}>{item.name}</span>
</ListItem>
))}
</ul>
React 的 reconciliation 是从外到内进行的,key 必须给 React 提供足够的信息来决定如何处理整个组件树。
面试中的表达
面试官问 key 相关问题,通常是想确认你理解 reconciliation 机制和性能优化的原则:
React 的 key 是 reconciliation 算法的关键输入。当 React 对比新旧 children 列表时,key 帮助它精确地识别每个元素的身份。如果 key 不变,React 会尝试复用现有的 DOM 节点和状态;如果 key 变了,React 会认为这是不同的元素,卸载旧的、创建新的。
用 index 作为 key 在某些场景下会出问题:列表中间插入时,所有后续项的 index 都变了,React 以为它们都是新的;列表项如果有内部状态(比如输入框),状态会被错误地复用。最稳妥的方式是用数据本身的唯一标识作为 key,比如数据库 ID,或者用 nanoid/crypto.randomUUID() 生成。
key 的唯一性只需要在兄弟节点之间满足,不需要全局唯一。
延展阅读
- React Docs: Preserving and Resetting State — 官方文档,详细解释 key 如何影响 state 保留
- React Docs: Reconciliation — reconciliation 机制的官方说明
- Dan Abramov: Index as a Key is an Anti-Pattern — React 官方博客关于为什么 index 作为 key 是反模式
- Robin Pokorny: Index as a Key is an Anti-Pattern — 深入分析 index 作为 key 的具体问题场景