1. 起因

代码review 的时候经常看到 key={index} 满天飞,编译器不会报错,界面看着也没问题,直到某天一个可编辑列表出现了”删除第二行,结果第三行的输入框内容跑到第二行去了”这种诡异 bug,才想起来去查 key 到底是干嘛用的。

2. key 不是给你用来消除警告的

React 官方文档写得很清楚,key 是给 diff 算法用的,帮助 React 判断列表里哪些元素是”同一个”,从而决定是复用 DOM 节点还是重新创建。很多人(包括我自己)一开始只是为了消除控制台那条”Warning: Each child in a list should have a unique key prop”的红字,随手拿 index 当 key 用,反而埋了一个隐患。

1
2
3
{list.map((item, index) => (
<input key={index} defaultValue={item.text} />
))}

如果 list 只是纯展示、顺序永远不变、也不会增删,用 index 没什么问题。但只要列表会排序、插入、删除,用 index 当 key 就会出问题。

3. index 当 key 为什么会导致状态错位

1
2
3
4
5
6
7
8
9
const [list, setList] = useState([
{ id: 1, text: 'A' },
{ id: 2, text: 'B' },
{ id: 3, text: 'C' },
]);

function removeItem(id) {
setList(list.filter(item => item.id !== id));
}

假设用 key={index} 渲染这三行输入框,用户在第二行(index=1)输入了一些内容,然后点击删除第一行。删除后数组变成 [B, C],但 React 看到的 key 序列还是 [0, 1]——它认为 key=0 和 key=1 的节点还在,只是内容变了,于是复用了原来 index=0 和 index=1 的 DOM 节点,而不是把它们删掉重建。结果就是:原本第二行(B)的输入内容,现在挂在了第一行的 DOM 上,视觉上看起来像是”内容跟错了行”。

item.id 当 key 就不会有这个问题,因为 id 跟着数据走,删除哪一项,React 就精确知道该卸载哪个 DOM 节点:

1
2
3
{list.map(item => (
<input key={item.id} defaultValue={item.text} />
))}

4. 用随机数当 key 是另一个极端

见过有人为了”绝对不重复”,直接在 render 里用 Math.random() 或者 crypto.randomUUID() 生成 key:

1
2
3
{list.map(item => (
<Item key={Math.random()} data={item} /> // 每次渲染都换一个新 key
))}

这样每次渲染 key 都不一样,React 会认为所有节点都是全新的,之前的全部卸载,全部重新创建。表现出来就是列表每次重渲染都会闪一下,如果 Item 内部有动画、有 useEffect 挂载逻辑,会被无意义地反复触发,性能比不写 key 还差。key 一定要来自数据本身、且在组件生命周期内保持稳定,不能是渲染时现造的。

5. 判断标准

场景 建议
静态列表,不会增删排序 index 可以接受
列表有排序、插入、删除,或子项有内部状态(输入框、勾选框) 必须用数据自带的稳定 id
后端没有提供 id 前端生成一次性 id 并存进数据里(比如插入新项时 crypto.randomUUID()),而不是每次渲染都生成

6. 小结

key 这个属性小到经常被当成”为了消除警告加的东西”,但它直接决定了 diff 算法怎么复用 DOM。列表一旦涉及增删排序或者子项自带状态,index 当 key 基本等于埋雷,迟早会炸。