vue3手写diff算法(图解+例子+详细注释)

在阅读此文章前,您需要懂得vue3将虚拟DOM挂载为真实节点的基本原理!!!

什么时候会用到diff算法呢?
答:在vue3的使用中,修改响应式属性需要重新渲染页面,会重新执行render渲染函数返回新虚拟DOM,拿到新的虚拟DOM后,需要进行patch对比新旧虚拟DOM,在对比双方的子级节点,需要拿旧的虚拟DOM的子级和新的虚拟DOM子级进行patchChildren比较双方子级元素的差异,并且双方子级都为数组的情况下(具有多个子级节点)就会使用到diff算法!

为什么需要用diff算法呢?
答:diff算法主要为了对比对新旧虚拟DOM之间的差异,对相同的节点进行复用,只需要找出新增的虚拟DOM进行创建并插入(提高性能),元素发生位置变化,也需要找出哪些元素需要移动(减少元素移动次数);如果不进行对比,每次更新都先对旧节点进行卸载,再重新挂载新节点,而新虚拟DOM是没有el真实节点的,所以每次挂载节点前都需要先根据标签名创建真实节点,再进行挂载,这样每次卸载或挂载都会照成页面重排,造成性能浪费!

我们来看一下在使用v-for中没有使用key标识节点是如果和进行更新的
vue3源码(了解一下就好,别跑题了)

//packages/runtime-core/renderer.ts

  const patchChildren: PatchChildrenFn = (...) => {
  ...
  //在compile模板编译过程中v-for的节点会被Fragment包裹(父级),编译过程中会知道遍历节点中有无绑定key
  //有的话就给Fragment的patchFlag就标识为128 /* KEYED_FRAGMENT */,否则为256 /* UNKEYED_FRAGMENT */
  
    if (patchFlag > 0) {// 大于0表示为动态节点(优化diff算法称为靶向更新)
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {//位运算,存在key
        patchKeyedChildren(...)//diff算法
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {//不存在key
        patchUnkeyedChildren(...)
        return
      }
    }
  }  

//注意:此对比函数为精简版
//旧子级,新子级,父级容器
const patchUnkeyedChildren = (oldChildren, newChildren, el) => {
    // 获取新旧子节点的长度
    const oldLength = oldChildren.length
    const newLength = newChildren.length
    
    // 1. 取得公共最小长度
    const commonLength = Math.min(oldLength, newLength)
    let i
    
    // 2. patch对比公共部分
    for (i = 0; i < commonLength; i++) { 
      patch(...)//对比新旧子级
    }

    // 3. 大于,卸载旧节点,(说明要删除旧节点)
    if (oldLength > newLength) {
      unmountChildren(...)//这里会传入commonLength,只会对后面的元素做处理
    } else { // 4. 否则挂载新的子节点
      mountChildren(...)//这里会传入commonLength,只会对后面的元素做处理
    }
  } 

上面的代码可以看出在没有key的情况下,拿出公共长度的元素进行比较,再判断是否有多余的旧节点进行删除,否则可能存在新插入的节点;看上去这个写法还不错,我们来看个例子:
在这里插入图片描述

  1. 上图可看出:新子级在下标为2添加了F,公共对比中AB可以进行复用,对比到旧节点为C后,新虚拟DOM变为F了,它俩进行patch,新的F会替换掉C,后面的会一直替换下去,每一次替换可能需要创建真实节点再进行挂载(节点类型不同的情况下,节点类型相同会复用旧的真实节点)
  2. 类型相同的话虽然会复用旧虚拟DOM保存好的真实节点,但还是需要对两个虚拟DOM进行比较(因为两个虚拟DOM的属性、内容或子级可能不同),每次比对都可能会照成页面重排或重绘!
  3. 后面判断旧子级是否大于新子级时为false,所以对E进行挂载(这个E由于没有复用旧节点,所以会重新创建一个新节点并插入)

由此可见,ABCDE都是可以复用的节点,只是新添加了F,理想的做法应该是复用ABCDE(不用动),然后创建F真实节点,将其插入到C前面,只会造成一次页面重排,这样大大提高了性能!!!

官方:
如果不使用key,Vue会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法; (意思应该是:比如两个li元素,只是内容不同,尝试修改内容后复用该节点;因为新子级的虚拟DOM中是没有保存真实el节点的,需要根据标签名创建元素节点再进行挂载)
而使用key时,它会基于key的变化重新排列元素顺序,并且会移除/销毁key不存在的元素;

正文

在处理有key进行diff算法时,我把对比分为四种情况需要处理:

  1. 从前往后递增对比节点类型和key相同的进行复用(在不同处停止对比)。
  2. 从后往前递减行对比节点类型和key相同的进行复用(在不同处停止对比)。
  3. 新子级比旧子级多,说明有新增节点,如果少说明要卸载某些旧节点。
  4. 首尾相同都可能会有相同节点,首尾都对比完,中间部分可能存在可复用乱序节点。

1.前后对比(新增新节点)

从前往后对比
在这里插入图片描述
从后往前比
在这里插入图片描述
两种情况都是:i大于e1说明有新增,i到e2之间的元素就是要插入的

思路:

  1. 分别有三个指针:i=0;e1=旧子级数量;e2=新子级数量
  2. 从头开始比,如果i小于等于某一方就停止(说明某一方遍历完了),相同就进行patch并i++继续循 环,不相同则直接终止循环
  3. 再进行从后往前比,如果i小于等于某一方就停止,相同就进行patch并e1++和e2++继续循环,不相同则直接终止循环
  4. i大于e1说明有新增,i到e2之间的元素就是要插入的,在插入前尝试获取下一个元素作为参照物进行插入,因为有可能新增的在某个元素前面!
 function isSameVnode(vnode1, vnode2) {//判断两个虚拟节点是否相同
  return (vnode1.type === vnode2.type) && (vnode1.key === vnode2.key)
}

//实现了往后插入
const patchKeyedChildren = (oldChildren, newChildren, el) => {//全量diff,比较两个子级的差异
    let i = 0
    let e1 = oldChildren.length - 1
    let e2 = newChildren.length - 1

    while (i <= e1 && i <= e2) {//从头开始比,相同就进行patch,有一方停止循环直接跳出
      const n1 = oldChildren[i]//取出每一个进行比较是否相同
      const n2 = newChildren[i]
      if (isSameVnode(n1, n2)) {//比较类型和key,如果相同就递归比较他们的子级
        //对比两个元素(上面只是类型和key相同,并不代表两个元素中属性或子级相同)
        patch(n1, n2, el)
      } else {
        //当两个元素不相同时就停止比较,因为要更新当前的旧元素
        break
      }
      i++
    }
    
    while (i <= e1 && i <= e2) {//再从后往前比
      const n1 = oldChildren[e1]//取出最后一个
      const n2 = newChildren[e2]
      if (isSameVnode(n1, n2)) {//比较类型和key,如果相同就递归比较他们的子级
        patch(n1, n2, el)
      } else {
        //当两个元素不相同时就停止比较,因为要更新当前的旧元素
        break
      }
      e1--
      e2--
    }
   // console.log(i, e1, e2);
   
    //i要比e1大说明有新增,i和e2之间的元素就是要插入的
    if (i > e1) {
      if (i <= e2) {
        while (i <= e2) {//比如i=0,e2=1,就是插入两个元素
           const nextPos = e2 + 1 //e2的下一个索引
          //如果nextPos小于newChildren长度,说明后面有元素,否则为null,从末尾插入元素
          const anchor = nextPos < newChildren.length ? newChildren[nextPos] : null
          patch(null, newChildren[i], el, anchor)//插入元素
        }
      }
    }
  }

2.前后对比(卸载旧节点)

从前往后对比
在这里插入图片描述

从后往前对比
在这里插入图片描述
可以看出当循环判断到i比e2大的时候,i到e1之间的不在新子级中,说明是要卸载

    //i要比e1大说明有新增,i和e2之间的元素就是要插入的
    if (i > e1) {
      if (i <= e2) {
        while (i <= e2) {//比如i=0,e2=1,就是插入两个元素
          const nextPos = e2 + 1 //e2的下一个索引
          //如果nextPos小于newChildren长度,说明后面有元素,否则为null,从末尾插入元素
          const anchor = nextPos < newChildren.length ? newChildren[nextPos] : null
          patch(null, newChildren[i], el, anchor)//插入元素
          i++
        }
      }
    } else if (i > e2) { //i比e2大的时候,i到e1之间的不在新子级中,说明是要卸载
      if (i <= e1) {
        while (i <= e1) {
          unmount(oldChildren[i])
          i++ 
        }
      }
    }

3.处理中间(乱序比对)

在这里插入图片描述

在前后对比完,中间的就是乱序的子级,我们要在乱序子级中找出哪些节点可以进行复用,不可复用的就删除掉,新增的子级就创建元素并插入;
从上图可看出CDE都可进行复用,只需要对H进行新增;
下面会把旧子级中[C,D,E]称为旧乱序子级(列表),新子级中[E,C,D,H]称为新乱序子级(列表)
思路:

  1. 新增s1和s2指针,分别指向在i停止的位置
  2. 把新子级的所有乱序元素(CDEH)保存在一个映射表里(keyToNewIndexMap)
  3. 遍历旧乱序子级,查找映射表中是否存在该元素,存在就进行复用,不存在就说明需要创建新元素并插入,还要把旧节点进行删除
  const patchKeyedChildren = (oldChildren, newChildren, el) => {
    ....省略上面代码
  //-----乱序比对-----
    //以下要判断无序的可以复用情况 如i到e1和到e2之间的都称为乱序
    let s1 = i
    let s2 = i
    const keyToNewIndexMap = new Map()//用于保存新乱序子级中的元素的下标
    for (let i = s2; i <= e2; i++) {
      keyToNewIndexMap.set(newChildren[i].key, i)
    }
    //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
    
    //循环乱序旧子级,看看新子级存不存在该元素,存在就添加到列表中复用,否则删除
    for (let i = s1; i <= e1; i++) {//注意i从s1开始,也是就是乱序的第一个元素开始
      const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
      //查找是否存在该元素,里面保存的是对应元素在newChildren的下标
      let newIndex = keyToNewIndexMap.get(oldchild.key)
      if (newIndex == undefined) {//不存在该元素
        unmount(oldchild)//多余的删掉
      } else {
        patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
      }
    }
 }

以上存在两个问题:
1、只是对相同的节点进行了复用,不存在的节点进行删除;但没有对新乱序子级中的位置进行移动(复用的节点位置还是旧节点原来的位置)
解决思路:新增toBePatched属性保存新乱序子级的个数,进行倒叙插入(找出当前元素的下一个元素节点作为参照物插到其前面,如果没有直接插入最后面即可)

2、新的H也并没有创建挂载(新虚拟DOM中没有保存真实节点,需要创建真实节点),所有要标识出哪些新虚拟DOM需要创建真实节点,但如何标识呢?
解决思路:新增newIndexToOldIndexMap属性,以toBePatched为长度的数组,并设置默认值为0,在上面代码对旧乱序子级循环查找可复用节点中,如果存在就会进行parch的同时并进行标识,可以理解为对patch过的新虚拟DOM进行标识(因为可以复用旧虚拟DOM保存好的真实节点),在进行移动位置时就可以判断,如果还是为0的就是需要创建真实节点的

  const patchKeyedChildren = (oldChildren, newChildren, el) => {
    ....省略上面代码
	//-----乱序比对-----
    //以下要判断无序的可以复用情况 如i到e1和到e2之间的都称为乱序
    let s1 = i
    let s2 = i
    const keyToNewIndexMap = new Map()//用于保存乱序的新子级中的元素的下标
    for (let i = s2; i <= e2; i++) {//注意i是从s2开始
      keyToNewIndexMap.set(newChildren[i].key, i)
    }
    //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]

    //循环乱序旧子级,看看新子级存不存在该元素,存在就添加到列表中复用,否则删除
    const toBePatched = e2 - s2 + 1 //新乱序总个数
    //根据乱序个数创建数组并赋值为0,记录是否比对过映射表
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0)//[0,0,0,0]
    for (let i = s1; i <= e1; i++) {//循环旧乱序子级
      const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
      //查找是否存在该元素,里面保存的是对应元素在newChildren的下标
      let newIndex = keyToNewIndexMap.get(oldchild.key)
      if (newIndex == undefined) {
        unmount(oldchild)//多余的删掉
      } else {
        newIndexToOldIndexMap[newIndex - s2] = i + 1 //标识
        patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
      }

    }//到这只是新旧比对,没有移动位置
    
    //console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
    //console.log(newIndexToOldIndexMap);//[5, 3, 4, 0]

    //需要移动位置
    for (let i = toBePatched - 1; i >= 0; i--) {//倒叙插入
      let index = i + s2 //找到当前元素在newChildren中的下标
      let current = newChildren[index] //找到newChildren最后一个乱序元素
      //找到元素的下个元素作为参照物
      let anchor = index + 1 < newChildren.length ? newChildren[index + 1] : null 
      //current可能是新增的元素没有el,如果没有el
      if (newIndexToOldIndexMap[i] === 0) {
        patch(null, current, el, anchor)//第一个元素传入null,表示要创建元素并根据参照物插入
      } else {
        hostInsert(current.el, el, anchor.el)//存在el直接根据参照物插入
      }
    }
 }

标识例子:
在这里插入图片描述

  1. 进行前后对比完,s1=2,s2=2,i=2,keyToNewIndexMap[{‘E’ : 2}, {‘C’: 3},{ ‘D’ : 4}, {‘H’ : 5}],newIndexToOldIndexMap[0,0,0,0]
  2. for (let i = s1; i <= e1; i++)遍历旧乱序,i为2,oldchild = oldChildren[2],旧节点为C
  3. 从keyToNewIndexMap[C]映射表中查找key是否有C,所以找到返回下标:newIndex=3
  4. 开始标识newIndexToOldIndexMap[newIndex - s2]=i+1;那就是newIndexToOldIndexMap[1]=3
  5. 此时keyToNewIndexMap{e:2,c:3,d:4:h5},newIndexToOldIndexMap[0,3,0,0],两个映射表对比,是不是表明了C被标识了
  6. 为什么要i+1呢?如果第一个元素就已经乱序了,那么i就为0;为0表示没有patch过,这样就无法辨别了,这里为了防止,所有+1
  7. 也表明是newIndexToOldIndexMap映射记录:新C对应的旧C在oldChildren(旧)中的第几个:2+1为3(看图oldChildren中第三个为旧C)
  8. 最后newIndexToOldIndexMap[5,3,4,0]映射keyToNewIndexMap[{‘E’ : 2}, {‘C’: 3},{ ‘D’ : 4}, {‘H’ : 5}],这样我们是不是知道只有H需要创建真实节点了

总体思路(看上面的图解)

  1. 新建一个keyToNewIndexMap映射表为[{‘E’ : 2}, {‘C’: 3},{ ‘D’ : 4}, {‘H’ : 5}],toBePatched为4,newIndexToOldIndexMap为[0,0,0,0],分别对应keyToNewIndexMap的四个
  2. 遍历旧乱序子级,判断哪些旧子级在keyToNewIndexMap映射表存在,存在的话进行patch并表标识,不存在就卸载旧元素,遍历完后newIndexToOldIndexMap为[5,3,4,0]
  3. 进行倒叙插入,从toBePatched-1往前遍历,i=3,i+s2 = 5,这样就找到在newChildren中的下标为5的H虚拟DOM,再取到F作为参照物
  4. 接下来就判断newIndexToOldIndexMap[3]为0,需要创建元素,所有patch第一个参数为null,标识需要创建元素;
  5. 继续遍历,为D,从newChildren中的找到该虚拟DOM,再取到H作为参照物
  6. 判断newIndexToOldIndexMap已经为patch过为4,所以直接插入到H前面即可

或许你们会有一个疑问,在上方我有说“新虚拟DOM中没有保存真实节点,需要创建节点”,为什么现在标识过的子级就不用创建真实节点了呢?
答:
因为进行 patch(oldchild, newChildren[newIndex],el)进行新旧比对的时候,体现复用会把旧的真实节点赋值给新虚拟dom的el中,这样在移动位置的时候,新虚拟DOM的el中就存在真实节点了!

在此diff算法告一段落

但在上面代码对乱序列表中每个元素都进行了倒叙插入(移动位置),其实有一些连续的子级是不需要动的,只需要移动位置发生改变的元素!
所以在diff算法中还有另一个核心,那就是diff算法的最长递增子序列

最长递增子序列举例

在这里插入图片描述
在进行遍历旧乱序子级,新旧CDE进行patch并进行标识,在patch内部新CDE已经取代了旧CDE,只是位置没有发生改变

接下来旧遍历新乱序子级,进行倒叙插入,第一个是H标识为0,创建真实节点并插入到F前面
遍历第二个D和第三个C不用动,得到以下序列
在这里插入图片描述
遍历最后一个E,将E移动到C前面,整个diff算法就完成了

所以在查到最长递增子序列需要用到newIndexToOldIndexMap[5,3,4,0] ,找出最长递增的子序列3和4,在遍历新乱序子级进行倒叙插入时,遇到这两个元素就跳过移动!!!
查到最长递增子序列主要用到算法:贪心算法、二分查找发

想要了解的可以去我下一篇最长递增子序列的讲解,末尾也对此篇手写diff算法进行了收尾!!!
最后感谢您的观看,如有不对的地方请大佬指出,万分感谢!!!

Vue2和Vue3都是流行的前端框架,它们在虚拟DOM的diff算法上有一些区别。下面我会详细介绍一下Vue2和Vue3的diff算法Vue2的diff算法Vue2使用的是经典的双指针算法来进行虚拟DOM的diff过程。大致的步骤如下: 1. 创建新旧虚拟DOM树(VNode),并进行比较。 2. 对新旧虚拟DOM树进行同层级的节点对比,找出差异。 3. 如果两个节点类型不同,则直接替换整个节点及其子节点。 4. 如果两个节点类型相同,则进行更详细的比较。 5. 对于有key的节点,通过key来匹配新旧节点,减少移动节点的操作。 6. 对于没有key的节点,使用遍历的方式进行比较,效率较低。 7. 如果在旧节点集合中找不到匹配的节点,则认为是新增节点,创建并插入到正确的位置。 8. 如果在新节点集合中找不到匹配的节点,则认为是删除节点,从DOM中移除。 Vue2的diff算法存在一些缺点: 1. 每次更新都需要对整个VNode树进行遍历,效率较低。 2. 对于没有key的节点,会使用遍历的方式进行比较,导致性能下降。 3. 当VNode树较大时,diff算法的性能会受到影响。 Vue3的diff算法Vue3采用了一种更高效的diff算法,称为静态标记和提升(Static Markup and Hoisting)。它的主要思想是通过编译阶段的静态分析,将动态节点和静态节点进行标记,从而减少diff的过程。 Vue3的diff算法具体步骤如下: 1. 在编译阶段,通过静态分析将模板中的动态节点和静态节点进行标记。 2. 对于静态节点,会将其提升为常量,并在patch过程中跳过对这些节点的比较。 3. 对于动态节点,会使用类似Vue2的diff算法进行比较和更新。 4. 对于列表渲染(v-for)的情况,会通过唯一的key来进行精确匹配和复用节点。 5. 通过静态标记和提升,减少了不必要的比较和更新操作,提高了diff算法的效率。 Vue3的diff算法相比Vue2有以下优点: 1. 在编译阶段进行静态标记和提升,减少了运行时的工作量。 2. 可以更精确地识别出动态节点和静态节点,减少不必要的比较和更新操作。 3. 对于列表渲染,通过唯一的key进行精确匹配和复用节点,提高了性能。 总结: Vue2和Vue3的diff算法都是基于虚拟DOM的思想,但Vue3引入了静态标记和提升的概念,通过编译阶段的静态分析来优化diff过程,提高了性能。在实际开发中,如果需要更高的性能,推荐使用Vue3。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值