Vue 系列之:Vue2 双端 Diff 算法原理
了解暴力 Diff 算法
两棵使用暴力 Diff 算法,复杂度是 O(n²):
-
第一棵树的每个节点(n 个),都要和第二棵树的每个节点(n 个)比较,共 n * n 次操作(O (n²))。
-
整体对比完成后,可能会涉及插入、删除、修改,操作次数最多为 O(n)(因为最多修改 n 个节点)。
-
因此,执行所有操作的总复杂度最多是 O(n²)(对比) + O(n)(操作) ≈ O(n²)。
那么 Vue2 的双端 Diff 算法做了哪些优化呢?
核心原理
Vue 通过虚拟 DOM(VNode)作为中间层,将多次数据变化合并后,通过 Diff 算法计算出最小的 DOM 操作量,再批量更新真实 DOM。核心优化就是减少操作 “数量”(避免频繁操作)和 “范围”(只更新变化的部分)
同一层级比较:Vue 的 Diff 算法只在同一父节点的直接子节点之间进行比对,不会跨层级比较(例如不会拿父节点的子节点和祖父节点的子节点比较)。这是因为 DOM 结构中跨层级的移动非常少见,忽略跨层级比较可以大幅降低复杂度。
深度优先递归遍历:Vue 会从根组件开始,先处理当前组件的虚拟 DOM,再递归处理它的子组件,子组件处理完后再回到父组件处理下一个子组件,直到整个组件树遍历完成。每遍历到一个节点,就只对它的直接子节点数组执行 Diff(而不是整个树)。
复杂度:整体复杂度为 O (组件树深度 × n),其中 n 是当前层级的子节点数量。因为每个层级的 Diff 是独立的(只处理当前层级的子节点),所以整体复杂度远低于全量 Diff 的 O (n²),这一点描述正确。
Vue2 Diff 全流程
Vue Diff 包含 patch、isSameVnode、patchVnode、updateChildren 四个关键函数。它们之间的关系如下:
patch
作用:
- 作为 Diff 算法的入口,对比新旧 VNode 并根据结果操作真实 DOM(创建、删除或更新)。
核心逻辑:
-
如果旧 VNode 不存在:直接根据新 VNode 创建真实 DOM 并插入到父节点。
-
如果新 VNode 不存在:移除旧 VNode 对应的真实 DOM。
-
如果新旧 VNode 是同一节点(通过 isSameVnode 判断):调用 patchVnode 进行详细对比和更新。
-
如果不是同一节点:删除旧 VNode 对应的 DOM,创建新 VNode 对应的 DOM 并插入。
流程简化:
function patch(oldVnode, newVnode) {if (!oldVnode) {// 旧节点不存在,直接创建新节点createElm(newVnode);} else if (!newVnode) {// 新节点不存在,移除旧节点removeElm(oldVnode.elm);} else if (isSameVnode(oldVnode, newVnode)) {// 同一节点,深入对比更新patchVnode(oldVnode, newVnode);} else {// 不同节点,替换const parentElm = oldVnode.elm.parentNode;removeElm(oldVnode.elm);createElm(newVnode, parentElm);}
}
isSameVnode
作用:
-
决定两个虚拟节点(VNode)是否代表同一个 DOM 节点,是 Diff 算法的前提。
-
只有被判定为 “同一节点”,才会进行后续的属性对比和更新
判断逻辑:
-
两个 VNode 的 key 必须相同(key 是节点的唯一标识,常用于列表渲染)。
-
两个 VNode 的 tag(标签名)必须相同。
-
对于组件节点,还需判断 isComment(是否为注释节点)、data(属性数据)等辅助条件。
代码简化示例:
function isSameVnode(a, b) {return a.key === b.key && a.tag === b.tag;
}
patchVnode
作用:
- 当两个 VNode 被判定为同一节点后,patchVnode 负责对比它们的细节(属性、文本、子节点等)并更新真实 DOM。
核心逻辑:
-
处理文本节点:如果新 VNode 是文本节点,直接更新真实 DOM 的文本内容。
-
处理属性更新:新 VNode不是文本节点,对比新旧 VNode 的 data,更新 DOM 属性。
-
处理子节点:
-
如果新旧 VNode 都有子节点,调用 updateChildren 对比并更新子节点;
-
如果只有新 VNode 有子节点,直接创建并插入;
-
如果只有旧 VNode 有子节点,直接删除。
-
流程简化:
function patchVnode(oldVnode, newVnode) {const elm = newVnode.elm = oldVnode.elm; // 复用真实 DOM 节点const oldCh = oldVnode.children;const newCh = newVnode.children;// 如果是文本节点,直接更新文本if (newVnode.text) {elm.textContent = newVnode.text;} else {// 更新属性(如 class、style、事件等)updateAttrs(oldVnode.data, newVnode.data, elm);// 处理子节点if (oldCh && newCh) {// 新旧都有子节点,调用 updateChildren 对比updateChildren(elm, oldCh, newCh);} else if (newCh) {// 只有新节点有子节点,创建并插入createChildren(elm, newCh);} else if (oldCh) {// 只有旧节点有子节点,删除removeChildren(elm, oldCh);}}
}
isSameVnode 与 patchVnode 的区别
isSameVnode:负责「判断是否为同一节点」(决策阶段),是后续操作的前提。它只做简单的表层判断(key、tag 等),不涉及具体更新逻辑。
patchVnode:负责「同一节点的详细更新」(执行阶段),当 isSameVnode 判定为同一节点后,才会进入这个函数,处理文本、属性、子节点等细节的更新。
举个生活例子:
-
isSameVnode 像保安检查身份证(快速确认「是不是同一个人」);
-
patchVnode 像这个人进入房间后,更新他的衣物、物品等细节(「同一个人,但状态可能变化」)。
updateChildren(核心)
需知
updateChildren 函数是 Vue 双端 Diff 算法的核心内容。
这部分内容比较多,需要耐心看。
patch、isSameVnode、pathVnode 他们的参数都是单个节点:
-
patch(oldVnode, newVnode)
-
isSameVnode(oldVnode, newVnode)
-
patchVnode(oldVnode, newVnode)
updateChildren 的参数是一组子节点,是同一父节点下的子节点数组:
- updateChildren(el, oldChildren, newChildren)
其核心思想是:同时从新旧子节点数组的头尾两端开始比对,逐步向中间收拢
目的是:优先处理常见的节点内容不变、位置变化的场景。
理解四个指针
| oldStartIndex | oldEndIndex |
|---|---|
| 指向未处理的第一个旧节点 | 指向未处理的最后一个旧节点 |
| newStartIndex | newEndIndex |
|---|---|
| 指向未处理的第一个新节点 | 指向未处理的最后一个旧节点 |
所有比对在 oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex 的循环条件下进行(即还有未处理的节点时)。原因后面再说。
常见场景
循环中会依次尝试以下四种比对,若匹配则直接处理,避免全量遍历:
1. 头头比较
-
比较 oldStartVnode 和 newStartVnode。
-
如果相同(key 和标签一致)
-
复用节点(patchVnode 更新内容)
-
两者头指针都向右移动一位(oldStartIndex++、newStartIndex++)
-
-
目的: 高效处理列表开头节点未变的情况。

2. 尾尾比较
-
比较 oldEndVnode 和 newEndVnode。
-
如果相同
-
复用节点(patchVnode 更新内容)
-
两者尾指针都向左移动一位(oldEndIndex–、newEndIndex–)
-
-
目的: 高效处理列表末尾节点未变的情况。

3. 旧头新尾比较
-
比较 oldStartVnode 和 newEndVnode。
-
如果相同,说明这个旧头节点被移到了最后
-
复用节点(patchVnode 更新内容)
-
将旧头节点对应的 DOM 移动到旧尾节点的 DOM 后面
-
旧头指针右移(oldStartIndex++),新尾指针左移(newEndIndex–)
-
-
目的:高效处理节点移动到末尾的情况。

4. 旧尾新头比较
-
比较 oldEndVnode 和 newStartVnode。
-
如果相同,说明这个旧尾节点被移到了最前。
-
复用节点(patchVnode 更新内容)
-
将旧尾节点对应的 DOM 移动到旧头节点的 DOM 前面
-
旧尾指针左移(oldEndIndex–),新头指针右移(newStartIndex++)。
-
-
目的: 高效处理节点移动到开头的情况。

其他场景
若上述四种情况均未匹配,会通过 key 值快速查找可复用的旧节点(这也是 Vue 要求列表渲染必须加 key 的核心原因):
-
循环 oldChildren 中 oldStartIndex 到 oldEndIndex 的节点,生成 key --> index 的映射表(仅包含当前未处理的旧节点,
{c: 2, d: 3, e: 4})。 -
遍历 newChildren 中 newStartIndex 到 newEndIndex 的未处理节点,对每个新节点:
-
若能在映射表中找到对应旧节点(即 key 存在于映射表中):
-
将该旧节点从原位置移动到当前旧头节点(oldStartIndex)对应的 DOM 前面(oldChildren[oldStartIndex])
-
在旧数组中用 undefined 标记该节点为已处理(避免数组塌陷)
-
旧头指针右移(oldStartIndex++)、新头指针右移(newStartIndex++)
-
-
若未找到:
-
创建新节点
-
若旧节点还有未处理的(oldStartIdx <= oldEndIdx),插入到当前旧头节点的 DOM 前面
-
若旧节点已处理完(oldStartIndex > oldEndIndex),直接追加到末尾
-
-
新头指针右移(newStartIndex++)
-
-
-
当循环结束(oldStartIndex > oldEndIndex || newStartIndex > newEndIndex),需处理剩余未匹配的节点:
-
新节点有剩余(newStartIndex <= newEndIndex):说明这些是全新节点,批量插入到 DOM 中
-
旧节点有剩余(oldStartIndex <= oldEndIndex):说明这些节点在新数组中已不存在,批量卸载(移除 DOM)
-
这就是前面提到的 “所有比对在
oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex的循环条件下进行” 的原因。因为一旦不满足条件,就会直接执行批量插入或批量卸载,不会进行循环比对。
-
updateChildren 总结
updateChildren 通过 “头尾指针收缩” 的策略,优先处理常见的节点内容不变仅位置移动的场景,再通过 key 值快速匹配复用节点,最终以较低的复杂度完成虚拟 DOM 的更新。
全部核心代码
// diff算法核心 采用双指针的方式 对比新老vnode的儿子节点
function updateChildren(el, oldChildren, newChildren) {let oldStartIndex = 0; // 老儿子的开始下标let oldStartVnode = oldChildren[0]; // 老儿子的第一个节点let oldEndIndex = oldChildren.length - 1; // 老儿子的结束下标let oldEndVnode = oldChildren[oldEndIndex] // 老儿子的最后一个节点let newStartIndex = 0; // 新儿子的开始下标let newStartVnode = newChildren[0]; // 新儿子的第一个节点let newEndIndex = newChildren.length - 1; // 新儿子的结束下标let newEndVnode = newChildren[newEndIndex] // 新儿子的最后一个节点// 根据key来创建老的儿子的index映射表,如{'a': 0, 'b': 1}代表key为'a'的节点在第一个位置,'b'在第二个位置const makeIndexBykey = (children) => {return children.reduce((memo, cur, index) => {memo[cur.key] = indexreturn memo}, {})}const keysMap = makeIndexBykey(oldChildren)// 只有当新、老儿子的开始下标都小于等于结束下标时才循环,一方不满足就结束循环while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {// 因为暴力对比过程把移动的vnode置为 undefined 如果不存在节点直接跳过if (!oldStartVnode) { // 开始位置 向后 +1oldStartVnode = oldChildren[++oldStartIndex]} else if (!oldEndVnode) {// 结束位置 向前 -1oldEndVnode = oldChildren[--oldEndIndex]}if (isSameVnode(oldStartVnode, newStartVnode)) { // 新前和后前相同// 递归比较儿子以及他们的子节点patch(oldStartVnode, newStartVnode)// 新,老开始下标 +1, 对应的节点变为 +1 后的节点oldStartVnode = oldChildren[++oldStartIndex]newStartVnode = newChildren[++newStartIndex]} else if (isSameVnode(oldEndVnode, newEndVnode)) {// 新后和旧后相同// 递归比较儿子以及他们的子节点patch(oldEndVnode, newEndVnode)// 新,老结束下标 -1, 对应的节点变为 -1 后的节点oldEndVnode = oldChildren[--oldEndIndex]newEndVnode = newChildren[--newEndIndex]} else if (isSameVnode(oldStartVnode, newEndVnode)) { // 新后和旧前相同// 递归比较儿子以及他们的子节点patch(oldStartVnode, newEndVnode)// 开始节点的真实dom,移动到结束节点的下一个前点的前面el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)// 老的开始下标 +1, 对应的节点变为 +1 后的节点oldStartVnode = oldChildren[++oldStartIndex]// 新的结束下标 -1, 对应的节点变为 -1 后的节点newEndVnode = newChildren[--newEndIndex]} else if (isSameVnode(oldEndVnode, newStartVnode)) { // 新前和旧后相同// 递归比较儿子以及他们的子节点patch(oldEndVnode, newStartVnode)// 结束的真实dom,移动到开始节点的前面el.insertBefore(oldEndVnode.el, oldStartVnode.el)// 老的结束下标 -1, 对应的节点变为 -1 后的节点oldEndVnode = oldChildren[--oldEndIndex]// 新的开始下标 +1, 对应的节点变为 +1 后的节点newStartVnode = newChildren[++newStartIndex]} else {// 上述四种情况都不满足 那么需要暴力比对// 用新的开始节点的key,去老的子节点生成的映射表中查找const moveIndex = keysMap[newStartVnode.key]if (!moveIndex) { // 如果没有找到直接把新节点的真实dom,插入到旧的开始节点的真实dom前面el.insertBefore(createElm(newStartVnode), oldStartVnode.el)} else {// 如果找到,取出该节点const moveNode = oldChildren[moveIndex] // 原来的位置用undefined占位 避免数组塌陷 防止老节点移动走了之后破坏了初始的映射表位置oldChildren[moveIndex] = undefined// 把取出的节点的真实dom插入到开始节点的真实dom前面el.insertBefore(moveNode.el, oldStartVnode.el)patch(newStartVnode, moveNode) //比较}// 新的开始下标 +1, 对应的节点变为 +1 后的节点newStartVnode = newChildren[++newStartIndex]}}// 如果老节点循环完毕了 但是新节点还有,如用户追加了一个,需要把剩余的节点插入if (newStartIndex <= newEndIndex ) {for (let i = newStartIndex; i <= newEndIndex; i++) {// 这是一个优化写法 insertBefore的第一个参数是null等同于appendChild作用// 看一下 结束指针的下一个元素是否存在let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].elel.insertBefore(createElm(newChildren[i]), anchor)}}// 如果新节点循环完毕了 但是老节点还有,如用户删除一个,需要把剩余的节点删除if (oldStartIndex <= oldEndIndex) {for (let i = oldStartIndex; i <= oldEndIndex; i++) {// 该节点不是占位节点,才做删除操作if (oldChildren[i] != null) {el.removeChild(oldChildren[i].el)}}}
}
全流程总结
-
patch:Diff 入口,决定节点的创建、删除或更新。
-
isSameVnode:判断节点是否可复用,是 Diff 的基础。
-
patchVnode:同一节点的细节更新,包括属性和子节点。
-
updateChildren:高效对比子节点列表,通过首尾指针法最小化 DOM 操作。
疑问
patch 不是 Diff 算法的入口吗?为什么会在 updateChildren 中被调用?
patch 是对比单个节点的通用入口函数,而 updateChildren 是专门用于优化对比一组子节点(数组)的算法函数。
patch 会首先用于组件根节点的初次渲染或更新,当 patch → isSameVnode → patchVnode → updateChildren 流程走到这时,updateChildren 中的每个节点们如何进行比对呢?答案就是递归使用 patch 函数。
递归是否意味着当根节点发生变化,Vue 会递归遍历整个 VNode 的所有后代节点?
理论上确实是这样。例如:
<div>{{message}}<child1><child1-1><child1-1-1 /><child1-1-2 /></child1-1><child1-2></child1-2></child1><child2><child2-1></child2-1><child2-2></child2-2></child2>
</div>
-
message 变化 → 触发根组件重新渲染
-
patch 根组件 → isSameVnode → patchVnode → 更新文本
-
发现有子节点 → 进入 updateChildren
-
patch child1 → isSameVnode → patchVnode
-
发现有子节点 → 进入 updateChildren
-
patch child1-1
-
这个过程会一直持续,直到所有的节点都被 patch 到。
遍历整个树?那这不是效率非常低?
是的。所以 Vue 的一系列优化都是围绕如何快速比对来进行的。
“递归遍历对比 VNode ≠ 全量更新 DOM 或组件。”
简单说:遍历是为了 “检查是否有变化”,但只要没变化,就不会做任何额外工作。这种 “遍历但不操作” 的成本非常低(只是在内存中对比几个 JavaScript 对象的属性),复杂页面的主要性能开销还是在操作真实 DOM 上。
并且在实际开发中,修改根节点的数据是比较少的,更多的是修改组件中的数据。而且我们知道 Vue 是组件级更新,Diff 的过程只会在该组件以及该组件的后代组件中执行,它的平级组件和父级组件则不会被涉及到,这就大大减少了复杂度,提高了效率。
缺点
看到这里你应该会明白,Vue2 最大的缺点就是:
当组件内任何响应式数据发生变化时,会进行全量 Diff,包括那些没有任何变化的静态节点也会参与 Diff 的过程,并且在处理乱序时双端 Diff 算法并不高效。
所以 Vue3 针对以上痛点做了一些优化:
-
跳过静态节点,Diff 范围最小化
-
通过 LIS 最小化移动,处理乱序效率更高
