症状
污染严格跟随位置:三行输入 “a”、“b”、“c”,删除第一行后列表显示 “b”、“c”,但第一个可见输入框里是 “b”——持有 “b” 的组件实例上移了一位并保住了自己的状态。机制是协调身份:key={i} 让组件身份等于列表位置,删除后 React 把旧索引 1 匹配新索引 1——同一个 key 即同一个实例,于是持有 “b” 的实例渲染起 “c” 的数据。删一行后下一行继承它的复选框、重排后编辑态落错条目、动画错放、焦点跳行,全部同源。bug 是确定性的,因此完全可测。
诊断思路
先 grep 所有 key={index} 形态,可变列表上的每一处都是潜在污染点;再写“删首行断言值”的确定性测试;最后检查数据是否有稳定 id——没有就在加载时生成并随条目持久保存。服务端数据缺标识符导致 index 成为顺手选择,是最常见起点。
根因
React 按 key 协调。索引 key 下组件身份等于位置:删除后,原本是第 2 行的组件变成第 1 行,继承第 1 行的状态与 DOM,其后每一行都继承邻居。行局部状态在任何变更后都表现为“向上迁移”。
修复与验证
key 用数据的稳定唯一标识(id/uuid);API 不给就在加载时造一个并终身保持。静态且永不重排的列表才允许索引 key;不需要行身份时把共享状态提升。要拒绝的反模式是 key={Math.random()}——每次渲染都变,所有行重挂载、状态清零、焦点摧毁,比索引更糟。验证时写删除与重排测试断言每行保持自己的状态、输入焦点与动画跟随逻辑条目。规则只有一条:同一逻辑条目,每次渲染同一 key。
代码示例
{rows.map((row, i) => (
<Row key={i} ... /> // identity = position
))}
{rows.map((row) => (
<Row key={row.id} ... /> // identity survives any mutation
))}
const rows = data.map((d) => ({ ...d, key: crypto.randomUUID() }));
// deletion filters rows; each remaining row keeps its own key and state