React 单端遍历,拿调度自由度换简单;Vue 双端比对加 LIS,拿 patch 复杂度换 DOM 复用。不是谁更先进,是两笔账。
列表里写 key={index},React 和 Vue 都会翻车。翻车姿势不同。
React:节点按位置复用了,state 还绑在旧 fiber 上。删掉第一项,第二项输入框里的字可能"继承"第一项的。状态与位置错配,肉眼可见。
Vue:双端比对先动首尾指针,index 刚好对得上的节点会被判成可复用,直接搬走。错位被推迟到某次顺序变化才爆,现场更难认。
面试官问 key,听的是你有没有读过 patch,不是背没背"双端 vs 单向"。
先拆几个流行结论
| 流传版本 | 实际情况 |
|---|---|
| Vue diff 全方位吊打 React diff | 常规顺序列表两者体感无差,只在乱序、倒序、头部批量插入时拉开 |
| React 没优化,就是暴力逐层比对 | 有同层比对、有 key 复用、有 lastPlacedIndex 判断,只是不做双端 |
| diff 算法能解决所有列表性能问题 | diff 只减少 DOM 操作,不减少 JS 计算,两者是两笔账 |
| 两个框架都禁止 index key,原因一样 | 结果类似,机制不同,Vue 的更隐蔽 |
index key 那条尤其值得展开。
React 的 index key 会让"节点复用但状态不复用"------state 错乱;Vue 的双端比对会让首尾节点被错误判定为可复用------结构错乱。
前者肉眼可见,后者要等到某次排序才暴露。
React:从头同步,剩下交给 Map
React 的 reconcileChildrenArray 是三段式:从头同步遍历,key 不同就 break;剩下的新节点去 mapRemainingChildren 建的 Map 里找旧 fiber;Map 里没被认领的旧 fiber 全部删除。
js
// React reconcileChildrenArray 结构示意
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
// 1. 从头同步比对,遇到 key 不一致立刻跳出
for (; oldFiber && newIdx < newChildren.length; newIdx++) {
if (oldFiber.key !== newChildren[newIdx].key) break;
oldFiber = oldFiber.sibling;
}
// 2. 剩余的旧节点全部塞进 Map,供新节点按 key 认领
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const child = newChildren[newIdx];
const matched = existingChildren.get(child.key ?? newIdx);
if (matched) {
existingChildren.delete(child.key ?? newIdx);
// oldIndex 小于 lastPlacedIndex 才需要真正挪 DOM
lastPlacedIndex = placeChild(matched, lastPlacedIndex, newIdx);
} else {
createFiberFromElement(child);
}
}
// 3. 没被认领的旧节点,直接删除
existingChildren.forEach(child => deleteChild(returnFiber, child));
关键在 lastPlacedIndex:它记录"已经放置过的最靠右的旧索引"。新节点对应的旧索引如果比它小,说明这个节点必须往右挪;如果比它大,原地不动就行。
这套逻辑能避免一部分无谓移动,但它不保证移动次数最少。
而且 React 没有"新尾 vs 旧尾"这一步 。列表整体倒序时,第一轮同步遍历立刻 break,后面全走 Map 查找加 lastPlacedIndex 判定,结果是大量节点被判定为需要移动。
Vue:四个指针交叉试探,乱序再上 LIS
Vue 的 patchKeyedChildren 分五步:前置同步、后置同步、旧节点多余则卸载、新节点多余则挂载、乱序走双端交叉加 LIS。
双端比对的核心是四个指针的四种命中组合:
倒序列表是这套逻辑的甜点场景:第一步"新尾 vs 旧尾"直接命中,两个尾指针一起前移,一次循环复用一个节点,几乎零移动。
React 在这种输入下要建 Map、逐个查、逐个判断 lastPlacedIndex。
Vue3 比 Vue2 多的那层是最长递增子序列 。当双端四指针都没命中,剩下的区间建 newIndexToOldIndexMap,用 getSequence 求出哪些节点相对顺序没变,这些节点原地不动,其余才 move:
js
// Vue3 乱序区间处理,节选
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
let j = increasingNewIndexSequence.length - 1;
// 必须倒序遍历,因为 move 的锚点是后一个节点
for (let i = toBePatched - 1; i >= 0; i--) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(child, container, anchor); // 不在 LIS 里,需要移动
} else {
j--; // 在 LIS 里,原地不动
}
}
LIS 保证的是"移动次数最少"这个数学意义上的下界。 React 的 lastPlacedIndex 只是避免向右无谓移动,不做全局最优。
差别有多大?常规列表看不出来,随机乱序 1000 项、每隔几秒整体重排的行情表、拖拽排序看板上,DOM 操作次数的差距会从个位数百分比拉到数倍。
React 为什么不跟:Fiber 要能随时刹车
这是面试的分水岭。如果只会说"React 简单所以快不了",基本就停在第二层了。
React 的选择绑在 Fiber 上。可中断渲染要求 reconciler 能在任意节点暂停、让出主线程、按优先级恢复。
算法本身越复杂,单次工作单元的可预测性越差,中断与恢复的边界就越难划。 双端比对加 LIS 是一段"必须一次算完才有意义"的逻辑,塞进时间切片里会很难办。
另一个原因是运行时开销。LIS 至少 O(n log n),还要额外维护几个数组。React 团队选的是把成本压在框架外层(调度、优先级、并发特性),而不是压在 diff 内部。
Vue 走的是相反的路:把渲染性能压到极致,能省的 DOM 操作一个都不放过。 代价是 patch 函数分支更多、逻辑更绕、单次执行时间更长------但 Vue 不需要在 patch 中间让出主线程。
一句话收束:React 用简单算法换调度自由度,Vue 用复杂算法换 DOM 复用率。 两者都没错,是两种不同的赌注。
| 维度 | React | Vue |
|---|---|---|
| 遍历方向 | 单向,新头对旧头 | 双端交叉,四个指针 |
| 前置同步比对 | 有 | 有 |
| 后置同步比对 | 无 | 有 |
| 乱序复用 | Map 查找加 lastPlacedIndex | Map 查找加最长递增子序列 |
| 移动次数 | 非最优 | LIS 保证最少 |
| 跨层级复用 | 不支持 | 不支持 |
| 设计目标 | 调度可控、逻辑简单 | DOM 操作最少 |
Key 的坑:一个错状态,一个错结构
React 用 index key,节点按位置复用了,但组件 state 绑在原来的 fiber 上。删掉第一项之后,第二项的输入框内容就"继承"了第一项的。这是状态与位置错配。
Vue 用 index key,双端比对第一步"新头 vs 旧头"的 key 永远相等(因为 key 就是 index),于是直接判定可复用、patch 一下就过。真正的错位被推迟到某次长度变化或排序时才浮现,调试时现场已经面目全非。
结论一样:key 必须用业务唯一 ID。但排查思路不一样------React 先看 state,Vue 先看 DOM 结构。 知道这个区别,线上问题能少绕半小时。
别神话 diff
大部分业务页面的列表长度在几十到几百,更新频率在秒级。这种量级下,两套算法的差异被人眼完全抹平。
真正拉开差距的是三类场景:
- 数据量大(千级以上)
- 频繁重排(拖拽、排序、轮播)
- 结构性变更(头部批量插入、整体倒序)
如果你的页面不在这三类里,纠结 diff 性能大概率是在优化一个不存在的瓶颈。花那个时间去看 Fiber 调度或 Vue 的响应式依赖收集,收益高得多。
面试官问这题,想听的从来不是"双端和单向"这六个字。他想确认你知不知道比对的方向、复用的边界、以及框架为什么选中这套策略。
写在最后
你们线上踩过 index key 的坑吗?是 React 那边输入框串位,还是 Vue 这边列表结构错乱?评论区说说你是靠什么线索定位的。
有用的话点个赞。