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 这边列表结构错乱?评论区说说你是靠什么线索定位的。

有用的话点个赞。

相关推荐
flash俊杰1 小时前
AI 分段引擎与自动剪辑决策:从语义片段到时间线草稿的映射
前端·ai编程
flash俊杰1 小时前
时间线交互引擎:从像素到帧的逆向映射与磁吸网格
前端
光影少年1 小时前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
flash俊杰1 小时前
LLM 结构化输出的契约化工程:JSON Mode、Schema 约束与重试降级
前端·ai编程
༄久梦༒长醉༻1 小时前
AI面试助手:本地运行,一键备战
前端·ai编程
Web4Browser1 小时前
浏览器指纹逆向到底在分析什么:从 JavaScript 采集、Canvas 与 WebGL 到环境一致性校验
java·服务器·前端
2601_958352901 小时前
不改PCB也能调拾音方向?真相在这。
前端·嵌入式硬件·回声消除·语音模块·降噪处理
zhanghaha13142 小时前
HTML系列教程:17_HTML 框架(frameset、frame、iframe)零基础详解
前端·html
keke.shengfengpolang2 小时前
2026年资金分析岗校招技术栈拆解:Excel预测模型、SQL取数、SAP与Oracle资金模块,附2027届备考路线
面试