React 为何不做双端 diff?Vue 与 React 更新逻辑的三层差异

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 判定,结果是大量节点被判定为需要移动。

flowchart TD A[新旧列表从头同步比对] --> B{key 相同} B -->|是| C[复用 fiber 双指针后移] C --> A B -->|否| D[剩余旧节点放入 Map] D --> E[遍历剩余新节点按 key 认领] E --> F{Map 中命中} F -->|是| G[比较 oldIndex 与 lastPlacedIndex 决定是否移动] F -->|否| H[创建新 fiber 插入] G --> I[Map 中剩余旧节点全部删除] H --> I

Vue:四个指针交叉试探,乱序再上 LIS

Vue 的 patchKeyedChildren 分五步:前置同步、后置同步、旧节点多余则卸载、新节点多余则挂载、乱序走双端交叉加 LIS。

双端比对的核心是四个指针的四种命中组合:

flowchart TD S[进入循环:新头未越新尾 且 旧头未越旧尾] --> Q1{新头 vs 旧头} Q1 -->|相同| R1[复用:新头+1 旧头+1] Q1 -->|不同| Q2{新尾 vs 旧尾} Q2 -->|相同| R2[复用:新尾-1 旧尾-1] Q2 -->|不同| Q3{新尾 vs 旧头} Q3 -->|相同| R3[旧头移到旧尾之后:旧头+1 新尾-1] Q3 -->|不同| Q4{新头 vs 旧尾} Q4 -->|相同| R4[旧尾移到旧头之前:旧尾-1 新头+1] Q4 -->|不同| R5[按 key 查旧列表:命中则移动 未命中则新建] R1 --> S R2 --> S R3 --> S R4 --> S R5 --> S

倒序列表是这套逻辑的甜点场景:第一步"新尾 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 这边列表结构错乱?评论区说说你是靠什么线索定位的。

有用的话点个赞。

相关推荐
程序员Sunday7 小时前
Promise.all、allSettled、race、any 怎么选,失败后其他请求会怎样
开发语言·前端·javascript
niucloud-admin7 小时前
JAVA V6 多商户商城 开发文档——管理端前端
前端
weixin_422201307 小时前
如何解决小程序图标点击热区小,落点不准问题?
前端·小程序·样式·点击热区·扩大
程序员清风7 小时前
AI时代建议学点自己真正感兴趣的!
java·后端·面试
szial7 小时前
JavaScript 的 call、apply 和 bind:如何控制函数调用
开发语言·前端·javascript
CopyCode8 小时前
Agent 开发不是模型训练:一个前端的入门认知
前端·llm·agent
(Charon)8 小时前
【C++面试】线程同步机制:互斥锁、条件变量、原子操作与读写锁
开发语言·c++·算法·面试
吠品8 小时前
HTTPS 真身:HTTP 套了层 TLS
java·服务器·前端
JYeontu8 小时前
实现一个「曲线滑块验证」功能
前端·javascript·html
Csvn8 小时前
组件库(从 API 契约到 Headless 内核)
前端