- Published on
React 列表渲染的 key 属性
在 React 中,通过数组映射来渲染元素列表是一种基础且常见的模式。在此过程中,key prop 扮演着一个至关重要的角色。它并非一个普通的属性,而是 React 用于其协调算法 (Reconciliation Algorithm) 的一个核心“提示 (hint)”。key 的正确使用,直接决定了列表在动态变化(如增、删、改、重排)时的渲染性能和状态管理的正确性。
key 的核心作用:元素的稳定身份标识
key 的核心作用是为列表中的每个元素提供一个稳定的身份标识。当 React 对比两次渲染之间的元素列表时,它会使用 key 来匹配新旧两个列表中的元素,从而判断哪些元素是新增的、被删除的,或是仅仅移动了位置。
key 的两大核心要求- 唯一性 (Unique): 在同一组兄弟节点中,每个元素的
key必须是唯一的。 - 稳定性 (Stable): 一个元素的
key不应随时间或重新渲染而改变。它应该与该数据项本身进行绑定。
如何选择
key- 来自数据库的数据: 应始终使用数据项的唯一主键(如
item.id)作为key。 - 本地生成的数据: 如果数据没有稳定 ID,应在创建时为其生成一个。可以使用
uuid等库来生成唯一标识符。 - 应避免: 绝对不要使用数组的索引 (
index) 作为动态列表的key,除非列表是完全静态、永不重排或筛选的。
key 对协调算法与性能的影响
React 的高效得益于其能够计算出 UI 状态变更的最小差异集,并仅对改变的部分进行 DOM 更新。key 在此过程中扮演了“寻路标”的角色。
反模式:使用索引作为 key(或不提供 key)
当不提供 key 时,React 会默认使用数组的索引 (index) 作为 key。如果一个列表的顺序会发生变化,这会导致严重的性能问题。
- 场景: 一个列表
[A, B, C],在A和B之间插入一个新元素D,列表变为[A, D, B, C]。
- React 的 diffing 过程
key=0:A->A。节点可复用。key=1:B->D。内容不同,React 会销毁B组件,新建并挂载D组件。key=2:C->B。内容不同,React 会销毁C组件,新建并挂载B组件。key=3: 之前不存在。新建并挂载C组件。
- 后果: 尽管
B和C组件实例只是移动了位置,但由于key(索引)的变化,React 错误地认为它们是全新的元素,导致了三个组件的不必要重渲染。
最佳实践:使用稳定 key
- 场景: 同样是
[A, B, C]变为[A, D, B, C],但每个元素都有稳定的 ID。
- React 的 diffing 过程:
- React 发现
key='id-a'的A仍然存在。 - React 发现
key='id-b'的B和key='id-c'的C也仍然存在,只是位置变了。它会移动这两个组件的 DOM 节点,而不会重新创建或渲染它们。 - React 发现
key='id-d'的D是一个新元素,于是只创建并挂载D组件。
- React 发现
- 后果: 实现了最高效的 DOM 更新,只进行了 1 次创建和 2 次移动,没有不必要的重渲染
key 与组件状态的正确性
使用索引作为 key 不仅是性能问题,更严重的是,它可能导致组件内部状态的错乱。
索引 key 导致的状态管理 Bug
当列表项是带有内部状态的组件(例如,一个 <input>),使用索引作为 key 会在列表项被删除或重排时,导致状态与数据的不匹配。React 复用组件实例是基于 key 的,如果 key 是索引,当数据项在数组中被删除时,React 会复用 DOM 节点,但会用新的 props 来渲染它,而该组件实例的内部 state 却被保留了下来。
输入框状态错乱的经典案例
使用索引作为 key (错误):
import React, { useState } from 'react';
export default function TodoListWithIndexKey() {
const [todos, setTodos] = useState([
{ id: 'a', text: '任务 A' },
{ id: 'b', text: '任务 B' },
{ id: 'c', text: '任务 C' },
]);
const handleDeleteFirst = () => {
// 移除数组的第一个元素
setTodos(currentTodos => currentTodos.slice(1));
};
return (
<div>
<h3>反模式:使用 Index 作为 Key</h3>
<p>步骤:1. 在“任务 A”的输入框里输入一些文字。 2. 点击删除按钮。</p>
<button onClick={handleDeleteFirst}>
删除第一项
</button>
<ul>
{todos.map((todo, index) => (
// ❌ 错误:在动态列表中,使用不稳定的 index 作为 key
<li key={index}>
<span>{todo.text}: </span>
<input
type="text"
placeholder="在这里输入..."
/>
</li>
))}
</ul>
</div>
);
}
复现步骤:
- 在第一个输入框(任务 A)中输入 "111"。
- 点击“删除第一项”按钮。
- 观察结果: “任务 A” 消失了,但它下方的输入框中的 "111" “漂移” 到了现在是第一项的“任务 B”的输入框中。这是因为 React 认为
key=0的<li>只是内容从“任务 A”变成了“任务 B”,所以复用了<li>及其子组件<input>,导致其内部 state 被错误地保留。
解决方案: 将 key={index} 替换为 key={todo.id} 即可完全修复此问题。