详解 Vue 的 Diff 算法
原文重点讲解 Vue 2 的
patch、sameVnode、patchVnode和updateChildren双端 Diff。本文保留原来的源码阅读路线和四种头尾比较场景,修复“从真实 DOM 生成 VNode”“参数顺序”“idxInOld为 0 时判断错误”等问题,并补充 Vue 3 的 keyed children、patch flags、Block Tree 和 LIS。
一、数据变化后,Vue 如何更新节点
渲染真实 DOM 的成本包括节点创建、属性更新、布局、样式计算和绘制。Vue 并不会因为某个响应式数据变化就把整棵 DOM 树无条件重建,而是大致经过下面的流程:
响应式数据变化
↓
组件的渲染 watcher/effect 被调度
↓
重新执行 render,得到 newVNode
↓
newVNode 与旧的 oldVNode 比较
↓
patch/renderer 提交必要的 DOM 操作
需要修正原文中的一个容易误解的说法:Vue 通常不是“先根据真实 DOM 生成一颗 virtual DOM,再修改它”。首次渲染是由模板编译出的 render function 或手写 render function 生成 vnode;后续更新会保留旧 vnode,并生成新 vnode 进行比较。
Diff 是 renderer 的更新过程,结果最终仍然要作用到真实 DOM。它不是把真实 DOM 操作变成零成本,也不保证任何场景都只产生最少的 DOM 操作。
二、Virtual DOM 和真实 DOM 的区别
真实 DOM 是浏览器提供的节点对象:
<div>
<p>123</p>
</div>
简化的 vnode 可以表示为:
const vnode = {
tag: 'div',
data: {},
children: [
{
tag: 'p',
data: {},
children: [],
text: '123',
},
],
}
这是教学模型。Vue 2 的 VNode 还可能包含 elm、key、componentOptions、componentInstance、isComment 等字段;Vue 3 的 vnode 字段则包括 type、props、children、shapeFlag、patchFlag 和 dynamicChildren 等。不同版本的 vnode 不能直接混用。

VNode 的主要价值是:
- 用 JavaScript 数据结构表达应该渲染的 UI;
- 允许框架统一处理元素、组件、Fragment、文本和注释节点;
- 可以在比较后只提交必要变化;
- 配合不同 renderer 映射到 DOM、SSR 字符串或其他平台。
“虚拟 DOM”并不是浏览器 DOM 的完整复制品,也不是 Vue 固定不变的公开数据结构。
三、Diff 的比较范围
在常见的树 Diff 中,比较主要发生在同一层级:
<!-- 旧树 -->
<div>
<p>123</p>
</div>
<!-- 新树 -->
<div>
<span>456</span>
</div>
renderer 会比较同一父节点下的 p 和 span,不会为了寻找理论上的全局最优解,把旧树的 div 与新树的 span 做任意跨层级匹配。跨层级移动通常会被当作删除旧节点并创建/插入新节点。

这是一种工程上的启发式选择:通用树编辑的最优匹配成本很高,而 UI 更新通常更关注同层级的节点复用、列表移动和组件身份。
四、Vue 2 的 patch
Vue 2 的响应式系统中,数据变化会通知依赖它的 watcher;组件渲染 watcher 重新执行 render,最后调用 patch 更新视图。可以把流程简化为:
Dep.notify()
↓
渲染 Watcher 更新
↓
生成 newVNode
↓
patch(oldVNode, newVNode)

4.1 patch 的核心思想
下面是删减后的 Vue 2 风格伪代码。真实实现还要处理初次挂载、组件、注释、异步组件、hydration、transition 和模块钩子:
function patch(oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oldElm = oldVnode.elm
const parentElm = api.parentNode(oldElm)
createElm(vnode)
if (parentElm !== null) {
api.insertBefore(parentElm, vnode.elm, api.nextSibling(oldElm))
removeVnodes(parentElm, [oldVnode], 0, 0)
}
}
return vnode
}
这里的两个参数都表示 vnode。首次挂载时,Vue 2 会把真实 DOM 元素转换为一个旧节点或走初始挂载分支;不能把上面的简化代码当作完整 API。
如果新旧节点不值得比较,直接替换旧节点;如果值得比较,则复用旧节点对应的真实元素,继续比较属性、文本和 children。
4.2 sameVnode
Vue 2 的 sameVnode 不是只比较标签名,至少要关注 key、标签、注释状态、data 是否存在和 input 类型:
function sameVnode(a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
}
function sameInputType(a, b) {
if (a.tag !== 'input') return true
const typeA = a.data && a.data.attrs && a.data.attrs.type
const typeB = b.data && b.data.attrs && b.data.attrs.type
return typeA === typeB
}
真实源码还要考虑异步占位符等特殊分支。key 是节点身份的一部分:同一父节点下,key 和类型匹配时才有机会复用组件实例和 DOM 节点。

4.3 patchVnode
当 sameVnode(oldVnode, vnode) 成立时,Vue 2 会复用旧节点的真实元素:
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm
if (oldVnode === vnode) return
if (
oldVnode.text != null &&
vnode.text != null &&
oldVnode.text !== vnode.text
) {
api.setTextContent(elm, vnode.text)
return
}
updateAttrsClassStyleEvents(elm, oldVnode, vnode)
const oldCh = oldVnode.children || []
const ch = vnode.children || []
if (oldCh.length && ch.length) {
updateChildren(elm, oldCh, ch)
} else if (ch.length) {
addVnodes(elm, null, ch, 0, ch.length - 1)
} else if (oldCh.length) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text || '')
}
}
它主要完成:
- 复用旧 vnode 对应的真实元素;
- 文本节点变化时更新文本;
- 更新 attrs、class、style、事件和指令等模块;
- 旧子节点和新子节点都存在时,调用
updateChildren; - 只有新子节点时创建并插入;
- 只有旧子节点时移除;
- 文本和子节点之间发生类型变化时,清理旧内容并设置新内容。

五、Vue 2 updateChildren:双端 Diff
Vue 2 的核心列表比较会同时从旧、新 children 的两端开始:
oldStart ↔ oldEnd
newStart ↔ newEnd
每轮优先尝试以下四种情况:
oldStartVnode与newStartVnode相同:头部复用;oldEndVnode与newEndVnode相同:尾部复用;oldStartVnode与newEndVnode相同:旧头节点移动到尾部;oldEndVnode与newStartVnode相同:旧尾节点移动到头部。

5.1 核心伪代码
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let oldStartVnode = oldCh[oldStartIdx]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = newCh[newStartIdx]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(
parentElm,
oldStartVnode.elm,
api.nextSibling(oldEndVnode.elm),
)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(
oldCh,
oldStartIdx,
oldEndIdx,
)
}
const idxInOld = oldKeyToIdx[newStartVnode.key]
// 关键修正:索引 0 是有效位置,不能写成 if (!idxInOld)。
if (idxInOld == null) {
api.insertBefore(
parentElm,
createElm(newStartVnode),
oldStartVnode.elm,
)
} else {
const vnodeToMove = oldCh[idxInOld]
patchVnode(vnodeToMove, newStartVnode)
oldCh[idxInOld] = undefined
api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
const before = newCh[newEndIdx + 1]
? newCh[newEndIdx + 1].elm
: null
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
这里的 api、createElm、addVnodes 和 removeVnodes 代表 Vue 2 内部的 DOM 操作和辅助函数。代码用于理解算法,真实源码还会调用模块钩子并处理组件节点。
原文中写成 if (!idxInOld) 是一个常见的讲解代码错误:当匹配节点正好位于旧数组下标 0 时,会被错误地当成“没有找到”。正确判断应是 idxInOld == null 或 idxInOld === undefined。
5.2 四种情况的示例
假设旧列表和新列表都使用自身作为 key:
oldCh = [a, b, d]
newCh = [a, c, d, b]
第一轮:
oldStart = a, oldEnd = d
newStart = a, newEnd = b
旧头和新头匹配,复用 a。
第二轮:
oldStart = b, oldEnd = d
newStart = c, newEnd = b
旧头 b 与新尾 b 匹配,把真实 DOM 中的 b 移到尾部。
第三轮可能匹配旧尾、新尾的 d,最后剩余的 c 根据新列表位置插入:
a, c, d, b

5.3 循环结束后的两种情况
当某一侧指针越过边界时:
oldStartIdx > oldEndIdx:旧节点先遍历完,新列表还有节点,需要插入剩余的新节点;newStartIdx > newEndIdx:新节点先遍历完,旧列表剩余节点需要删除。

六、key 的作用和使用建议
key 用来表达同一父节点下子节点的身份。它影响:
- DOM 节点是否复用或移动;
- 子组件实例是否复用;
- 输入框焦点和本地状态是否跟随正确的列表项;
- transition、KeepAlive 等功能的节点识别。
<li v-for="item in items" :key="item.id">
<input v-model="item.text" />
</li>
没有 key 时,Vue 可能按照位置复用节点。纯文本、只追加、不会排序删除且没有内部状态的简单列表,按位置更新通常足够;可插入、删除、排序或包含表单状态的列表,应使用稳定且唯一的业务 id。
不要把“使用 key”理解成“节点永远不会更新”:key 相同只表示有机会匹配到同一节点,节点的 props、文本和子树仍然会继续 patch。
七、Vue 3 的 Diff 有哪些不同
Vue 3 不只是把 Vue 2 的函数改名:
patchKeyedChildren处理有 key 的子节点;patchUnkeyedChildren处理无 key 的子节点;- 头尾同步后建立 key 到新索引的映射;
- 对需要移动的节点使用最长递增子序列(LIS)减少移动;
- 模板编译器通过 patch flags、静态提升和 Block Tree 提供动态节点信息;
- 组件渲染 effect 和 scheduler 共同决定更新时机。
概念上的 Vue 3 keyed children 流程是:
同步相同的头部
↓
同步相同的尾部
↓
处理一侧剩余的新增/删除
↓
为中间区间建立 key 映射
↓
patch 可复用节点,创建新节点
↓
按 LIS 结果尽量少移动真实节点
LIS 优化减少的是移动操作,并不意味着任何列表更新都只产生一次 DOM 操作。编译器优化、响应式依赖范围和列表 key 共同影响最终性能。
八、常见误解
误解 1:Diff 会比较所有节点的全局最优结果
通常不会。Vue 使用针对 UI 场景的启发式同层比较和 keyed children 策略。
误解 2:Diff 可以避免所有重排和重绘
不能。真实 DOM 的插入、删除、移动、样式变化仍可能触发布局和绘制;Diff 只是减少不必要的更新。
误解 3:没有 key 就一定不能复用节点
没有 key 时仍可能按位置复用;只是无法可靠表达业务项身份,在重排和有状态列表中容易发生错位。
误解 4:Vue 3 仍然完全照搬 Vue 2 双端 Diff
Vue 3 保留了一些头尾比较思想,但增加了 keyed/unkeyed 分支、编译器提供的动态信息和移动优化,源码结构与 Vue 2 不同。
总结
- Vue 更新大致是响应式变化、重新 render、生成新 vnode、patch 到真实 DOM;
- Diff 主要进行同层级比较,不会为任意跨层级节点寻找全局最优匹配;
- Vue 2 的
patch先判断sameVnode,再由patchVnode更新文本、属性和 children; updateChildren通过四个指针进行双端比较,未命中时借助 key 映射查找可移动节点;idxInOld为 0 时仍然是有效索引,不能使用if (!idxInOld);- 稳定 key 表达节点身份,影响 DOM、组件实例和表单状态的复用;
- Vue 3 结合 keyed/unkeyed children、patch flags、Block Tree 和 LIS 优化更新;
- VDOM/Diff 没有天然的绝对性能保证,应该结合真实场景测量。
参考资料
原文作者:windlany。原文关于 patch、sameVnode、patchVnode、updateChildren、四种头尾匹配、key 表和新增/删除节点的主线予以保留;错误的 VNode 来源表述、源码拼接噪声、idxInOld 下标 0 判断、Vue 2/3 混淆及绝对性能结论已修正或补充说明。