讲解
渲染列表用数组的 map:{todos.map(t => <li key={t.id}>{t.text}</li>)}。JSX 里放数组会被展开渲染,每个元素需要一个 key——这就是控制台「Each child in a list should have a unique key」警告的来源。
key 不是给你用的,是给 React 的 diff 算法用的。状态变化后 React 重新执行组件得到新元素树,和旧树对比:同位置的元素 key 相同且类型相同,就复用对应的真实 DOM 和组件实例(只更新变化的属性);key 对不上就销毁重建。key 让 React 在「列表插入一项」时知道其余项只是挪了位置,而不是全部重画。
用 index 当 key 的 bug 场景很经典:一个带勾选框的待办列表,删除第一项后,后面每项的勾选状态全部「错位」——因为 React 按 index 复用了组件实例,第二项的实例被当成了第一项。数据有唯一 id 就用 id;没有 id 的静态列表(永远不增删排序)用 index 也没问题。排序、筛选、增删频繁的列表尤其不能偷懒。
还有一个隐藏细节:key 不仅能用在列表里。给组件一个变化的 key(<UserDetail key={userId}>)可以强制 React 销毁旧实例重建新实例——切换用户时所有内部状态自动重置,这是比手动重置 state 更声明式的「重开一局」技巧。
示例
亲手实现一个迷你 key-diff:看 key 如何决定「复用还是重建」(在本教程构建时被真实执行):
import assert from 'node:assert/strict';
// 迷你 reconcile:按 key 对齐新旧 children
function reconcile(oldChildren, newChildren) {
const oldByKey = new Map(oldChildren.map((c) => [c.key, c]));
let reused = 0;
let created = 0;
const result = newChildren.map((c) => {
const old = oldByKey.get(c.key);
if (old) {
reused++;
oldByKey.delete(c.key);
return { ...c, dom: old.dom }; // 复用 DOM 节点
}
created++;
return { ...c, dom: '新建DOM' };
});
return { result, reused, created, removed: oldByKey.size };
}
const oldList = [
{ key: 'a', text: '苹果', dom: 'DOM-a' },
{ key: 'b', text: '香蕉', dom: 'DOM-b' },
{ key: 'c', text: '樱桃', dom: 'DOM-c' },
];
// 删除 b,顺序不变 → a、c 复用
const r1 = reconcile(oldList, [
{ key: 'a', text: '苹果' },
{ key: 'c', text: '樱桃(更新)' },
]);
assert.strictEqual(r1.reused, 2);
assert.strictEqual(r1.removed, 1);
assert.strictEqual(r1.result[1].dom, 'DOM-c'); // c 的 DOM 被复用,文本更新
// 用 index 当 key 时删除第一项 → 实例错位复用
const withIndex = oldList.map((c, i) => ({ ...c, key: String(i) }));
const r2 = reconcile(withIndex, [
{ key: '0', text: '香蕉' }, // 删了苹果后,香蕉顶到 index 0
{ key: '1', text: '樱桃' },
]);
// React 视角:key 0 的 DOM(DOM-a,装着苹果的勾选状态)被复用给了香蕉!
assert.strictEqual(r2.result[0].dom, 'DOM-a');
// key 强制重置:userId 变了 → 整个子树重建
const detail1 = reconcile([{ key: 'user-1', dom: 'DOM-u1' }], [{ key: 'user-2' }]);
assert.strictEqual(detail1.created, 1);
assert.strictEqual(detail1.removed, 1);
console.log('稳定 key:复用', r1.reused, '个节点,只更新文本');
console.log('index 当 key:香蕉继承了苹果的 DOM 节点(状态错位!)');
真实代码:
function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.done} onChange={() => onToggle(t.id)} />
{t.text}
</li>
))}
</ul>
);
}
// key 强制重置技巧:切换用户时详情页状态清零
function Profile({ userId }) {
return <UserDetail key={userId} userId={userId} />;
}
常见坑
- 用 index 当 key:增删排序时组件状态错位(勾选、输入内容串行);有 id 必用 id。
- key 不唯一:重复 key 让 diff 行为不可预测,控制台警告必须处理。
- 用随机数当 key:每次渲染 key 都变等于每次重建,性能和状态全丢。
- 忽略「key 重置」技巧:切换数据源时手动重置一堆 state,不如给组件换个 key 一把梭。
- key 加在错误层级:key 要给 map 返回的最外层元素,加在内层无效。
小结
列表用 map + 稳定唯一 key;key 决定复用还是重建,变化的 key 可强制重置组件。下一章系统讲条件渲染的取舍。