vue3的diff(Difference)算法

vue3的diff(Difference)算法

算法中涉及的核心函数

patch(),process*(),patchBlockChildren(),patchChildren(),patchUnkeyedChildren(),patchKeyedChildren()

  • patch函数负责将虚拟 DOM树应用到实际的 DOM 上,这个函数是diff算法的入口函数(大概)

  • patch函数做的事情:

    1. 新旧阶段相同就返回
    2. 如果旧节点存在且类型与新节点不同,则卸载旧节点树。
    3. 如果新节点的补丁标志为 BAIL,则禁用优化模式并清空动态子节点。
    4. 解构新节点的关键属性并根据节点类型执行不同的处理逻辑。
    5. 设置引用 (ref)
  • 但是其中和diff相关的核心部分是第4步,根据节点类型的不同执行不同的处理逻辑,

接下来我们以processFragment()为下一个执行函数

  • processFragment函数负责处理片段节点逻辑

  • processFragment函数所做的事情:

    1. 设置片段的起始锚点和结束锚点

    2. 解构新节点的关键属性

    3. 将带有 :slotted 作用域 ID 的插槽片段的ID 合并到插槽作用域 ID 列表(如果有)

    4. 旧节点不存在执行挂载

    5. 旧节点存在

      1. 如果补丁标志大于 0&&补丁标志包含稳定片段标志(通过位操作&实现)&&动态子节点存在&&旧节点的动态子节点存在

        • 执行patchBlockChildren函数 负责处理块级子节点的更新 这是一个优化操作

          • 因为一个稳定片段不需要重新排序子节点,但可能需要更新它们的属性、内容或执行其他操作,所以调用patchBlockChildren函数更高效的处理更新操作而不是直接进入diff算法片段比较节点.
      2. 浅层遍历静态子节点

    6. 否则进入patchChildren函数

  • patchChildren 函数主要是根据有没有key调用patchUnkeyedChildren或patchKeyedChildren
  • patchUnkeyedChildren对应没有key的处理情况

    此方法比较简单,是直接遍历新旧节点列表较短的那一个挂载节点,然后根据新旧节点列表的长度关系移除旧节点或挂载新节点

  • patchKeyedChildren

    • 此方法是vue3diff的精髓所在 它使用了双指针

    • 过程如下

      1. 从头部开始复用节点

      2. 从尾部开始复用节点

      3. 挂载新列表尾部没有比较的节点

      4. 把多余的旧节点卸载掉

      5. 对中间剩余的节点进行复用

        1. 构建新节点列表中间部分的key:index的映射
        2. 遍历剩余的旧子节点,尝试匹配并更新它们,同时移除不再存在的节点
        3. 移动和挂载
javascript 复制代码
   const patchKeyedChildren = (
     c1: VNode[],
     c2: VNodeArrayChildren,
     container: RendererElement,
     parentAnchor: RendererNode | null,
     parentComponent: ComponentInternalInstance | null,
     parentSuspense: SuspenseBoundary | null,
     namespace: ElementNamespace,
     slotScopeIds: string[] | null,
     optimized: boolean,
   ) => {
     let i = 0
     const l2 = c2.length //新的子节点长度
     let e1 = c1.length - 1 // c1 尾部索引
     let e2 = l2 - 1 // c2 尾部索引
 ​
     // 1. sync from start
     // (a b) c
     // (a b) d e
     //从头部开始比较(复用节点)
     while (i <= e1 && i <= e2) {
       const n1 = c1[i]
       const n2 = (c2[i] = optimized
         ? cloneIfMounted(c2[i] as VNode)
         : normalizeVNode(c2[i]))
       if (isSameVNodeType(n1, n2)) {
         patch(
           n1,
           n2,
           container,
           null,
           parentComponent,
           parentSuspense,
           namespace,
           slotScopeIds,
           optimized,
         )
       } else {
         break
       }
       i++
     }
 ​
     // 2. sync from end
     // a (b c)
     // d e (b c)
     //从尾部开始比较(复用节点)
     while (i <= e1 && i <= e2) {
       const n1 = c1[e1]
       const n2 = (c2[e2] = optimized
         ? cloneIfMounted(c2[e2] as VNode)
         : normalizeVNode(c2[e2]))
       if (isSameVNodeType(n1, n2)) {
         //相同就patch
         patch(
           n1,
           n2,
           container,
           null,
           parentComponent,
           parentSuspense,
           namespace,
           slotScopeIds,
           optimized,
         )
       } else {
         //不同就结束
         break
       }
       e1--
       e2--
     }
 ​
     // 3. common sequence + mount
     // (a b)
     // (a b) c
     // i = 2, e1 = 1, e2 = 2
     // (a b)
     // c (a b)
     // i = 0, e1 = -1, e2 = 0
     // (挂载尾部的新节点)
     // 旧的列表遍历完了,新的列表还没有遍历完,把新列表的尾部挂载上
     // 当新旧子节点列表进行比较时,如果发现旧的子节点已经遍历完(即 i > e1),但新的子节点列表还有剩余未处理的节点(即 i <= e2),说明这些剩余的新节点是需要 新增挂载 的节点。这段代码的作用就是将这些新增的节点挂载到 DOM 中。
     if (i > e1) {
       if (i <= e2) {
         const nextPos = e2 + 1
         const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
         while (i <= e2) {
           patch(
             null,
             (c2[i] = optimized
               ? cloneIfMounted(c2[i] as VNode)
               : normalizeVNode(c2[i])),
             container,
             anchor,
             parentComponent,
             parentSuspense,
             namespace,
             slotScopeIds,
             optimized,
           )
           i++
         }
       }
     }
 ​
     // 4. common sequence + unmount
     // (a b) c
     // (a b)
     // i = 2, e1 = 2, e2 = 1
     // a (b c)
     // (b c)
     // i = 0, e1 = 0, e2 = -1
     //卸载旧节点
     //把比较后旧节点多余的的节点卸载掉
     else if (i > e2) {
       while (i <= e1) {
         unmount(c1[i], parentComponent, parentSuspense, true)
         i++
       }
     }
 ​
     // 5. unknown sequence
     // [i ... e1 + 1]: a b [c d e] f g
     // [i ... e2 + 1]: a b [e d c h] f g
     // i = 2, e1 = 4, e2 = 5
     //复用(挂载)中间部分的(新)节点
     else {
       const s1 = i // prev starting index
       const s2 = i // next starting index
 ​
       // 5.1 构建新节点列表中间部分的key:index的映射
       const keyToNewIndexMap: Map<PropertyKey, number> = new Map()
       for (i = s2; i <= e2; i++) {
         const nextChild = (c2[i] = optimized
           ? cloneIfMounted(c2[i] as VNode)
           : normalizeVNode(c2[i]))
         if (nextChild.key != null) {
           if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
             warn(
               `Duplicate keys found during update:`,
               JSON.stringify(nextChild.key),
               `Make sure keys are unique.`,
             )
           }
           keyToNewIndexMap.set(nextChild.key, i)
         }
       }
 ​
       // 5.2 遍历剩余的旧子节点,尝试匹配并更新它们,同时移除不再存在的节点
       let j
       let patched = 0 // 记录已经成功匹配并更新的节点数量
       const toBePatched = e2 - s2 + 1 // 新子节点需要处理的数量
       let moved = false // 标记是否有任何节点发生了移动
 ​
       // 用于跟踪最长稳定子序列的最大新索引
       let maxNewIndexSoFar = 0
 ​
       // 创建一个数组,用于记录新节点索引与旧节点索引的映射关系
       // 注意:旧节点索引偏移了 +1,且旧索引为 0 表示新节点没有对应的旧节点
       const newIndexToOldIndexMap = new Array(toBePatched)
       for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 初始化为 0
 ​
       // 遍历旧子节点列表中的剩余部分(从 s1 到 e1)
       for (i = s1; i <= e1; i++) {
         const prevChild = c1[i] // 当前旧子节点
         if (patched >= toBePatched) {
           // 如果所有新子节点都已处理完毕,则剩下的旧子节点只能被卸载
           unmount(prevChild, parentComponent, parentSuspense, true)
           continue
         }
 ​
         let newIndex // 记录当前旧子节点在新子节点列表中的索引位置
         //这一步是为了找到可复用的旧节点在新节点列表中的位置
         if (prevChild.key != null) {
           // 如果旧子节点有 key,则直接通过 key 查找其在新子节点列表中的位置
           newIndex = keyToNewIndexMap.get(prevChild.key)
         } else {
           // 如果旧子节点没有 key,则尝试通过类型匹配找到相同类型的无 key 节点
           for (j = s2; j <= e2; j++) {
             // 只有当新子节点尚未匹配过且类型相同的情况下才认为找到了匹配项
             if (
               newIndexToOldIndexMap[j - s2] === 0 && // 新子节点尚未匹配过
               isSameVNodeType(prevChild, c2[j] as VNode) // 类型相同
             ) {
               newIndex = j // 找到匹配的新子节点索引
               break
             }
           }
         }
 ​
         if (newIndex === undefined) {
           // 如果没有找到匹配的新子节点,则说明该旧子节点已被删除,需要卸载
           unmount(prevChild, parentComponent, parentSuspense, true)
         } else {
           // 如果找到了匹配的新子节点,则记录新旧索引的映射关系
           newIndexToOldIndexMap[newIndex - s2] = i + 1 // 旧索引偏移 +1 处理
 ​
           // 检查是否发生了节点移动
           if (newIndex >= maxNewIndexSoFar) {
             maxNewIndexSoFar = newIndex // 更新最大新索引
           } else {
             moved = true // 如果新索引小于最大值,说明节点发生了移动
           }
 ​
           // 对匹配的旧子节点和新子节点进行补丁操作(更新或复用)
           patch(
             prevChild,
             c2[newIndex] as VNode,
             container,
             null,
             parentComponent,
             parentSuspense,
             namespace,
             slotScopeIds,
             optimized,
           )
           patched++ // 成功匹配并更新了一个节点
         }
       }
       // 5.3 移动和挂载
       // 仅当节点发生移动时,生成最长递增子序列(LIS),用于确定稳定子序列。
       const increasingNewIndexSequence = moved
         ? getSequence(newIndexToOldIndexMap) // 使用最长递增子序列算法计算稳定子序列。
         : EMPTY_ARR // 如果没有发生移动,则为空数组。
 ​
       j = increasingNewIndexSequence.length - 1 // 初始化稳定子序列的索引指针。
 ​
       // 从后向前遍历新子节点列表,以便使用最后一个已修补的节点作为锚点。
       for (let i = toBePatched - 1; i >= 0; i--) {
         const nextIndex = s2 + i // 当前新子节点的索引。
         const nextChild = c2[nextIndex] as VNode // 当前新子节点。
         const anchor =
           nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor // 确定锚点元素。
         // 如果映射表中的值为 0,表示该位置需要挂载新节点。
         if (newIndexToOldIndexMap[i] === 0) {
           // 挂载新节点。
           patch(
             null, // 没有对应的旧节点。
             nextChild, // 新子节点。
             container, // 容器元素。
             anchor, // 锚点元素。
             parentComponent, // 父组件实例。
             parentSuspense, // 父级 Suspense 边界。
             namespace, // 命名空间。
             slotScopeIds, // 插槽作用域 ID 列表。
             optimized, // 是否启用优化模式。
           )
         } else if (moved) {
           // 如果有节点发生了移动。
           // 移动条件:
           // 1. 没有稳定子序列(例如,完全反转的情况)。
           // 2. 当前节点不在稳定子序列中。
           if (j < 0 || i !== increasingNewIndexSequence[j]) {
             move(nextChild, container, anchor, MoveType.REORDER) // 移动节点到正确的位置。
           } else {
             // 如果当前节点在稳定子序列中,则不需要移动,继续检查下一个稳定子序列节点。
             j--
           }
         }
       }
     }
   }
 ​

第5步是最抽象的下面详细介绍

old:['a','b','c','d','e','f','g']new:['a','b','d','e','c','h','f','g']为例子.进入第五步时i=2,s1 =2 , s2 = 2, e1=4,e2=5

第一步的目的是从新列表在下标在未操作的区域里得到一个key:index的映射,此案例是2到5

根据代码得到的映射如下:keyToNewIndexMap({d:2,e:3,c:4,h5})

第二步的目的是获得偏移数据数组newIndexToOldIndexMap,并复用能复用的节点卸载需要卸载的节点

  • 遍历所有旧列表中未操作区域中的节点进行操作

    • 如果所有新子节点都已处理完毕,则剩下的旧子节点只能被卸载(s1到e1)
    • 查找其在新子节点列表中的位置
    • 如果没有找到就说明此节点应该被卸载
    • 否则记录新旧索引的映射关系(从这里可以看出来newIndexToOldIndexMap列表里存的是旧索引偏移加一的值,对应下标是其所在新节点区域的位置)

根据代码得到的映射如下:newIndexToOldIndexMap:[4,5,3,0]

  • 数组的下标表示新子节点在新子节点列表中的相对位置。例如,newIndexToOldIndexMap[0] 对应的是新子节点列表中未比较位置的第一个新子节点。本案例中为'd'

  • 数组中每个元素的值表示该新节点在旧子节点列表中的位置。值为 0 表示没有找到对应的旧节点。由于旧节点索引偏移了 +1,因此值为 0 表示新节点没有对应的旧节点。如果值不为 0,则实际的旧节点索引是该值减去 1。例如,newIndexToOldIndexMap[0] = 4 说名节点'd'在旧节点的索引为3,刚好也是'd'

    • 新子节点列表中的相对位置: 0(2),1(3),2(4),3(5)
    • 旧子节点列表中的位置 : 3, 4, 2, 无

第三步的目的是根据建立的映射关系移动节点

这一步是从后往前进行比较和移动的,使用newIndexToOldIndexMap的最长递增子序列作为是否应该进行移动的依据

理由是:

  • newIndexToOldIndexMap数组记录了其在新子节点列表中的位置以及在旧子节点列表中的位置,
  • 如果位置没有发生移动,那么本来下标小的也应该小,本来下标大的也应该大

最后调用move函数将其移动到正确的位置即可,锚点元素的确定规则如下:

  • 如果 nextIndex + 1 新子节点数组的长度,则使用 c2[nextIndex + 1].el 作为锚点。这表示下一个兄弟节点的 DOM 元素。
  • 否则,使用 parentAnchor 作为锚点。parentAnchor 是父级容器中的一个参考节点,通常用于没有更多兄弟节点的情况。

总结

一、核心设计思想

  1. 避免不必要的创建或销毁操作:Vue3的Diff算法会尽可能处理现有DOM节点,避免不必要的节点创建或销毁。
  2. 双端比较:算法从新旧子节点的两端开始比较,快速处理相同部分,提高比较效率。
  3. 利用最长递增子序列(LIS)优化节点移动:对于无法直接匹配的节点,Vue3使用LIS算法优化节点的移动操作,尽量减少DOM的插入和移动次数。
  4. 静态节点标记:在编译阶段,Vue3会标记静态节点,并在更新过程中跳过这些节点的比较,从而减少渲染成本。

二、算法实现

  1. 节点类型判断:在Diff算法中,首先判断新旧节点的类型是否相同。如果类型相同,则进行属性和子节点的更新;如果类型不同,则直接替换节点。
  2. 属性更新:Vue3通过逐一比较新旧属性集合来更新节点属性。如果新旧属性值不同,则更新到新值;如果某个属性在旧节点中存在,但新节点中不存在,则移除该属性。
  3. 子节点比较:子节点的比较是Diff算法中最复杂的部分。Vue3采用双端比较策略,同时从新旧子节点的两端开始对比,快速跳过相同部分。对于无法直接匹配的节点,使用key来定位节点,并利用LIS算法优化节点移动。

三、算法优化

  1. 静态节点提升:Vue3在编译时会对静态节点进行提升,从而在更新过程中跳过这些节点的比较,大大减少渲染成本和Diff算法的运行时间。
  2. 支持碎片化:Vue3允许组件有多个根节点,即支持碎片化,这可以减少DOM中不必要的包装层级,提高渲染性能。
  3. 区块树和编译优化:Vue3引入了区块树(Block Tree)的概念,它可以跳过静态内容,快速定位到动态节点,从而减少了Diff时的比较次数。此外,Vue3在编译时还会对模板进行静态提升和优化,进一步提高性能。
  4. 响应式系统改进:Vue3使用Proxy替代了Vue2中的Object.defineProperty来实现响应式系统。Proxy可以捕获对象的访问和修改,使得Vue3在追踪状态变更时更加高效,并且可以监听动态新增的属性。这一改进也间接影响了Diff算法的性能,因为更精确的响应式系统可以减少不必要的更新和比较。

四、算法效果

Vue3的Diff算法通过上述设计思想和实现策略,在性能和灵活性之间找到了良好的平衡。它能够高效地比较新旧虚拟DOM树,并将必要的更改反映到实际DOM上,从而提高了前端应用的性能和响应速度。

综上所述,Vue3的Diff算法是一种高效、灵活的算法,它通过一系列优化策略,实现了对虚拟DOM的高效比较和更新。

相关推荐
陈卓41031 分钟前
Redis-限流方案
前端·redis·bootstrap
顾林海39 分钟前
Flutter Dart 运算符全面解析
android·前端
七月丶1 小时前
🚀 现代 Web 开发:如何优雅地管理前端版本信息?
前端
漫步云端的码农1 小时前
Three.js场景渲染优化
前端·性能优化·three.js
悬炫1 小时前
赋能大模型:ant-design系列组件的文档知识库搭建
前端·ai 编程
用户108386386801 小时前
95%开发者不知道的调试黑科技:Apipost让WebSocket开发效率翻倍的秘密
前端·后端
稀土君1 小时前
👏 用idea传递无限可能!AI FOR CODE挑战赛「创意赛道」作品提交指南
前端·人工智能·trae
OpenTiny社区1 小时前
Node.js 技术原理分析系列 4—— 使用 Chrome DevTools 分析 Node.js 性能问题
前端·开源·node.js·opentiny
写不出代码真君2 小时前
Proxy和defineProperty
前端·javascript
乐坏小陈2 小时前
TypeScript 和 JavaScript:2025 年应该选择哪一个?【转载】
前端·javascript