• vue3源码分析——手写diff算法


    引言

    <<往期回顾>>

    1. vue3源码分析——实现组件通信provide,inject
    2. vue3源码分析——实现createRenderer,增加runtime-test
    3. vue3源码分析——实现element属性更新,child更新

    本期来实现,上一期中还差了children与children的对比😉😉😉 vue3更新流程中的children与children的对比,也就是diff算法,所有的源码请查看

    正文

    在children于children的对比中,vue3采用的是双端对比的方式,使用3个指针来进行移动对比,那肯定就会有许多的情况,且听我慢慢道来👍👍👍!

    先看个结果

    image.png

    通过上面的测试用例,可以看到是分了7种情况来的,在本篇文章,不采用编写测试用例,有兴趣的可以自己去github上面查看,这里主要使用图文加上代码,帮助大家更快的理解vue3中的diff算法

    锁定对比的范围

    既然采用的是双端对比,那么肯定是需要3个指针的,请看下图:

    image.png 总共有3个指针

    • i 代表的是起始指针默认是 0
    • e1 代表的是old children的末尾指针, 默认是 oldChildren.length -1
    • e2 代表的是new children的末尾指针, 默认是 newChildren.length -1

    既然有了指针,那是否需要锁定范围呢!指针做对应的移动。移动的方式如下:

    • 如果oldChildren和newChildren左边的节点都一样,那么就需要i往右边移动,直到不同位置停止.
    • 如果oldChildren和newChildren右边的节点都一样,那么对应的e1和e2往左边移动,同样也是到不同位置停止。

    根据上面的需求,我相信都能得出一个条件并且写出以下代码

    export function patchKeyedChildren(oldChildren, newChildren){
        let i = 0;
        let e1 = oldChildren.length - 1;
        let e2 = newChildren.length - 1;
        
        // 当 i <= e1 && i<= e2的时候,需要左边移动
        while(i <= e1 && i <= e2){
          if(oldChildren[i] 等于 newChildren[i]){
            i++
          }else{
            break;
          }
        }
        
        // 同理,右边的指针往左边移动也是这样
          while(i <= e1 && i <= e2){
          if(oldChildren[e1] 等于 newChildren[e2]){
            el--;
            e2--;
          }else{
            break;
          }
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    上面的代码是伪代码,主要是用于说明情况,双端对比,拿到不相同的部分,接下来根据不相同的部分来进行分情况讨论

    分情况讨论

    总共有7中情况,分别是:

    1. 老的比新的左边长,在新的左边创删除的节点
    2. 老的比新的右边长,在新的右边创删除的节点
    3. 老的比新大左边短,在新的左边创建新的节点
    4. 老的比新的右边短,在新的右边创建新的节点
    5. 老的比新的中间节点多,删除多余的节点
    6. 老的新的中间节点一样多,位置发生变化,需要移动位置
    7. 老的新的中间节点少,创建新的节点

    老的左右边长

    这里包含了老的左边长和右边长,都是需要删除对应边界的节点

    老节点左边长

    会发现,i不动, e1和 e2两个指针往左边移动 image.png

    最终 i = 0, e1 = 1, e2 = -1,所以可以得出一个条件是 i > e2 && i <= e1

    老节点右边长

    可以发现,e1,e2不动,i往右边移动 image.png 最终 i = 3, e1 = 3, e2 = 2,所以可以得出一个条件是 i > e2 && i <= e1

    老的比新的两边长,就是说只要满足 i > e2 && i <= e1这个条件,那就需要执行删除操作

    export function patchKeyedChildren(oldChildren, newChildren){
    // ……省略上面的指针移动代码
      if (i >= e2 && i <= e1) {
         // 为啥需要while呢,因为可能两边出现多个多余的节点,需要循环删除
          while (i <= e1) {
           // 删除当前节点
            hostRemove(n1[i].el)
            i++
          }
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    老的左右边短

    这里包含了,老节点的左边和右边比新的节点是更短的,需要创建新的节点

    老的比新的左边短

    i不动,e1,e2往左边移动

    image.png 最终 i = 0, e1 = -1, e2 = 0,所以可以得出一个条件是 i > e1 && i <= e2

    老的比新的右边短

    e1,e2不动,i往右边移动

    image.png 最终 i = 3, e1 = 2, e2 = 3,所以可以得出一个条件是 i > e1 && i <= e2

    老的比新的两边短,就是说只要满足 i > e1 && i <= e2这个条件,那就需要执行新增节点的操作

    export function patchKeyedChildren(oldChildren, newChildren){
    // ……省略上面的代码
    else  if (i > e1 && i <= e2) {
          //     a b   i = 0 e1 = -1 e2 = 0
          // d c a b  需要找到a的位置
          const nextPos = e2 + 1;
          const anchor = nextPos < l2 ? n2[nextPos].el : null
         // 同理,需要循环增加节点
          while (i <= e2) {
           // 插入节点
           patch(null, n2[i], container, parentComponent, anchor)
            i++
          }
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    看到这里,恭喜你👍👍👍,vue3 diff的双端对比就已经结束了,接下来锁定了一个中间范围,来判断中间范围的内容,删除,移动位置,新增

    中间节点老的比新的多

    image.png

    通过上面代码的执行,最终 i = 2, e1 =2, e2 = 2,但是这个不可以作为条件哦!思路需要转个弯🦮🦮🦮

    通过i 和 e1可以锁定,老节点中间部分,i 和 e2可以锁定新节点的范围; 遍历老的节点,判断老的节点在新节点中是否存在,可以使用双重for循环,但是比较费时间,可以把新节点的内容给缓存起来,那么可以通过空间来换取时间

    export function patchKeyedChildren(oldChildren, newChildren){
    // ……省略上面的代码
    else{
        // 中间对比
          const s1 = i;
          const s2 = i;
          // a b c d   i = 2 e1 = 2 e2 = 1
          // a b d
          // 条件  删除c
    
          // 判断旧节点是否在新节点里面,在的话保留,不在则删除
          const newIndexToOldIndexMap = new Map()
            // 把新节点的key装入map
          for (let j = s2; j <= e2; j++) {
            newIndexToOldIndexMap.set(newChildren[j].key, j)
          }
          // 然后遍历老的节点,判断是否在新的节点里面
           for (let i = s1; i <= e1; i++) {
            let newIndex;
            if (n1[i].key !== null) {
              newIndex = newIndexToOldIndexMap.get(n1[i].key)
            } else {
              // key 不存在,需要遍历新节点,看能不能找到这个节点
              for (let j = s2; j <= e2; j++) {
                if (isSameVNode(oldChildren[i], newChildren[j])) {
                  newIndex = j
                  break;
                }
              }
            }
            if (newIndex === undefined) {
              // 删除节点
              hostRemove(n1[i].el)
           }
      }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    通过上面可以发现,如果在中间对比的时候不写key,将会再来一次for循环,那就比较费性能了!!!

    中间对比,节点都存在,需要移动节点

    image.png 通过上面代码的执行,最终 i = 2, e1 =4, e2 = 4, 在上面的图中,会发现新老节点中只有E的节点移动了,其他的节点都不动,那么怎么找出 [C,D,E]和[E,C,D]中的最长连续的节点呢?

    遇到这个,肯定需要使用 最长递增子序列的算法啦,通过这个可以得出最长节点的连续范围.

    但是最长递增子序列中需要一个数组,这个数组需要记录老节点与新节点的位置关系

    image.png\

    通过上图,可以看到,先定义一个数组,长度是对比之后的长度,然后默认赋值为0,然后遍历老节点,用老节点的i来表示数组的结果,newIndex表示数据的下标,这样就能展示出新老节点的映射关系了

    接下来实现一下这部分的功能,由于这一部分的内容与整体有关,我把整个diff的逻辑都放出来,全部已经写好注释了😉😉😉

     function patchKeyChildren(n1, n2, container, parentComponent, anchor) {
    
        // 采用双端对比法
        const l2 = n2.length
        const l1 = n1.length
        let i = 0;
        let e1 = l1 - 1;
        let e2 = l2 - 1;
    
        const isSameVNode = (vnode1, vnode2) => {
          return vnode1.type === vnode2.type && vnode1.key === vnode2.key
        }
    
        // 获取左侧的位置
        while (i <= e1 && i <= e2) {
          if (isSameVNode(n1[i], n2[i])) {
            // 对比对应的子节点
            patch(n1[i], n2[i], container, parentComponent, anchor)
          } else {
            break;
          }
          i++
        }
        // 获取右侧是位置
        while (i <= e1 && i <= e2) {
          if (isSameVNode(n1[e1], n2[e2])) {
            patch(n1[e1], n2[e2], container, parentComponent, anchor)
          } else {
            break;
          }
          e1--;
          e2--;
        }
    
        // 新的比老的短,删除老的
        if (i >= e2 && i <= e1) {
          while (i <= e1) {
            hostRemove(n1[i].el)
            i++
          }
        }
    
        // 新的比老的长,挂载新的
        else if (i > e1 && i <= e2) {
          // 往左侧添加 
          //    a b   i = 0 e1 = -1 e2 = 0
          // d c a b  需要找到a的位置
          const nextPos = e2 + 1;
          const anchor = nextPos < l2 ? n2[nextPos].el : null
          while (i <= e2) {
            patch(null, n2[i], container, parentComponent, anchor)
            i++
          }
        }
        else {
          // 中间对比
          const s1 = i;
          const s2 = i;
          // a b c d   i = 2 e1 = 2 e2 = 1
          // a b d
          // 条件  删除c
    
          // 判断旧节点是否在新节点里面,在的话保留,不在则删除
          const newIndexToOldIndexMap = new Map()
          // 增加一个优化点,如果旧节点的数量和新节点的数量已经一致,那么其余旧节点直接删除
          const toBePatched = e2 - s1 + 1;
          let patched = 0;
    
          // 将老节点和新节点做映射 b c d --> c d b
          const oldIndexToNewIndexMap = new Array(toBePatched).fill(0)
          // 判断是否需要移动节点,不需要移动则不需要调用获取最长递增子序列
          let moved = false;
          let maxIndexSoFar = 0;
    
          // 把新节点的key装入map
          for (let j = s2; j <= e2; j++) {
            newIndexToOldIndexMap.set(n2[j].key, j)
          }
    
          for (let i = s1; i <= e1; i++) {
            // 新节点已经满足前面对比的老节点,需要删除其余的老节点
            if (patched >= toBePatched) {
              hostRemove(n1[i].el)
              continue;
            }
            let newIndex;
            if (n1[i].key !== null) {
              newIndex = newIndexToOldIndexMap.get(n1[i].key)
            } else {
              // key 不存在,需要遍历新节点,看能不能找到这个节点
              for (let j = s2; j <= e2; j++) {
                if (isSameVNode(n1[i], n2[j])) {
                  newIndex = j
                  break;
                }
              }
            }
            if (newIndex === undefined) {
              // 删除节点
              hostRemove(n1[i].el)
            } else {
              // 更新映射关系
              oldIndexToNewIndexMap[newIndex - s1] = i + 1
              // 判断是否需要移动节点
              if (newIndex > maxIndexSoFar) {
                maxIndexSoFar = newIndex;
              } else {
                moved = true
              }
              // 存在的话,进行下一次的patch
              patch(n1[i], n2[newIndex], container, parentComponent, anchor)
              patched++
            }
          }
    
          // 获取最长递增序列  获取的是最长递增子序列的索引下标数组
          const longestIncreasingSubsequence = moved ? getLongestIncreasingSubsequence(oldIndexToNewIndexMap) : []
          // 遍历,判断当前的下标是否存在于最长递增序列中,存在则不需要移动,不存在需要移动
    
          // 使用游标j
          let j = longestIncreasingSubsequence.length - 1;
          for (let i = toBePatched - 1; i >= 0; i--) {
            // 计算下一个节点
            const curIndex = i + s2;
            const anchor = curIndex + 1 < l2 ? n2[curIndex + 1].el : null;
            if (oldIndexToNewIndexMap[i] === 0) {
              // 新增节点
              patch(null, n2[i + s2], container, parentComponent, anchor)
            }
            else if (moved) {
              if (j < 0 || i !== longestIncreasingSubsequence[j]) {
    
                // 移动节点
                hostInsert(n2[curIndex].el, container, anchor)
              } else {
                // 命中目标节点,不需要移动
                j--;
              }
            }
          }
        }
      }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143

    中间节点新增

    对于这个功能,如果看懂了上面代码的话,是非常简单的,只需要判断oldIndexToNewIndexMap[i]是否为0,为0则代表,老节点中没有,新节点有,需要新增节点哦!

    总结

    本期主要实现了vue3的diff算法,主要分为7个部分:

    1. 老的比新的左边长,在新的左边创删除的节点
    2. 老的比新的右边长,在新的右边创删除的节点
    3. 老的比新大左边短,在新的左边创建新的节点
    4. 老的比新的右边短,在新的右边创建新的节点
    5. 老的比新的中间节点多,删除多余的节点
    6. 老的新的中间节点一样多,位置发生变化,需要移动位置
    7. 老的新的中间节点少,创建新的节点

    只有在中间节点一样多的时候,才会使用最长递增子序列,来判断哪些是需要移动的,只能说vue3的设计太厉害和灵活了,加油!!!

  • 相关阅读:
    Mall脚手架总结(三) —— MongoDB存储浏览数据
    互联网应用主流框架整合之构建REST风格的系统
    浪潮服务器使用ARCCONF查看RAID配置信息
    Day49-53 操作系统的中断、异常以及系统调用
    视频化全链路智能上云?一文详解什么是阿里云视频云「智能媒体生产」
    手工实现一个字符串内容替换方法来替换replace
    建模杂谈系列182 FuncDict2_模式设计
    不懂代码也能按需构建数字化应用
    【贪心】leetcode991. Broken Calculator
    【编程题】【Scratch三级】2021.12 分身术
  • 原文地址:https://blog.csdn.net/qq_41499782/article/details/125548015