一、核心分歧:变更的"发现方式"不同
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、
useTransition、use()都建立在这上面,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 的前提:
- 完整拿到新旧序列(需随机访问数组);
- 做一次全局 O(n log n) 计算;
- 再发射变更。
而 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 的规则两家完全一样:只在同层兄弟间唯一,不能跨层级、不能全局复用。
七、实践建议
通用(两家都适用)
首要任务是稳定 key (item.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 是"渲染对不对"的正确性问题------前者可以妥协,后者不能。