深度剖析 Vue2、Vue3、React 的 Diff 算法:从原理到实战
三大前端框架的 Diff 算法各有千秋,Vue2 的双端比较、Vue3 的最长递增子序列、React 的 Fiber 协调,本文通过完整代码示例,带你彻底搞懂它们的核心原理与 Map 的关键应用。
前言
在虚拟 DOM 的世界里,Diff 算法是性能的核心。当数据变化时,如何以最小的代价更新真实 DOM?三大框架给出了三种不同的答案:
- Vue2:双端比较算法(Double-Ended Diff)
- Vue3:最长递增子序列算法(LIS)+ 快速路径
- React:Fiber 架构 + 单向遍历比较
本文将通过 手写代码示例,深入剖析三者的实现原理,并重点讲解 Map 数据结构 在 Diff 算法中的关键作用。
核心概念:为什么需要 Diff?
虚拟 DOM 的本质
虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:
// 真实 DOM
<div id="app" class="container">
<p>Hello World</p>
</div>
// 虚拟 DOM
const vnode = {
type: 'div',
props: { id: 'app', class: 'container' },
children: [
{ type: 'p', props: {}, children: 'Hello World' }
]
}
Diff 的目标
当数据变化生成新的虚拟 DOM 树时,需要:
- 找出变化的部分:哪些节点新增、删除、移动、更新
- 最小化操作:用最少的 DOM 操作完成更新
- 高效比较:时间复杂度尽可能低
理想情况下,对比两棵树的差异需要 O(n³) 时间复杂度,但三大框架都做了优化,将其降至 O(n) 或接近 O(n)。
一、Vue2 的双端比较算法
核心思想
Vue2 采用 双端比较(Double-Ended Comparison),同时从新旧子节点数组的两端开始比较,通过四种比较方式减少移动操作。
旧节点列表:[A, B, C, D]
新节点列表:[A, C, B, E]
比较过程:
旧头 ←→ 新头
旧尾 ←→ 新尾
旧头 ←→ 新尾
旧尾 ←→ 新头
四种比较策略
function updateChildren(oldCh, newCh, parentElm) {
let oldStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newStartIdx = 0;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameVnode(oldStartVnode, newStartVnode)) {
// 情况1:旧头 == 新头 → 不移动,继续比较下一对
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// 情况2:旧尾 == 新尾 → 不移动,继续比较前一对
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 情况3:旧头 == 新尾 → 移动到末尾
patchVnode(oldStartVnode, newEndVnode);
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 情况4:旧尾 == 新头 → 移动到开头
patchVnode(oldEndVnode, newStartVnode);
parentElm.insertBefore(oldEndVnode.elm, oldCh[oldStartIdx].elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 情况5:以上都不匹配 → 使用 key 查找
// 创建旧节点 key -> index 的映射
const idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (idxInOld) {
// 找到了:移动到正确位置
const vnodeToMove = oldCh[idxInOld];
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
parentElm.insertBefore(vnodeToMove.elm, oldStartVnode.elm);
} else {
// 没找到:创建新节点
createElm(newStartVnode, parentElm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
// 旧节点处理完了,新节点还有剩余 → 批量新增
const refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
// 新节点处理完了,旧节点还有剩余 → 批量删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
// 判断是否为同一节点
function isSameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
图解双端比较
初始状态:
旧: [A, B, C, D]
↑ ↑
oldStart oldEnd
新: [A, C, B, E]
↑ ↑
newStart newEnd
步骤1: oldStart(A) == newStart(A) → 匹配,不移动
旧: [A, B, C, D]
↑ ↑
旧: [A, C, B, E]
↑ ↑
步骤2: oldStart(B) != newStart(C),检查其他三种情况
oldEnd(D) != newEnd(E)
oldStart(B) != newEnd(E)
oldEnd(D) == newStart(D)? → 不匹配
→ 进入 key 查找
步骤3: C 在旧节点中存在(索引2),移动到 newStart 位置
旧: [A, B, C, D]
↑ ↑
新: [A, C, B, E]
↑ ↑
最终结果:
- A 位置不变
- C 从位置2移动到位置1
- B 从位置1移动到位置2
- D 被删除
- E 被新增
Vue2 Diff 算法的局限
- key 查找效率低:使用
findIdxInOld线性查找,O(n) 复杂度 - 移动次数可能不是最少:双端比较不一定找到最优解
- 无法处理复杂场景:乱序移动时性能下降
二、Vue3 的 Diff 算法:LIS + 快速路径
核心改进
Vue3 在 Diff 算法上做了重大升级:
- 快速路径判断:首尾相同节点快速跳过
- Map 索引优化:使用 Map 存储 key → index 映射,O(1) 查找
- 最长递增子序列:计算最小移动次数
完整实现
function diffChildren(n1, n2, container) {
const oldChildren = n1.children;
const newChildren = n2.children;
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
let oldStartVnode = oldChildren[oldStartIdx];
let oldEndVnode = oldChildren[oldEndIdx];
let newStartVnode = newChildren[newStartIdx];
let newEndVnode = newChildren[newEndIdx];
// ========== 第一阶段:快速路径 ==========
// 从头部开始同步
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode.key === newStartVnode.key) {
patch(oldStartVnode, newStartVnode, container);
oldStartVnode = oldChildren[++oldStartIdx];
newStartVnode = newChildren[++newStartIdx];
} else {
break;
}
}
// 从尾部开始同步
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldEndVnode.key === newEndVnode.key) {
patch(oldEndVnode, newEndVnode, container);
oldEndVnode = oldChildren[--oldEndIdx];
newEndVnode = newChildren[--newEndIdx];
} else {
break;
}
}
// ========== 第二阶段:简单情况处理 ==========
// 新增节点
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
const refElm = newChildren[newEndIdx + 1]?.elm;
while (newStartIdx <= newEndIdx) {
mount(newChildren[newStartIdx++], container, refElm);
}
}
// 删除节点
else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
while (oldStartIdx <= oldEndIdx) {
unmount(oldChildren[oldStartIdx++]);
}
}
// ========== 第三阶段:复杂情况处理 ==========
else {
// 3.1 构建 key -> index 的 Map(核心优化!)
const keyToNewIndexMap = new Map();
for (let i = newStartIdx; i <= newEndIdx; i++) {
keyToNewIndexMap.set(newChildren[i].key, i);
}
// 3.2 遍历旧节点,更新可复用节点,标记待删除节点
const toBePatched = newEndIdx - newStartIdx + 1;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let moved = false;
let maxNewIndexSoFar = 0;
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const oldChild = oldChildren[i];
const newIndex = keyToNewIndexMap.get(oldChild.key);
if (newIndex !== undefined) {
// 找到可复用节点
patch(oldChild, newChildren[newIndex], container);
newIndexToOldIndexMap[newIndex - newStartIdx] = i + 1;
// 检测是否有移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
} else {
// 旧节点在新列表中不存在,删除
unmount(oldChild);
}
}
// 3.3 计算最长递增子序列,确定移动策略
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];
let j = increasingNewIndexSequence.length - 1;
// 3.4 从后向前遍历,移动和新增节点
for (let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = newStartIdx + i;
const nextChild = newChildren[nextIndex];
const refElm = newChildren[nextIndex + 1]?.elm;
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,需要挂载
mount(nextChild, container, refElm);
} else if (moved) {
// 需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, refElm);
} else {
j--;
}
}
}
}
}
最长递增子序列算法
/**
* 最长递增子序列(LIS)
* 返回最长递增子序列的索引数组
* 时间复杂度:O(n log n)
*/
function getSequence(arr) {
const result = [0];
const p = arr.slice(); // 存放前驱索引
const len = arr.length;
for (let i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI === 0) continue;
// 当前元素大于结果数组最后一个元素,直接追加
const lastResultIdx = result[result.length - 1];
if (arr[lastResultIdx] < arrI) {
p[i] = lastResultIdx;
result.push(i);
continue;
}
// 二分查找,找到第一个大于等于 arrI 的位置
let left = 0, right = result.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (arr[result[mid]] < arrI) {
left = mid + 1;
} else {
right = mid;
}
}
if (arrI < arr[result[left]]) {
if (left > 0) {
p[i] = result[left - 1];
}
result[left] = i;
}
}
// 回溯找到最长递增子序列
let resultLen = result.length;
let lastIdx = result[resultLen - 1];
while (resultLen-- > 0) {
result[resultLen] = lastIdx;
lastIdx = p[lastIdx];
}
return result;
}
LIS 图解
旧节点:[A, B, C, D, E]
新节点:[A, C, D, B, E]
经过前面的快速路径处理后:
old: [B, C, D] (索引 1-3)
new: [C, D, B] (索引 1-3)
构建 newIndexToOldIndexMap:
newIndex: 0 1 2
new节点: C D B
old索引+1: 2 3 1
newIndexToOldIndexMap = [2, 3, 1]
计算 LIS:
序列 [2, 3, 1] 的最长递增子序列是 [2, 3]
对应索引 [0, 1]
这意味着 C、D 不需要移动,只需移动 B
最终操作:
- C 位置不变
- D 位置不变
- B 移动到末尾
Vue3 Diff 的优势
| 特性 | Vue2 | Vue3 |
|---|---|---|
| key 查找 | 线性查找 O(n) | Map 查找 O(1) |
| 移动策略 | 双端比较,可能多余移动 | LIS 保证最少移动 |
| 预处理 | 无 | 快速路径跳过首尾相同节点 |
| 时间复杂度 | O(n²) 最坏情况 | O(n log n) |
三、React 的 Diff 算法:Fiber 协调
核心思想
React 采用 Fiber 架构,将 Diff 过程拆分成可中断的小任务:
- 单链表比较:新旧 Fiber 节点通过链表连接,单向遍历
- key + elementType 判断:使用 key 和元素类型判断可复用性
- 优先级调度:高优先级任务可打断低优先级任务
React Diff 的三个层级
┌─────────────────────────────────────────────────────┐
│ Tree Diff │
│ 比较树层级,同层比较,不跨层级移动节点 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Component Diff │
│ 比较组件类型,相同类型复用,不同类型重建 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Element Diff │
│ 比较元素节点,key 相同则复用,否则新建 │
└─────────────────────────────────────────────────────┘
Element Diff 实现
/**
* React Reconciler 中的子节点 Diff
* 简化版实现
*/
function reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChildren,
lanes
) {
// 第一轮遍历:处理更新的节点
let oldFiber = currentFirstChild;
let newIdx = 0;
let resultingFirstChild = null;
let previousNewFiber = null;
// 用于记录被删除的 Fiber
const existingChildren = new Map();
// ========== 第一阶段:顺序比较 ==========
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
// key 或类型不匹配,跳出循环
if (!isSameType(oldFiber, newChild)) {
break;
}
// 复用 Fiber
const newFiber = useFiber(oldFiber, newChild, returnFiber);
newFiber.flags |= Update;
// 构建新 Fiber 链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = oldFiber.sibling;
}
// ========== 第二阶段:处理新增节点 ==========
if (oldFiber === null) {
// 旧节点已处理完,剩余新节点全部新增
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const newFiber = createFiberFromElement(newChild, returnFiber);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// ========== 第三阶段:处理删除和移动 ==========
// 构建 key -> Fiber 的 Map(核心优化!)
let existingChild = oldFiber;
while (existingChild !== null) {
const key = existingChild.key !== null ? existingChild.key : existingChild.index;
existingChildren.set(key, existingChild);
existingChild = existingChild.sibling;
}
// 在 Map 中查找可复用节点
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const key = newChild.key !== null ? newChild.key : newIdx;
const matchedFiber = existingChildren.get(key);
if (matchedFiber) {
// 找到可复用节点
const newFiber = useFiber(matchedFiber, newChild, returnFiber);
newFiber.flags |= Placement; // 标记需要移动
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
// 从 Map 中移除,表示已复用
existingChildren.delete(key);
} else {
// 未找到,创建新节点
const newFiber = createFiberFromElement(newChild, returnFiber);
newFiber.flags |= Placement;
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
// ========== 第四阶段:删除未复用的节点 ==========
existingChildren.forEach((child) => {
child.flags |= Deletion;
});
return resultingFirstChild;
}
// 判断是否为相同类型节点
function isSameType(oldFiber, newElement) {
return (
oldFiber.key === newElement.key &&
oldFiber.elementType === newElement.type
);
}
React Diff 流程图
旧 Fiber 链表:
A -> B -> C -> D -> E
新子节点数组:
[A, D, C, F]
步骤1: 顺序比较
A 匹配 → 复用,继续
B != D → 跳出循环
步骤2: 构建 Map
Map { B -> B Fiber, C -> C Fiber, D -> D Fiber, E -> E Fiber }
步骤3: 继续处理新节点
D 在 Map 中 → 复用,标记 Placement
C 在 Map 中 → 复用,标记 Placement
F 不在 Map 中 → 新建
步骤4: 删除未复用节点
B、E 未被复用 → 标记 Deletion
最终操作:
- A 位置不变
- D 移动
- C 移动
- F 新增
- B、E 删除
React 的局限与优化
局限:
- 无法像 Vue3 的 LIS 那样保证最少移动次数
- 所有复用节点都标记 Placement,即使位置未变
React 团队的考量:
"React 的 Diff 算法不一定是最优的,但它足够快,而且足够简单。"
React 选择简单可预测的策略,而不是复杂的数学优化,这是性能与复杂度的权衡。
四、Map 在 Diff 算法中的核心作用
为什么需要 Map?
在 Diff 过程中,最耗时的操作是 查找旧节点在新列表中的位置。
线性查找(Vue2):
// vue2 中使用的是普通对象,使用旧节点来创建,维护的是 oldKey: oldIndex
// 然后根据新节点的key查找旧节点
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
// 时间复杂度 O(n)
function findIdxInOld(node, oldCh, start, end) {
for (let i = start; i <= end; i++) {
if (oldCh[i].key === node.key) return i;
}
return undefined;
}
Map 查找(Vue3/React):
// 时间复杂度 O(1),vue3中使用新节点创建,同时改用 Map 创建
const keyToIndexMap = new Map();
for (let i = 0; i < newChildren.length; i++) {
keyToIndexMap.set(newChildren[i].key, i);
}
const oldIndex = keyToIndexMap.get(oldNode.key); // O(1)
性能对比
假设有 1000 个子节点:
| 操作 | 线性查找 | Map 查找 |
|---|---|---|
| 查找一次 | O(n) ≈ 500 次比较 | O(1) ≈ 1 次查找 |
| 查找 n 次 | O(n²) ≈ 500,000 次 | O(n) ≈ 1000 次 |
| 内存消耗 | 无额外空间 | O(n) 额外空间 |
结论:Map 用空间换时间,将 O(n²) 降至 O(n),性能提升显著。
Vue3 中的 Map 使用
// Vue3 源码片段
const keyToNewIndexMap = new Map();
// 构建 Map:key -> newIndex,维护的是 newKey: newIndex
// 然后根据旧节点的key查找新节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
const nextChild = newChildren[i];
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
// 使用 Map 快速查找
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const oldChild = oldChildren[i];
const newIndex = keyToNewIndexMap.get(oldChild.key);
// ...
}
React 中的 Map 使用
// react 中使用的是 Map ,同时使用的是旧节点创建map对象。维护的是 oldKey: oldFiber
// 然后根据新节点的key查找旧节点
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber,
): Map<string | number, Fiber> {
const existingChildren: Map<string | number, Fiber> = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
// 构建 Map:key -> oldFiber
let existingChild = currentFirstChild;
while (existingChild !== null) {
const key = existingChild.key;
existingChildren.set(key, existingChild);
existingChild = existingChild.sibling;
}
// 使用 Map 查找可复用节点
const matchedFiber = existingChildren.get(newChild.key);
Map 的最佳实践
// ✅ 正确:为每个子节点设置唯一 key
<ul>
<li key="apple">Apple</li>
<li key="banana">Banana</li>
<li key="orange">Orange</li>
</ul>
// ❌ 错误:使用 index 作为 key
<ul>
<li key={0}>Apple</li> {/* 当列表顺序变化时,key 失去意义 */}
<li key={1}>Banana</li>
<li key={2}>Orange</li>
</ul>
// ❌ 错误:不设置 key(默认使用 index)
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul>
为什么不能用 index 作为 key?
// 场景:在列表开头插入新元素
const oldList = ['B', 'C', 'D'];
const newList = ['A', 'B', 'C', 'D'];
// 使用 index 作为 key:
// oldList: B(key:0), C(key:1), D(key:2)
// newList: A(key:0), B(key:1), C(key:2), D(key:3)
// Diff 结果:
// key:0 → B 变成 A(更新)
// key:1 → C 变成 B(更新)
// key:2 → D 变成 C(更新)
// key:3 → 新增 D
// 总共:3 次更新 + 1 次新增
// 使用唯一 key:
// oldList: B(key:B), C(key:C), D(key:D)
// newList: A(key:A), B(key:B), C(key:C), D(key:D)
// Diff 结果:
// key:A → 新增
// key:B, C, D → 位置移动
// 总共:1 次新增 + 3 次移动(无更新)
五、三大框架 Diff 算法对比总结
核心差异一览表
| 特性 | Vue2 | Vue3 | React |
|---|---|---|---|
| 算法名称 | 双端比较 | LIS + 快速路径 | Fiber 单向遍历 |
| 比较方向 | 四端同时比较 | 首尾快速路径 + Map 遍历 | 单向链表遍历 |
| key 查找 | 线性查找 O(n) | Map O(1) | Map O(1) |
| 移动策略 | 双端匹配移动 | 最长递增子序列 | 全部标记移动 |
| 时间复杂度 | O(n²) 最坏 | O(n log n) | O(n) |
| 可中断 | 否 | 否 | 是(Fiber) |
| 最优移动 | 否 | 是 | 否 |
算法流程对比
┌──────────────────────────────────────────────────────────────────┐
│ Vue2 双端比较 │
├──────────────────────────────────────────────────────────────────┤
│ 旧头 ──→ 新头 匹配? → patch,指针前进 │
│ 旧尾 ──→ 新尾 匹配? → patch,指针后退 │
│ 旧头 ──→ 新尾 匹配? → patch + 移动到末尾 │
│ 旧尾 ──→ 新头 匹配? → patch + 移动到开头 │
│ 以上都不匹配 → 线性查找 key │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Vue3 LIS 算法 │
├──────────────────────────────────────────────────────────────────┤
│ 1. 头部同步:从头匹配相同节点 │
│ 2. 尾部同步:从尾匹配相同节点 │
│ 3. 构建 Map:key → newIndex │
│ 4. 遍历旧节点:patch 可复用节点,删除废弃节点 │
│ 5. 计算 LIS:找出不需要移动的最长序列 │
│ 6. 移动/新增:反向遍历,LIS 外的节点移动/新增 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ React Fiber Diff │
├──────────────────────────────────────────────────────────────────┤
│ 1. 顺序比较:从头遍历,匹配相同 key │
│ 2. 构建 Map:key → oldFiber │
│ 3. 遍历新节点:从 Map 查找可复用 Fiber │
│ 4. 标记操作:Placement(新增/移动)、Deletion(删除) │
│ 5. 批量更新:根据 flags 执行 DOM 操作 │
└──────────────────────────────────────────────────────────────────┘
适用场景分析
Vue2 双端比较:
- 适合简单列表更新场景
- 首尾移动操作较多时效率高
- 乱序复杂场景性能下降
Vue3 LIS 算法:
- 大型列表性能最优
- 复杂移动场景保证最少操作
- 空间换时间,内存占用稍高
React Fiber:
- 支持可中断渲染
- 高优先级任务可打断低优先级
- 算法简单,但移动效率不如 LIS
六、实战 Demo:手写一个简化版 Diff
下面我们实现一个简化版的 Diff 算法,整合 Vue3 的核心思想。
完整代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diff 算法可视化演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
padding: 40px 20px;
color: #fff;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 20px;
font-size: 32px;
background: linear-gradient(90deg, #4fc3f7, #f06292);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
text-align: center;
color: rgba(255,255,255,0.6);
margin-bottom: 40px;
}
.demo-section {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
border: 1px solid rgba(255,255,255,0.1);
}
.demo-title {
font-size: 20px;
margin-bottom: 20px;
color: #4fc3f7;
}
.controls {
display: flex;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-group label {
font-size: 14px;
color: rgba(255,255,255,0.7);
}
.control-group input, .control-group select {
padding: 10px 15px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.1);
color: #fff;
font-size: 14px;
min-width: 200px;
}
.btn {
padding: 12px 24px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #4fc3f7, #2196f3);
color: #fff;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(79, 195, 247, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, #f44336, #e91e63);
color: #fff;
}
.visualization {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
margin-bottom: 30px;
}
.list-container {
background: rgba(0,0,0,0.2);
border-radius: 12px;
padding: 20px;
}
.list-title {
font-size: 16px;
margin-bottom: 15px;
color: rgba(255,255,255,0.8);
}
.list {
display: flex;
flex-wrap: wrap;
gap: 10px;
min-height: 60px;
}
.item {
padding: 12px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
min-width: 60px;
text-align: center;
transition: all 0.3s;
position: relative;
}
.item-old {
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
}
.item-new {
background: linear-gradient(135deg, #4ecdc4, #44a08d);
}
.item-created {
background: linear-gradient(135deg, #a8e063, #56ab2f);
animation: pulse 0.5s ease;
}
.item-deleted {
background: linear-gradient(135deg, #667, #445);
opacity: 0.5;
text-decoration: line-through;
}
.item-moved {
border: 2px solid #f39c12;
animation: shake 0.3s ease;
}
.item-key {
position: absolute;
top: -8px;
right: -8px;
background: #333;
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.operations {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 20px;
margin-top: 20px;
}
.operations-title {
font-size: 16px;
margin-bottom: 15px;
color: #f39c12;
}
.operation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.operation {
padding: 10px 15px;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
}
.op-create { background: rgba(168, 224, 99, 0.2); border-left: 3px solid #a8e063; }
.op-delete { background: rgba(244, 67, 54, 0.2); border-left: 3px solid #f44336; }
.op-move { background: rgba(243, 156, 18, 0.2); border-left: 3px solid #f39c12; }
.op-update { background: rgba(79, 195, 247, 0.2); border-left: 3px solid #4fc3f7; }
.algorithm-comparison {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.algorithm-card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.algorithm-card h3 {
color: #4fc3f7;
margin-bottom: 15px;
font-size: 18px;
}
.algorithm-card .feature {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
font-size: 13px;
}
.algorithm-card .feature:last-child {
border-bottom: none;
}
.feature-label { color: rgba(255,255,255,0.7); }
.feature-value { color: #4fc3f7; font-weight: 600; }
.code-block {
background: rgba(0,0,0,0.4);
border-radius: 8px;
padding: 20px;
margin-top: 20px;
overflow-x: auto;
font-family: 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
}
.code-block pre {
margin: 0;
color: rgba(255,255,255,0.9);
}
.comment { color: #6a9955; }
.keyword { color: #569cd6; }
.function { color: #dcdcaa; }
.string { color: #ce9178; }
.number { color: #b5cea8; }
</style>
</head>
<body>
<div class="container">
<h1>Diff 算法可视化演示</h1>
<p class="subtitle">深入理解 Vue2、Vue3、React 的 Diff 算法原理</p>
<!-- 可视化演示区 -->
<div class="demo-section">
<h2 class="demo-title">交互式 Diff 演示</h2>
<div class="controls">
<div class="control-group">
<label>旧列表(逗号分隔)</label>
<input type="text" id="oldList" value="A,B,C,D,E" placeholder="输入节点,如 A,B,C,D">
</div>
<div class="control-group">
<label>新列表(逗号分隔)</label>
<input type="text" id="newList" value="A,C,B,F,D" placeholder="输入节点,如 A,C,B,D">
</div>
<div class="control-group">
<label>算法选择</label>
<select id="algorithm">
<option value="vue3">Vue3 (LIS + Map)</option>
<option value="vue2">Vue2 (双端比较)</option>
<option value="react">React (Fiber)</option>
</select>
</div>
<div class="control-group" style="justify-content: flex-end;">
<button class="btn btn-primary" onclick="runDiff()">运行 Diff</button>
</div>
</div>
<div class="visualization">
<div class="list-container">
<div class="list-title">旧列表</div>
<div class="list" id="oldListDisplay"></div>
</div>
<div class="list-container">
<div class="list-title">新列表</div>
<div class="list" id="newListDisplay"></div>
</div>
</div>
<div class="operations">
<div class="operations-title">Diff 操作序列</div>
<div class="operation-list" id="operationList">
<div class="operation" style="color: rgba(255,255,255,0.5);">点击"运行 Diff"查看操作序列</div>
</div>
</div>
</div>
<!-- 算法对比卡片 -->
<div class="demo-section">
<h2 class="demo-title">三大框架 Diff 算法对比</h2>
<div class="algorithm-comparison">
<div class="algorithm-card">
<h3>Vue2 双端比较</h3>
<div class="feature">
<span class="feature-label">比较方向</span>
<span class="feature-value">四端同时</span>
</div>
<div class="feature">
<span class="feature-label">Key 查找</span>
<span class="feature-value">O(n) 线性</span>
</div>
<div class="feature">
<span class="feature-label">移动策略</span>
<span class="feature-value">启发式</span>
</div>
<div class="feature">
<span class="feature-label">最优移动</span>
<span class="feature-value">不保证</span>
</div>
<div class="feature">
<span class="feature-label">时间复杂度</span>
<span class="feature-value">O(n²)</span>
</div>
</div>
<div class="algorithm-card">
<h3>Vue3 LIS 算法</h3>
<div class="feature">
<span class="feature-label">比较方向</span>
<span class="feature-value">首尾快速+遍历</span>
</div>
<div class="feature">
<span class="feature-label">Key 查找</span>
<span class="feature-value">O(1) Map</span>
</div>
<div class="feature">
<span class="feature-label">移动策略</span>
<span class="feature-value">最长递增子序列</span>
</div>
<div class="feature">
<span class="feature-label">最优移动</span>
<span class="feature-value">保证</span>
</div>
<div class="feature">
<span class="feature-label">时间复杂度</span>
<span class="feature-value">O(n log n)</span>
</div>
</div>
<div class="algorithm-card">
<h3>React Fiber</h3>
<div class="feature">
<span class="feature-label">比较方向</span>
<span class="feature-value">单向链表</span>
</div>
<div class="feature">
<span class="feature-label">Key 查找</span>
<span class="feature-value">O(1) Map</span>
</div>
<div class="feature">
<span class="feature-label">移动策略</span>
<span class="feature-value">全部标记</span>
</div>
<div class="feature">
<span class="feature-label">最优移动</span>
<span class="feature-value">不保证</span>
</div>
<div class="feature">
<span class="feature-label">时间复杂度</span>
<span class="feature-value">O(n)</span>
</div>
</div>
</div>
</div>
<!-- 核心代码展示 -->
<div class="demo-section">
<h2 class="demo-title">Vue3 Diff 核心代码</h2>
<div class="code-block">
<pre><code><span class="comment">// 1. 构建 key -> index 的 Map(核心优化!)</span>
<span class="keyword">const</span> keyToNewIndexMap = <span class="keyword">new</span> <span class="function">Map</span>();
<span class="keyword">for</span> (<span class="keyword">let</span> i = newStartIdx; i <= newEndIdx; i++) {
keyToNewIndexMap.<span class="function">set</span>(newChildren[i].key, i);
}
<span class="comment">// 2. 遍历旧节点,复用可匹配节点</span>
<span class="keyword">for</span> (<span class="keyword">let</span> i = oldStartIdx; i <= oldEndIdx; i++) {
<span class="keyword">const</span> newIndex = keyToNewIndexMap.<span class="function">get</span>(oldChildren[i].key);
<span class="keyword">if</span> (newIndex !== <span class="keyword">undefined</span>) {
<span class="function">patch</span>(oldChildren[i], newChildren[newIndex]); <span class="comment">// 复用</span>
} <span class="keyword">else</span> {
<span class="function">unmount</span>(oldChildren[i]); <span class="comment">// 删除</span>
}
}
<span class="comment">// 3. 计算最长递增子序列,确定移动策略</span>
<span class="keyword">const</span> lis = <span class="function">getLongestIncreasingSubsequence</span>(newIndexToOldIndexMap);
<span class="comment">// 4. 移动和新增节点</span>
<span class="keyword">for</span> (<span class="keyword">let</span> i = toBePatched - <span class="number">1</span>; i >= <span class="number">0</span>; i--) {
<span class="keyword">if</span> (newIndexToOldIndexMap[i] === <span class="number">0</span>) {
<span class="function">mount</span>(newChildren[i]); <span class="comment">// 新增</span>
} <span class="keyword">else if</span> (!lis.includes(i)) {
<span class="function">move</span>(newChildren[i]); <span class="comment">// 移动</span>
}
}</code></pre>
</div>
</div>
</div>
<script>
// ==================== Diff 算法实现 ====================
/**
* Vue3 风格的 Diff 算法
*/
function diffVue3(oldList, newList) {
const operations = [];
const keyToNewIndex = new Map();
// 构建 key -> index 的 Map
newList.forEach((item, index) => {
keyToNewIndex.set(item, index);
});
// 记录新节点在旧列表中的位置
const newIndexToOldIndex = [];
const usedOldIndices = new Set();
// 遍历旧列表,找到可复用节点
oldList.forEach((item, oldIndex) => {
const newIndex = keyToNewIndex.get(item);
if (newIndex !== undefined) {
newIndexToOldIndex[newIndex] = oldIndex + 1; // +1 避免与 0 混淆
usedOldIndices.add(oldIndex);
} else {
operations.push({ type: 'delete', item, index: oldIndex });
}
});
// 记录未使用的旧节点(需要删除)
oldList.forEach((item, index) => {
if (!usedOldIndices.has(index)) {
// 已在上面处理
}
});
// 计算最长递增子序列
const lis = getLongestIncreasingSubsequence(
newIndexToOldIndex.map((v, i) => v || 0)
);
// 从后向前遍历,处理移动和新增
let lisIndex = lis.length - 1;
for (let i = newList.length - 1; i >= 0; i--) {
if (!newIndexToOldIndex[i]) {
// 新节点
operations.push({ type: 'create', item: newList[i], index: i });
} else if (lis[lisIndex] === i) {
// 在 LIS 中,不需要移动
lisIndex--;
} else {
// 需要移动
operations.push({ type: 'move', item: newList[i], from: newIndexToOldIndex[i] - 1, to: i });
}
}
return operations.reverse();
}
/**
* Vue2 风格的双端比较
*/
function diffVue2(oldList, newList) {
const operations = [];
const oldCh = [...oldList];
const newCh = [...newList];
let oldStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newStartIdx = 0;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode === newStartVnode) {
operations.push({ type: 'update', item: oldStartVnode, index: newStartIdx, match: 'head-head' });
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (oldEndVnode === newEndVnode) {
operations.push({ type: 'update', item: oldEndVnode, index: newEndIdx, match: 'tail-tail' });
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (oldStartVnode === newEndVnode) {
operations.push({ type: 'move', item: oldStartVnode, from: oldStartIdx, to: newEndIdx, match: 'head-tail' });
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (oldEndVnode === newStartVnode) {
operations.push({ type: 'move', item: oldEndVnode, from: oldEndIdx, to: newStartIdx, match: 'tail-head' });
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 使用 Map 查找
const idxInOld = oldCh.findIndex((item, idx) =>
idx >= oldStartIdx && idx <= oldEndIdx && item === newStartVnode
);
if (idxInOld !== -1) {
operations.push({ type: 'move', item: newStartVnode, from: idxInOld, to: newStartIdx, match: 'key-lookup' });
oldCh[idxInOld] = undefined;
} else {
operations.push({ type: 'create', item: newStartVnode, index: newStartIdx });
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx > oldEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
operations.push({ type: 'create', item: newCh[i], index: i });
}
} else if (newStartIdx > newEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
operations.push({ type: 'delete', item: oldCh[i], index: i });
}
}
}
return operations;
}
/**
* React 风格的 Fiber Diff
*/
function diffReact(oldList, newList) {
const operations = [];
const existingChildren = new Map();
// 构建 key -> index 的 Map
oldList.forEach((item, index) => {
existingChildren.set(item, index);
});
// 第一轮:顺序比较
let i = 0;
for (; i < oldList.length && i < newList.length; i++) {
if (oldList[i] === newList[i]) {
operations.push({ type: 'update', item: newList[i], index: i });
existingChildren.delete(newList[i]);
} else {
break;
}
}
// 第二轮:处理剩余节点
for (; i < newList.length; i++) {
const item = newList[i];
if (existingChildren.has(item)) {
operations.push({ type: 'move', item, from: existingChildren.get(item), to: i });
existingChildren.delete(item);
} else {
operations.push({ type: 'create', item, index: i });
}
}
// 删除未复用的节点
existingChildren.forEach((index, item) => {
operations.push({ type: 'delete', item, index });
});
return operations;
}
/**
* 最长递增子序列算法
*/
function getLongestIncreasingSubsequence(arr) {
if (arr.length === 0) return [];
const result = [0];
const p = arr.slice();
for (let i = 1; i < arr.length; i++) {
const arrI = arr[i];
if (arrI === 0) continue;
const lastIdx = result[result.length - 1];
if (arr[lastIdx] < arrI) {
p[i] = lastIdx;
result.push(i);
continue;
}
let left = 0, right = result.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (arr[result[mid]] < arrI) {
left = mid + 1;
} else {
right = mid;
}
}
if (arrI < arr[result[left]]) {
if (left > 0) p[i] = result[left - 1];
result[left] = i;
}
}
let len = result.length;
let idx = result[len - 1];
while (len-- > 0) {
result[len] = idx;
idx = p[idx];
}
return result;
}
// ==================== UI 交互 ====================
function renderList(containerId, items, type = 'old') {
const container = document.getElementById(containerId);
container.innerHTML = '';
items.forEach((item, index) => {
const div = document.createElement('div');
div.className = `item item-${type}`;
div.innerHTML = `${item}<span class="item-key">key:${item}</span>`;
container.appendChild(div);
});
}
function renderOperations(operations) {
const container = document.getElementById('operationList');
container.innerHTML = '';
if (operations.length === 0) {
container.innerHTML = '<div class="operation" style="color: rgba(255,255,255,0.5);">无操作</div>';
return;
}
operations.forEach(op => {
const div = document.createElement('div');
div.className = `operation op-${op.type}`;
let text = '';
switch (op.type) {
case 'create':
text = `CREATE: 新增节点 "${op.item}" 在位置 ${op.index}`;
break;
case 'delete':
text = `DELETE: 删除节点 "${op.item}" 在位置 ${op.index}`;
break;
case 'move':
text = `MOVE: 移动节点 "${op.item}" 从 ${op.from} 到 ${op.to}${op.match ? ` (${op.match})` : ''}`;
break;
case 'update':
text = `UPDATE: 更新节点 "${op.item}" 在位置 ${op.index}${op.match ? ` (${op.match})` : ''}`;
break;
}
div.textContent = text;
container.appendChild(div);
});
}
function runDiff() {
const oldListStr = document.getElementById('oldList').value;
const newListStr = document.getElementById('newList').value;
const algorithm = document.getElementById('algorithm').value;
const oldList = oldListStr.split(',').map(s => s.trim()).filter(Boolean);
const newList = newListStr.split(',').map(s => s.trim()).filter(Boolean);
// 渲染列表
renderList('oldListDisplay', oldList, 'old');
renderList('newListDisplay', newList, 'new');
// 执行 Diff
let operations;
switch (algorithm) {
case 'vue2':
operations = diffVue2(oldList, newList);
break;
case 'vue3':
operations = diffVue3(oldList, newList);
break;
case 'react':
operations = diffReact(oldList, newList);
break;
}
renderOperations(operations);
}
// 初始化
runDiff();
</script>
</body>
</html>
七、总结
核心要点回顾
-
Diff 算法的本质:最小化 DOM 操作,将 O(n³) 的树对比降至 O(n) 或 O(n log n)
-
Map 的关键作用:将 key 查找从 O(n) 降至 O(1),是性能优化的核心手段
-
Vue2 双端比较:四端同时比较,适合简单移动场景,复杂场景性能下降
-
Vue3 LIS 算法:首尾快速路径 + Map 查找 + 最长递增子序列,保证最少移动次数
-
React Fiber:单向链表遍历 + Map 查找,支持可中断渲染,但移动不一定最优
面试高频问题
Q1:为什么不能用 index 作为 key?
使用 index 作为 key 会导致:
- 列表顺序变化时,key 失去唯一性标识作用
- 所有节点都被认为是"更新"而非"移动"
- 性能浪费,可能导致状态错乱
Q2:Vue3 的 Diff 比 Vue2 快多少?
在极端乱序场景下,Vue3 比 Vue2 快约 100%(性能翻倍),主要得益于:
- Map 查找替代线性查找
- LIS 减少不必要的移动
- 快速路径跳过首尾相同节点
Q3:React 为什么不采用 LIS 算法?
React 团队的考量:
- LIS 算法增加复杂度,边际收益递减
- Fiber 架构的设计目标是可中断性,而非最优移动
- 简单策略更容易维护和优化
Q4:Map 会占用多少内存?
假设有 1000 个子节点:
- 每个 key(假设字符串 10 字节)+ index(数字 8 字节)≈ 18 字节
- Map 总内存 ≈ 1000 × 18 = 18KB
用 18KB 内存换取 O(n²) → O(n) 的性能提升,是非常划算的。
进一步学习
如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!