当前位置: 首页 > news >正文

Vue 系列之:Vue2 双端 Diff 算法原理

了解暴力 Diff 算法

两棵使用暴力 Diff 算法,复杂度是 O(n²):

  1. 第一棵树的每个节点(n 个),都要和第二棵树的每个节点(n 个)比较,共 n * n 次操作(O (n²))。

  2. 整体对比完成后,可能会涉及插入、删除、修改,操作次数最多为 O(n)(因为最多修改 n 个节点)。

  3. 因此,执行所有操作的总复杂度最多是 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
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)

其核心思想是:同时从新旧子节点数组的头尾两端开始比对,逐步向中间收拢

目的是:优先处理常见的节点内容不变、位置变化的场景。

理解四个指针

oldStartIndexoldEndIndex
指向未处理的第一个旧节点指向未处理的最后一个旧节点
newStartIndexnewEndIndex
指向未处理的第一个新节点指向未处理的最后一个旧节点

所有比对在 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 的核心原因):

  1. 循环 oldChildren 中 oldStartIndex 到 oldEndIndex 的节点,生成 key --> index 的映射表(仅包含当前未处理的旧节点,{c: 2, d: 3, e: 4})。

  2. 遍历 newChildren 中 newStartIndex 到 newEndIndex 的未处理节点,对每个新节点:

    • 若能在映射表中找到对应旧节点(即 key 存在于映射表中):

      • 将该旧节点从原位置移动到当前旧头节点(oldStartIndex)对应的 DOM 前面(oldChildren[oldStartIndex])

      • 在旧数组中用 undefined 标记该节点为已处理(避免数组塌陷)

      • 旧头指针右移(oldStartIndex++)、新头指针右移(newStartIndex++)

    • 若未找到:

      • 创建新节点

        • 若旧节点还有未处理的(oldStartIdx <= oldEndIdx),插入到当前旧头节点的 DOM 前面

        • 若旧节点已处理完(oldStartIndex > oldEndIndex),直接追加到末尾

      • 新头指针右移(newStartIndex++)

  3. 当循环结束(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>
  1. message 变化 → 触发根组件重新渲染

  2. patch 根组件 → isSameVnode → patchVnode → 更新文本

  3. 发现有子节点 → 进入 updateChildren

  4. patch child1 → isSameVnode → patchVnode

  5. 发现有子节点 → 进入 updateChildren

  6. patch child1-1

  7. 这个过程会一直持续,直到所有的节点都被 patch 到。

遍历整个树?那这不是效率非常低?

是的。所以 Vue 的一系列优化都是围绕如何快速比对来进行的。

递归遍历对比 VNode ≠ 全量更新 DOM 或组件。

简单说:遍历是为了 “检查是否有变化”,但只要没变化,就不会做任何额外工作。这种 “遍历但不操作” 的成本非常低(只是在内存中对比几个 JavaScript 对象的属性),复杂页面的主要性能开销还是在操作真实 DOM 上。

并且在实际开发中,修改根节点的数据是比较少的,更多的是修改组件中的数据。而且我们知道 Vue 是组件级更新,Diff 的过程只会在该组件以及该组件的后代组件中执行,它的平级组件和父级组件则不会被涉及到,这就大大减少了复杂度,提高了效率。

缺点

看到这里你应该会明白,Vue2 最大的缺点就是:

当组件内任何响应式数据发生变化时,会进行全量 Diff,包括那些没有任何变化的静态节点也会参与 Diff 的过程,并且在处理乱序时双端 Diff 算法并不高效。

所以 Vue3 针对以上痛点做了一些优化:

  • 跳过静态节点,Diff 范围最小化

  • 通过 LIS 最小化移动,处理乱序效率更高

http://www.dtcms.com/a/545958.html

相关文章:

  • 网站建设与维护案列领优惠券的网站怎么做
  • 【AIGC面试面经第四期】LLM-Qwen相关问答
  • 百度首页网站的设计用php做企业网站的可行性
  • 前端流水线连接npm私有仓库
  • 创可贴设计网站官网怎么建公司网站
  • leetcode375.猜数字大小II
  • 江西网站开发方案建设一个门户网站 费用
  • Android设备使用AirPods
  • 用js做的网站页面教育机构有哪些
  • @Transactional 事务注解坑之为什么自调用(同一个类中方法互相调用)事务不生效?
  • 使用 WSL 在 Windows 上安装 Linux
  • 有专业做网站的学校吗网站seo是啥
  • Agent记忆框架(三)
  • 建歌网站多少钱在百度备案网站
  • F040 python中医药图谱问答|双推荐算法+知识图谱+智能问答+vue+flask+neo4j前后端分离B/S架构|爬虫|图谱生成|全套
  • 南京做网站企业如何建网站做推广
  • 网页设计素材螺蛳粉图seo 网站两个ip
  • Blender骨骼笔记
  • 6.4 大数据方法论与实践指南-计算成本治理(省钱)
  • 开发BUG修复汇总(持续更新)
  • html5网站模板怎么用个人社保缴费证明怎么查询
  • 网站规划思想方法有哪些内容手机微网站平台登录入口
  • 【docker】bashrc文件的合理配置
  • Docker Desktop 安装教程和最佳实践
  • 6 mysql对order by group by join limit count的实现
  • Rust:Trait 抽象与 unsafe 底层掌控力的深度实践
  • 安全员C证(全国版)模拟考试练习题答案解析
  • (huawei)最小栈
  • 四川建设网官网住房和城乡厅网站文字很少怎么做优化
  • apache 配置网站茶叶网站源码php