背景
首页推荐列表出了一个问题:接口每次返回 6 条文章,里面有两条 ID 相同。第一次加载能看到重复文章,再点“换一换”,页面竟然多了一篇。
先检查请求和状态更新。接口数量没变,组件用的也是 setList(res.result.data),没有拼接旧数组。渲染代码则是常见的写法:
return (
<section>
{list.map((article) => (
<Article
key={article.id}
article={article}
/>
))}
</section>
);
控制台已经提示 key 重复。可“key 重复会有问题”只能指出方向,还没解释清楚:多出来的那篇究竟从哪里来?
后来用 Vue 3 写了同样的例子。数据还是 6 条,ID 还是重复,Vue 在更新时给出警告,页面却保持 6 条。这就值得把两边源码放在一起看了。
这里的“刷新”指同一个组件内替换列表,例如点击“换一换”。浏览器整页重新加载会重新创建页面,不能直接套用下面这段旧节点残留的过程。
先把数据固定下来
两个示例都在下面两组数据之间切换:
第一次:1001、1002、1002、1003、1004、1005
第二次:2001、2002、2002、2003、2004、2005
两组各有 6 条,组内存在重复 ID,新旧两组之间没有相同 ID。这个条件很关键。
审稿时,又用本地 React 19.2.7 配合 jsdom 跑了一次最小验证:每条数据渲染一个 article,同一个 root 连续提交两组数据,提交完成后统计 DOM。没有开启 StrictMode。
首次提交:数据 6 条,article 节点 6 个
再次提交:数据 6 条,article 节点 7 个
残留节点:第一组中第一个 key 为 1002 的 article
多出来的是旧节点。新一轮仍然只创建了 6 个 article,却没有把上一轮的节点全部清走。
下面选取 React 19.1.0 和 Vue 3.5.13 的发布标签讲解对应算法,避免 main 分支变化后链接对不上。它们是源码讲解基线,不是声称两份示例都安装了这些版本。代码片段均做了删减;React 的上述 DOM 数量是本地实测,Vue 部分结合此前页面观察和源码推导。
React:Map 覆盖之后,少删了一个旧节点
先看 ReactChildFiber.js 中的 mapRemainingChildren。这个函数会把待处理的旧 Fiber 放进 Map,显式 key 对应的语句是:
existingChildren.set(existingChild.key, existingChild);
为什么这次会走到这里?在它之前,reconcileChildrenArray 会从头调用 updateSlot。对于普通 React 元素,key 对不上就返回 null,退出顺序匹配。本例第一项已经从 1001 变成 2001,所以剩余的全部旧节点都会进入 Map。
给两个重复节点分别标记为 B₁、B₂,方便追踪;它们的 key 都是 1002:
旧节点:A、B₁、B₂、C、D、E
Map:
1001 → A
1002 → B₂ // B₁ 的映射被覆盖
1003 → C
1004 → D
1005 → E
Map 里只剩 5 个条目。不过,Map 覆盖只是替换了一条映射,并不会顺带把 B₁ 的 DOM 删除。
接着,React 遍历新列表,通过 updateFromMap 查找可复用节点。这次所有新 key 都以 200 开头,一个旧节点也匹配不到,于是创建 6 个新 Fiber,并标记插入。
问题出在最后的清理。在 reconcileChildrenArray 的末尾,React 根据 Map 中剩余的条目登记删除:
existingChildren.forEach((child) => deleteChild(returnFiber, child));
清理遍历的对象是 Map。B₁ 早已被覆盖,不在这个 Map 里,也就没有在这一步被登记删除。等到提交阶段,5 个旧节点被移除,6 个新节点被插入,B₁ 留在原来的容器里:
6 个旧 DOM − 5 个被删除的 DOM + 6 个新 DOM = 7 个 DOM
这就把“多一篇”解释完整了:在这组输入和更新路径下,被覆盖的旧节点漏掉了删除处理。
这个结果有明确的触发条件。不能推广成“只要 key 重复,每次就多一条”。例如新旧 key 序列完全一致、只修改标题,React 也可能一路走完顺序匹配,根本不建立这个 Map。key 相同之后,还要继续判断元素类型等条件,才能决定是否复用 Fiber。
Vue:也有 Map,但删除时遍历的是旧数组
Vue 的关键代码在 renderer.ts 的 patchKeyedChildren。
Vue 确实先做头部、尾部匹配,但这不能解释本例为什么正常。新旧第一项分别是 1001 和 2001,最后一项分别是 1005 和 2005,头尾都匹配不上。
它判断节点是否相同的条件也很明确。在 vnode.ts 的 isSameVNodeType 中,排除开发热更新分支后是:
return n1.type === n2.type && n1.key === n2.key;
位置一样,并不代表可以直接复用;类型和 key 都要相同。原先把这个例子解释成“Vue 按位置更新”,漏掉了 key 已经全部改变的事实。
进入中间部分的处理后,Vue 建立的是新列表的 key 到下标映射:
2001 → 0
2002 → 2 // 下标 1 被覆盖,同时产生重复 key 警告
2003 → 3
2004 → 4
2005 → 5
Vue 同样发生了 Map 覆盖。区别在下一步:它逐个遍历旧数组 c1,查找每个旧节点在新列表中的位置。下面是省略其他分支后的逻辑:
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
const newIndex = keyToNewIndexMap.get(prevChild.key);
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true);
}
}
旧的 1001、两个 1002、1003、1004、1005,全都在新 Map 中找不到。由于循环遍历的是完整旧数组,两个 1002 都会被访问、卸载,没有谁因为 Map 覆盖而被跳过。
接下来,Vue 用 newIndexToOldIndexMap 记录新列表各位置是否有旧节点与之匹配。本例一个也没匹配到,所以六个位置的值都是 0。最后的挂载循环按新列表位置遍历,为这六个位置分别创建节点。
因此,本例的过程是:
完整遍历旧数组,删除 6 个旧 DOM
按新列表位置,创建 6 个新 DOM
最终仍然是 6 个 DOM
重复的新 key 覆盖了查询表中的一个下标,但没有删掉新数组中的那条数据,也没有让旧数组少一次遍历。这才是这组输入下 Vue 保持 6 条的原因。
Vue 这次能正确清空旧节点,还有一个前提:新旧 key 没有交集。如果改成部分重叠,两个旧节点就可能查到同一个新下标,匹配关系仍然会出问题。
回到推荐列表
这次排查最容易忽略的是:数据数组只有 6 条,并不能保证更新后的 DOM 也只有 6 个。旧 DOM 如果漏删,页面就会留下数组里已经没有的文章。
如果业务确实允许同一篇文章出现多次,例如来自不同推荐位,就需要为“这一条推荐记录”提供独立、稳定的身份。文章 ID 标识文章,未必能唯一标识列表中的一次展示。
这次 React 与 Vue 的差异,落在了几行很具体的代码上:Map 覆盖了谁,删除循环又遍历了谁。把这两步顺着走完,“为什么多一篇”就不再只是一个重复 key 警告了。