前端每日知识点:React 与 Vue Diff 算法对比 · 完整总结

一、核心分歧:变更的"发现方式"不同

React 和 Vue 的性能差异,根源并不在于"谁的算法更快",而在于变更到底是怎么被发现的

维度 React Vue
模式 拉(pull) 推(push)
流程 状态变化 → 重新执行 render 生成新树 → 与旧树逐层对比 → 算出差异 状态变化 → 响应式系统已记录"谁依赖我" → 直接定位到要更新的组件/属性 → diff 只负责收尾
是否知道哪里变了 不知道,只能靠比较找出来 知道,依赖追踪直接命中目标

这个根本分歧,决定了两家在优缺点、取舍和工程表现上的所有后续差异。

二、React(Fiber Reconciler)

2.1 做法简述

React 的 diff 有几个关键词:同层比较、深度优先、Fiber 链表

  • 只比较同一层级的兄弟节点,跨层级直接销毁重建。
  • 两条 reconcile 路径(见 react-reconciler/src/ReactChildFiber):
    • 无 key / key 不匹配:按位置(index)配对,一旦类型对不上就跳出循环,剩余节点走新增/删除/移动。这条路径比较粗糙。
    • 有 key :先把旧 fiber 建成 key → fiber 的 Map,单遍遍历新 children 查表复用,找不到就新建,Map 中残留的旧 fiber 全部删除。配合 lastPlacedIndex 跳过部分冗余移动,但不是全局最优。
  • diff 结果打成 side-effect 标签挂在 fiber 树上,交给调度器执行。

本质上是启发式匹配,不是严格的最长公共子序列,也没有最小移动次数优化。

2.2 优点

  • 模型简单一致:规则就三条------类型不同即重建、key 相同即复用、跨层级不比较。心智负担小,行为可预测,出了问题也好定位。
  • 并发能力独一份 :diff 可中断、分片、按优先级调度。Time Slicing、Suspense、useTransitionuse() 都建立在这上面,Vue 目前没有真正对等的机制。
  • 纯函数式、幂等:相同的 state + props 必产出相同的树,便于测试、SSR、流式渲染、跨平台复用(React Native、React Three Fiber 共用同一套 reconcile)。
  • 零编译约束:JSX 就是 JS,动态性不受限,想做高阶抽象、运行时生成结构都可以,不用看编译器脸色。

2.3 缺点

  • 默认更新粒度过粗 :一次 setState 会让整棵子树重新 render,即使内容根本没变。必须手动 memo / useMemo / useCallback,这又带来心智负担和闭包陷阱。这是 React 性能问题的头号来源,而不是 diff 本身慢。
  • 缺少编译期信息:每帧都要重新执行 JSX 调用、重新生成虚拟 DOM 对象,静态节点也逃不过;大表单、长列表开销可观。(React Compiler 正在补这块。)
  • 列表 diff 非最优:面对大规模乱序重排,比如拖拽排序、随机洗牌,启发式匹配会产生多余的删建,而不是最少移动次数。
  • key 用错代价极高:用 index 当 key 会导致组件实例被复用、内部 state 串位,bug 极难排查。

三、Vue(2.x 双端比较 / 3.x Block Tree + LIS)

3.1 做法简述

Vue 的思路是"能省则省":

  • 响应式前置ref/reactive 赋值时就记录依赖,更新直达组件实例,父组件不必重渲染。
  • 模板编译产出静态标记hoistStatic 提升不变节点;PatchFlags 为动态节点打标(TEXT / CLASS / STYLE / PROPS / FULL_PROPS / KEYED_FRAGMENT...),diff 按 flag 走分支,静态节点直接跳过。
  • Block Tree :以 v-if / v-for 为边界切块,块内动态节点打平为一维数组,diff 只遍历这些动态节点,层级深浅不再影响复杂度。
  • 列表:2.x 用头尾四指针双端比较;3.x 在此基础上对乱序段取新索引序列的最长递增子序列(LIS,O(n log n),贪心 + 二分),LIS 内节点不动、其余移动,达到理论最小移动次数。

3.2 优点

  • 编译器大幅削减 diff 工作量:理想情况下只触及若干带 flag 的动态节点,复杂度由"整棵树"降到"动态节点数"。这是 Vue 同等规模下通常更快的主因。
  • 更新粒度天然更细:依赖追踪直击目标组件,无需 re-render 整条链路,也不要求开发者手动 memo。
  • 列表顺序变更是最优解:拖拽、排序、筛选等场景移动次数达理论下限。
  • 分支可预测:模板语法保证结构可被静态分析,编译器与运行时契约稳定。

3.3 缺点

  • 响应式有边界成本:深层嵌套大对象初始化需递归遍历(Vue 3 用 Proxy 懒代理缓解但未根除);解构会丢响应式;Map/Set、频繁增删的大集合仍有坑。
  • 动态性须给编译器让路 :放弃模板改手写 render 函数,PatchFlags 与静态提升基本失效,优化红利消失。JSX 在 Vue 中属"二等公民"。
  • 细粒度追踪有内存开销 :每个响应式对象需维护 dep 集合,超大规模数据(上万行可编辑表格)下可能比 React 重渲染更贵,需 v-memo、浅层响应式或虚拟化降级。
  • 调度与并发能力弱 :无 Fiber 式可中断渲染,长任务会阻塞主线程;3.5+ 有 effectScope、异步组件挂起,但时间切片与优先级抢占和 React 不在一个量级。
  • 编译器 + 运行时强耦合:跨平台故事不如 React 统一,生态位更集中于 Web。

3.4 对照速查

维度 React Vue
变更发现 重新 render 后比较(pull) 响应式直接定位(push)
更新粒度 组件级,需手动 memo 组件级/属性级,自动
编译期优化 无(React Compiler 补中) PatchFlags、静态提升、块树
列表算法 启发式,非最优 双端 + LIS,最小移动
并发/调度 Fiber,可中断、有优先级 无对等能力
开发者负担 低(约定优于配置)
动态性上限 高(JSX 即 JS) 受模板/编译器约束
跨平台 统一(RN 等同构) 相对分散

四、index 作为 key:React 与 Vue 的表现一致

4.1 结论先行

会出问题,但"错乱"表述不准确。真实问题是状态错位(state 串位),而不是视觉顺序乱了:

  • 列表渲染出的视觉顺序永远正确(DOM 顺序跟随数组)。
  • 出错的是:某个 DOM 节点/组件实例背后绑定的内部状态,与其当前应代表的数据对不上号。
  • 表现为勾选框跑到另一行、输入框内容不随数据走、焦点跳到别的逻辑项、动画进出元素错误。

机制在两家完全一致,因为 key 语义相同:key + 标签类型相同 → 复用该实例,仅更新 props。

4.2 React 侧机制(以删除首项为例)

初始列表:

jsx 复制代码
const list = [
  { id: 'a', name: 'Alice' },
  { id: 'b', name: 'Bob' },
  { id: 'c', name: 'Carol' }
];

key={index} 渲染三行。用户勾选了第 2 行(Bob,index=1),该 fiber 内部 done = true

现在删除首项,数组变成:

jsx 复制代码
[{ id: 'b', name: 'Bob' }, { id: 'c', name: 'Carol' }]

React 按位置配对:

  • 新树 index=0 的 key=0,匹配旧树 index=0 的 key=0 → 类型相同、key 相同 → 复用旧 Alice 的 fiber,只把 props 改成 Bob。
  • 新树 index=1 的 key=1,匹配旧树 index=1 的 key=1 → 复用旧 Bob 的 fiber ,它内部 done = true 被原封不动带过来,但 props 已变成 Carol。

结果:Carol 那行莫名处于勾选态,Alice 的勾选历史彻底丢失。

如果 Row 内是未受控的 <input>,你甚至能看到输入框文字没变------DOM 节点被直接复用了。

额外代价:删除首项时 React 认为"所有位置仍是同一个 key,仅 props 变了",于是做 n−1 次 update + 删最后一个,而不是"删第一个",性能退化为 O(n) 更新。

4.3 Vue 侧机制

vue 复制代码
<Row v-for="(item, i) in list" :key="i" :item="item" />

剧本完全一致:v-for 生成的 vnode 以 index 为 key,patch 阶段 sameVNodeType 判定为真 → 组件实例被复用,data() / setup() 不重新执行,实例上的 done 随位置漂移。

4.4 行为差异细节

细节 React Vue
无 key 时 隐式回退 index(dev 报警告) 模板必须写 key;不写走"就地复用",效果等价于 index key
重复 key dev 不报错,静默出错 Vue 2 dev 会警告重复 key,Vue 3 基本静默
纯 DOM 元素 节点复用,未受控 input 值残留 同;静态文本会被 patch 更新,"看起来正常"概率更高,反而掩盖问题
<TransitionGroup> ------ index key 使 enter/leave 动画作用于错误元素,问题暴露最直观

Vue 有时"看起来没坏",因为纯 DOM 节点文本会被重新 patch 成对的,只有带内部状态的子组件或未受控表单控件才会露馅------这恰恰让它更难排查。

4.5 index key 安全的情形(常被忽略)

同时满足"会变序"和"有本地状态"时 bug 必然出现,不是概率问题。以下情形用 index 不会出 bug:

  • 列表永不重排、不插入、不删除(纯静态渲染)。
  • 只在末尾追加(append-only):已存在项索引不变,不会复用错位。(从头部或中间插入/删除必出事。)
  • 列表项是完全受控、无内部状态的纯展示单元:视觉输出全由 props 推导,无 useState/data、无未受控 input、无需保持焦点、无 CSS transition。此时 key 仅承担"辅助 diff 识别"作用,用 index 最多损失性能。
  • 一次性渲染后不再更新(如 SSR 静态快照)。

4.6 比 index key 更糟的写法

jsx 复制代码
<div key={Math.random()} />

或者每次渲染重新生成 UUID:强制每个节点每次都被销毁重建------状态全丢、焦点狂跳、输入框失焦、性能爆炸。index key 至少保留复用机会,随机 key 是主动放弃一切优化。

4.7 正确做法

  • 首选数据自身稳定标识:item.id(后端主键、业务编号)。
  • 数据无唯一字段:取数时一次性派生并随数据一起保存(如 _uid),绝不在渲染时现算。
  • 确实只能用索引的场景:确保列表项是无状态展示组件,交互状态提到父级由数据驱动。
  • 拖拽排序:排序完成后必须用 item.id 重排数组,绝不能靠 DOM 顺序反推 index。
  • react-virtuoso@dnd-kit<TransitionGroup> 等配合时,库通常强制要求稳定 key。

五、为什么 React 不上 LIS:五个层面

5.1 先纠正前提:React 并非"没优化"

React 的 keyed 路径用稳定 key 时,节点身份识别 100% 正确,任意增删重排都不会出现状态串位。它放弃的不是正确性,只是"最少移动次数"。内部 lastPlacedIndex 判断能跳过部分冗余 Placement 标记,属于局部优化而非全局最优。

5.2 架构层面:Fiber 单遍流式模型与 LIS 天然冲突

LIS 的前提:

  1. 完整拿到新旧序列(需随机访问数组);
  2. 做一次全局 O(n log n) 计算;
  3. 再发射变更。

而 Fiber reconciler 是深度优先、单遍、可中断、增量 的:reconcileChildren 接收的 newChildren 可以是任意 iterable(数组、generator、lazy 生成序列),一边消费一边产出 effect,中途可被调度器挂起、让出主线程、之后 resume。

上 LIS 就必须把新 children 整体物化进内存,变成不可中断的两阶段过程,还要额外分配数组与索引映射------这与 Fiber 的核心卖点直接冲突。

对比 Vue:模板编译器提前把 v-for 切成 block,动态节点打平为一维连续数组,长度静态可知、无 generator、无流式子节点。LIS 的全部前置条件在编译期被免费满足。这是 Vue 能做而 React 很难做的结构性原因,不是"没想到"。

5.3 收益层面:真实列表大多落在启发式的最优区间

生产环境列表以尾部追加、尾部删除、首尾交换为主,真正的随机乱序重排是少数:

  • 尾部 push/pop、头部 unshift/shift:Map 方案已是理论最优,LIS 不会更好。
  • 只有"中间插入 + 大规模洗牌"场景 LIS 才有明显优势。

为少数场景付出全局复杂度、内存与调度模型的代价,ROI 不划算。React 宁可让这类场景用户自行上库(@dnd-kit、虚拟列表),也不在核心 reconciler 里塞一个多数人用不到的 O(n log n)。

5.4 瓶颈定位不同:钱该花在别处

| React 的投资方向 | Vue 的投资方向 |
|-------------|---------------------------------------------------|--------------------------------------|
| 主要假设 | 瓶颈是重复执行 render、重建整棵子树 | 瓶颈是 diff 遍历的节点数 |
| 对应手段 | Fiber 并发调度 + memo + React Compiler 自动 memoization | PatchFlags + 静态提升 + Block Tree + LIS |

React 视角下,"少移动几个 DOM 节点"是常数因子级优化;"整棵子树根本不用 re-render"是量级级优化。后者交给 Compiler 解决后,前者就不值得动手术。

而且现代浏览器中对已有 DOM 节点调用 insertBefore 搬位置,代价远小于销毁重建一个带状态的组件实例。React 的 Map 方案已保住实例身份(贵的部分),剩余移动次数属"可接受的不完美"。

5.5 跨平台语义:React 不知"移动"到底贵不贵

Reconciler 需同时服务 ReactDOM、React Native、RSC、Three Fiber 等,宿主对"移动"的代价模型差异巨大:

  • Web DOM 搬节点便宜;
  • React Native 视图重排触发原生布局,某些情况比重建更贵;
  • SSR 根本没有"移动",只有输出顺序。

在平台无关的 core 里做高度依赖宿主代价模型的优化并不干净。Vue 生态位基本锁定浏览器,代价模型单一已知,做这类优化很自然。

5.6 复杂度预算

ReactChildFiber 长期是全仓库最难读的模块之一:分支密集、边界条件极多(fragment 展开、null/boolean/portal、key 重复、iterable、ref 转移......)。团队对这块的复杂度预算早已用尽,加入 LIS + 索引重建的 bug 面与回归风险不小,而受益场景有限。相比之下 Preact 敢上 LCS 近似,是因为 reconciler 仅几百行,改得起。

5.7 代价量化示例

[a,b,c,d,e] → [b,c,d,e,a](首项挪至末尾):

方案 需移动的节点数 实例是否被复用
LIS(Vue 3) 1(a)
React Map + lastPlacedIndex 接近 5(lastPlacedIndex 能省部分,非全局最优)
index 当 key 若干次更新 否 ------ 状态串位,属 bug

关键分层:前两者差别仅是"多做几次 DOM move"(性能问题,通常无感);第三种才是"数据渲染错了"(正确性问题,必现)。所以 React 文档死磕的是"key 必须稳定唯一",而非"我们的 diff 不够优"。

5.8 这不是"能不能",而是"要不要"

  • Preact :体量小,diffChildren 直接上 LCS 近似,确实移动更少。
  • Svelte / Solid:keyed each 块在编译期就生成带身份的更新逻辑,连运行时 diff 都省掉。
  • Vue 3:折中路线------运行时 LIS,但前提是编译器给了干净输入。

三家共同点:都必须先有"子节点是连续数组、长度静态可知"的保证。React 为保留 JSX 完全动态性(children 可为任意表达式、任意 iterable、运行时才决定结构),主动放弃该保证,连带放弃 LIS。这是一致的取舍,不是漏掉的优化。

六、常见误传澄清

  • "React diff 慢":不准确。同层比较本身 O(n),瓶颈在于重复执行 render 函数,不在 reconcile。React Compiler(自动 memoization)后短板明显补齐。
  • "Vue 没有虚拟 DOM":错。Vue 2/3 都有,只是 diff 输入量被压缩了。
  • "Vue 响应式一定更快":仅在中等规模、常规交互下成立。数据量极大时依赖收集开销会反超。
  • "index key 绝对禁忌":不准确。见 4.5,静态列表、append-only、纯受控无状态项下完全无害。
  • key 的规则两家完全一样:只在同层兄弟间唯一,不能跨层级、不能全局复用。

七、实践建议

通用(两家都适用)

首要任务是稳定 keyitem.id,或取数时一次性派生并随数据保存)。这一步完成即获得约 95% 收益:状态不串、实例复用。

真有大规模乱序重排(拖拽看板、实时排序榜单),别指望框架救场:上虚拟列表(react-virtuoso / tanstack/virtual)+ 专用拖拽库(@dnd-kit,自管 stable id 与 transform 动画),此时 reconciler 层面的移动次数差异会被彻底掩盖。

场景大到能感知 diff 差异时,大概率也该上虚拟化了------差距会被虚拟化库掩盖。

React 侧重点

  • 减少不必要整树 re-render 才是真正大头:手动 memo/useMemo,或开启 React Compiler(自动 memoization,Next.js 已默认开启)。这笔账远大于纠结 LIS。
  • 跨端(小程序/RN/Canvas)优先 React,同构生态成熟。

Vue 侧注意

  • 手写 render 函数 / JSX 时 PatchFlags 与静态提升基本失效,LIS 红利仍在但前置优化没了。
  • 超大数据量下 dep 集合开销会反超,需用 v-memo 或浅层响应式降级。
  • 团队希望少写优化代码、默认就快,且结构以模板为主 → Vue 更省心。

选型依据(不要拿 diff 算法当决策依据,它在真实项目里几乎从不是瓶颈)

  • 需要并发渲染、可中断加载、复杂 Suspense 编排 → React 的 Fiber 是硬优势。
  • 希望少写优化代码、默认就快、结构以模板为主 → Vue 编译期优化更省心。
  • 大量拖拽排序、实时重排 → Vue 3 的 LIS 占优,React 侧用稳定 key + 虚拟化也能抹平。
  • 需跨端统一 → React 生态更成熟。

八、一句话总纲

key 的本质是跨渲染周期的身份标识。index 描述的是"位置",位置会变;拿位置当身份,一旦列表增删重排,框架就会把 A 的实例拿去渲染 B,A 的内部状态便串到 B 身上。这套逻辑 React 与 Vue 完全相同,差异仅在于 Vue 的纯 DOM 项更容易"看起来正常"而把问题藏起来。至于 LIS:它是"少搬几次节点"的性能优化,而 key 是"渲染对不对"的正确性问题------前者可以妥协,后者不能。

相关推荐
moMo1 小时前
React 全栈实战:从组件到鉴权的完整指南
react.js
Bs_MoneyMagnet2 小时前
基于springboot+vue的医院陪诊服务预约平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
suliqiang2 小时前
【前端技术】 Web 前端技术36年 演进全景图
信息可视化·微信小程序·小程序·前端框架·uni-app·人机交互·xcode
单线程121383 小时前
从案例分析 Vue3 Tokenizer+Parser 源码五
前端·javascript·vue.js
传奇开心果编程11 小时前
【Xilem 0.4 基础语法学与练】第一课:从零到计数器
学习·rust·前端框架
leoZ23113 小时前
2026-09-09-springboot-cloud-deploy-pitfalls
java·前端·javascript·vue.js·人工智能·spring boot·后端
芭拉拉小魔仙13 小时前
Vue 2 门诊收费系统中的医保结算流程设计与实践
前端·javascript·vue.js
JunjunZ17 小时前
Naive UI 虚拟级联选择器适配 Element Plus 风格
前端·javascript·vue.js