当 React 的 key 不再唯一:一次 DOM 僵尸节点的排查记录
TL;DR生产线上的工序任务管理页面出现了一个现象后端返回了 3 条数据表格却显示了 5 行。多次查询后行数越积越多但清除浏览器缓存或用全新浏览器打开就恢复正常。排查路径最终收敛到一行配置ResizableGridTable rowKeyid /。后端返回的数据中id字段存在重复值React reconciliation 在重复 key 的场景下无法正确卸载旧 DOM 节点每次查询都在页面上留下一个「DOM 僵尸」。本文记录了从表象到根因的完整排查过程以及 React reconciliation 在 key 冲突时的具体行为。背景一个「缓存问题」的表象问题最初被当作缓存问题上报工序任务管理页面切换查询条件后表格数据行数对不上。后端日志确认只返回了 2 条记录但页面上渲染了 4 行。清除浏览器缓存后恢复正常。排查时先确认了几个常规方向逐一排除React 状态逻辑组件中使用setDataSource(result.records)直接替换数组没有追加操作状态更新路径无误。HTTP 磁盘缓存Network 面板中 API 请求的 Size 列显示实际传输大小非disk cache确认请求真实到达了后端。localStorage项目中 localStorage 仅存储 auth token、session 标记和表格列宽不持久化业务数据。第三个方向排除之后一个线索浮现出来全新浏览器正常旧浏览器异常。这意味着问题不在代码逻辑或网络缓存而在浏览器本地的某种 DOM 状态残留。拆解现状React reconciliation 怎样使用 key要理解这个残留是怎么产生的需要先回到 React 的 reconciliation 机制。React 在两次渲染之间通过key属性匹配新旧 Fiber 节点内部维护一张MapKey, Fiber来做 O(1) 查找。这个机制有一个前提假设key 在当前列表的兄弟节点中唯一。React 文档对此有明确的约束但在实际项目中前端通常直接信任后端返回的id字段不额外校验唯一性。ResizableGridTable项目中的可拖拽表格组件内部将rowKey透传给 Ant Design 的Table组件最终作为每行的 React key。配置是ResizableGridTable rowKeyid ... /这意味着表格的每一行以record.id作为 React key。当后端数据中id出现重复时上述「key 唯一」的前提被打破。根因分析逐步复现 key 冲突下的 reconciliation以下用一个简化的场景来说明整个过程。第一次查询后端返回 3 条数据其中id有重复旧 dataSource: [ { id: A, name: 任务1 }, { id: A, name: 任务2 }, ← 重复 key { id: B, name: 任务3 } ]React 渲染 3 个 Fiber 节点各自对应一个 DOM 节点A₀ → tr keyA任务1/tr A₁ → tr keyA任务2/tr ← 与 A₀ 重复的 key B → tr keyB任务3/tr注意A₀和A₁拥有相同的 keyA但 React 在首次渲染时不做 key 唯一性校验——它只是把这三个节点挂到了 DOM 树上。第二次查询用户切换了查询条件后端返回 2 条新数据新 dataSource: [ { id: A, name: 任务4 }, { id: B, name: 任务5 } ]React 开始 reconciliation分三步走。第一步按 index 逐个比对index 0: 旧 keyAA₀ vs 新 keyA → 匹配 → A₀ 复用内容更新为任务4 index 1: 旧 keyAA₁ vs 新 keyB → key 不同 → 跳出按 index 比对这一步的产物A₀被复用并更新A₁和B进入「旧剩余节点」集合。第二步构建旧剩余节点 Map与新剩余节点匹配旧剩余节点 → Map { A → A₁, B → B } ↑ A₁ 占据了 key A 的槽位 新剩余节点 → [{ id: B }] 查 Map(B) → 找到 B → 复用 ✓B被正常匹配并复用。此时旧剩余节点中还剩下A₁key 为A。第三步清理未匹配的旧节点这是问题的关键。React 遍历旧剩余节点判断每个节点是否需要删除。判断逻辑是旧节点的 key 在新列表的 key 集合中存在吗检查 A₁: key 是 A → newKeys.has(A) true → React 认为 key A 已经被处理第一步中 A₀ 匹配了它 → A₁ 不被卸载留在 DOM 树中React 无法区分A₀和A₁——在 React 视角里它们都映射到同一个 keyA而新列表中确实存在一个keyA的行。于是A₁对应的 DOM 节点永远不会被卸载成为DOM 僵尸。累积效应每次查询都可能在 DOM 树中残留一个孤儿节点。多次查询后页面 DOM 中有 4 行但 React 只管理了其中 3 行第 4 行是 React 已经「遗忘」的僵尸节点——不被 Virtual DOM 追踪不被卸载不可交互用户看到的就是「行数越查越多」这解释了为什么清除浏览器缓存重新加载页面、重建 DOM 树后问题消失也解释了为什么全新浏览器不会触发——首次渲染没有旧 DOM 树不经过 reconciliation。解决方案前端修复优先采用问题的前提是 key 不唯一。前端作为最后一道防线不应完全信任后端数据的唯一性。修改rowKey配置// 修改前 ResizableGridTable rowKeyid ... / // 修改后 — 使用 index 保证同一列表内唯一 ResizableGridTable rowKey{(_record, index) index} ... /如果数据中存在天然唯一的业务字段如workTaskNo优先使用该字段ResizableGridTable rowKeyworkTaskNo ... /使用index作为 key 的取舍在列表会发生重排、筛选、插入的场景下index作为 key 可能导致组件状态错位。但对于以「整页替换」模式更新数据的表格每次查询直接setDataSource全量替换index是一个可接受的兜底方案。后端修复确保/work-order-process-task/page接口返回的id字段全局唯一。一个分页查询接口返回的列表中同一字段出现重复值应在数据层约束。补充防护在src/services/http/request.ts中为所有请求统一添加了Cache-Control: no-cache请求头防止浏览器缓存 API 响应——虽然这次问题根因不是 HTTP 缓存但作为防御层仍有价值。影响面这次排查的结论不只适用于当前页面。项目中任何使用rowKeyid且后端可能返回重复id的表格都存在相同的风险。需要关注的影响面所有使用ResizableGridTable且rowKey指向非唯一字段的页面任何以业务字段作为 React key、但该字段的唯一性无法在前端验证的场景分页场景下问题更隐蔽单页数据量小重复概率低不易暴露收束React key 的唯一性约束比看起来更关键。key 重复不只是「性能警告」它会导致 DOM 节点泄漏——被 React 遗忘的节点永远不会被垃圾回收也不会被用户正常交互。前端不能假设后端数据的完整性。即使数据库层面id有唯一约束接口聚合、联表查询、数据迁移等环节仍可能引入重复值。在rowKey这类影响渲染正确性的配置上前端做兜底校验成本很低。区分两类「缓存」排查时容易将 DOM 残留归因于 HTTP 缓存或本地存储但 React reconciliation 产生的僵尸节点是一种不同机制的残留——它存在于内存中的 Fiber 树和 DOM 树之间不在 DevTools 的 Application 面板中可见也不在 Network 面板中体现。将「数据残留」和「DOM 残留」分开考虑可以更快收敛排查方向。