Vue3核心--页面更新时的diff算法

文章详细解析了Vue3中用于提高渲染效率的Diff算法,包括最侧边对比、中间对比以及利用最长递增子序列确定元素移动的策略,旨在减少DOM操作,优化性能。通过对新旧虚拟DOM节点的比较,确定更新范围,并通过插入、删除和移动节点来实现高效更新。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文针对于diff算法的实现着重解析,其它的相关逻辑仅作简单介绍。

vue3在dom节点发生更新的时候会触发已经收集的依赖,从而进行对新旧虚拟dom是否产生变化做进一步处理,尽可能的减少页面渲染时的重绘和重以增加渲染速度,而实现这一步的则是整个vue中最为关键的diff算法。

而整个算法分为两种处理逻辑共三个步骤,第一步就是处理最侧边对比,比如老节点ABCD和新节点AB这种右侧更新的,或者老节点ABCD和新节点CD这种左侧发生更新的。第二种处理时中间对比,比如A B C -E H D- F G 和 A B C -D E- F G这种中间发生更新的,这种处理分两个步骤处理,先是建立将要更新的节点中的key和对应位置index的映射关系,然后然后从老节点中遍历查看是否能通过key值取出对应数值,如果没取到则对其进行删除,所以第二步只做一个中间处理的删除逻辑。第三步则是通过一个求*最长递增子序列* 的得出的值来进行更新区域的位置调换,和新节点的插入,也是整个算法最核心的地方,甚至有面试题会问到这里。

**由于过程比较复杂,所有解释都会在对应的代码上方用注释标注**

首先,当dom发生改变的时候,以下patchChildren这个函数作为一个接口一定会被调用,这里的传入的c1,c2分别是发生改变位置所在的所有-新旧-子节点,模板里的标签在框架内部产生的虚拟dom,同级在数组里排开,嵌套标签则也会作为一个虚拟dom放在父级虚拟dom内的children中,而container则是传入他的父容器,第四个则是锚点,用于标记真实dom插入位置,后面尤其关键。

function patchChildren(c1, c2, container, anchor) {
//声明一个变量所谓while循环的依据
    let i = 0
//e1,e2则分别代表传入的新旧子节点的数组长度
    let e1 = c1.length - 1
    let e2 = c2.length - 1

//此时这一个循环的目的,是为了排除左侧对应位置相同的虚拟dom
    while (i <= e1 && i <= e2) {
//n1,n2则是提取出每次循环新旧节点数组中其对应下标的虚拟dom做对比
      const n1 = c1[i]
      const n2 = c2[i]
//这里则是把判断条件封装成了一个函数,而判断相同的依据则是n1,n2中的type是否相同,也就是他们在真实dom中的标签名,比如div标签的虚拟dom中的type就是div,而第二个判断依据则是用户在vfor循环中传入的key值是否相同。从这就可以知道为什么input输入内容之后更新dom,其关联的元素发生位置改变时input却不会一同改变,那是因为key值都为undefined时,值对比其中的type属性,也就是标签名,所以没有发生改变
      if (isIdentical(n1, n2)) {
//patch这里仅简单说明.像在vue2脚手架中,在main.js文件new Vue的实例中会有一个render:(h)=>{}的函数,就是render函数拿到h创建完的虚拟dom之后,会在内部初始化的时候调用render函数.而vue3中则是在mount函数之后创建虚拟dom,然后传入并调用render函数,render函数在声明周期里只执行一次,而render调用之后做的唯一一件事也就是调用patch,所以这里patch也就是单独抽离出来用于递归调用的入口函数.
//所以说这里patch就是递归调用处理子节点
        patch(n1, n2, container, parentComponent, anchor)
      } else {
//一旦发现n1,n2不相同,就确定了第一个发生改变的元素下标,所以就跳出循环(后面会画图)
        break
      }
      i++
    }
//这里则是对右侧做一个范围缩减,此时i标识的是第一个发生变化的地方.所以这里就跟上面逻辑相似,通过反向缩小长度来缩小距离也就是让新旧节点的长度递减,之所以让i同时满足e1和e2是因为e1,e2长度可能会因为发生删减而不相同,此时就可以根据它们来判断是需要删除还是添加
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = c2[e2]

      if (isIdentical(n1, n2)) {
        patch(n1, n2, container, parentComponent, anchor)
      } else {
//这里也是倒叙循环,一旦n1,n2不相同就是确定了右侧不同的第一个位置,这样就确定了产生变化部分的最小范围
        break
      }

      e1--
      e2--
    }
}

function isIdentical(n1, n2) {
   return n1.type === n2.type && n1.key === n2.key
}

假如有以下两个数组,做完以上的双端对比之后,确认了更新范围在于CD之间

然后根据以上拿到的i=2,和e1=1,e2=3可以做出最简单的第一步处理,接着上面函数的39行接着往下写

if (i > e1) {
//此时根据已有的条件可以进入到这里
      if (i <= e2) {
//这里是因为想要指定插入真实dom只能用insertBefore来插入dom,所以这里的currPos则是要插入到以currPos为下标元素的前面,也就是往后移一位.此时因为虚拟dom中会有一个el属性保存着挂载之后的真实dom,所以取到赋值给anchor之后可以作为参照点(锚点)添加到前面.但如果是末尾则会因为后面不会再有东西而取到undefined报错,所以可以把他赋值为null,这样insertBefore也会默认插入到队尾
        const currPos = e2 + 1
        const anchor = currPos < l2 ? c2[currPos].el : null

        while (i <= e2) {
//这里的patch第一个参数为null则表明他是新节点,所以在patch接口内部走的是挂载(mountElement),此时新添加的C和D就会被渲染在页面上
          patch(null, c2[i], container, parentComponent, anchor)
          i++
        }
      }
//同时如果反过来,让e1=3为[A,B,C,D],e2=1为[C,D],此时左侧对比第一次就会break且值为0,右侧的e1:3,e2:1执行两次之后break,期间e1和e2自减了两次,此时e1=1,e2=-1,此时根据条件e2 < i < e1可以写出满足以下条件就要删除
    } else if (i > e2) {
      while (i <= e1) {
//这里则是调用以下函数
        hostRemove(c1[i].el)
        i++
      }
    }
//通过操作dom来拿到其父节点,再从其父节点删除
function hostRmove(child) {
  const parent = child.parentNode
  if (parent) {
    parent.removeChild(child)
  }

下一步要解决的是当更新范围内的顺序不同时,这里还是接着上面的函数接着往下写,都是在最外层的函数patchChildren的内部

//这里是把上面双端对比后的范围起始下标保存下来,并同时声明为s1,s2为增加代码可读性
      let s1 = i
      let s2 = i
//这里是一个计数器的最大范围,因为有这么一种情况,比如当旧节点数组长度为7,新节点数组长度为3,但是只执行了5次,就把新节点的3个元素给筛选出来了,所以后面循环的一定是新节点里没有的,于是就可以删除并直接跳出循环
      const toPatched = e2 - s2
//这一个则是上面最大值的计数器
      let hasPatched = 0
//要先创建一个Map对象用于存储key值和下表之间的对应关系,由此可以推断出是否存在或者位置所在
      const currKeyMap = new Map()
//这里则会先对Map对象进行初始化,把所有用户写入的key值和对应下标存储起来(后面会做没传key的处理)
      for (let i = s1; i <= e1; i++) {
        const currChild = c2[i]
        currKeyMap.set(currChild.key, i)
      }
//这是这里用于下面在for循环内部通过key在Map里取出的下标
      let currIndex
//回顾一下,e2是更新后的虚拟dom数组的最大下标,而s2则是左侧第一个更新处的下标
      for (let i = s2; i <= e2; i++) {
//因为新节点的key值已经都存好了,现在要通过老节点的key做对比就要先拿到老节点,然后再获取其虚拟dom中的key值
        const prevChild = c1[i]
//这里则是上面声明的计数器,如果已经上足够的数量则直接删除并跳出,需要结合后面理解
        if (hasPatched > toPatched) {
          hostRemove(prevChild.el)
          continue
        }
//如果老节点的身上没有key值,就是用户没传,则通过遍历更新范围,将循环变量作为下标进行对比
        if (prevChild.key != null) {
//取出老节点对应的key值,如果没有取到则证明在新节点中不存在,应该删除
          newIndex = currKeyMap.get(prevChild.key)
        } else {
          for (let j = s2; j <= s2; j++) {
//没有key值则是每对比一个值的变化都需要将新节点数组遍历一遍, 很影响性能,如果对比上了则拿到对应下标然后退出变量j所在的循环
            if (isSomeVNodeType(prevChild, c2[j])) {
              newIndex = j
              break
            }
          }
        }

        if (newIndex === undefined) {
          hostRemove(prevChild.el)
        } else {
          newIndexContrastOldIndex[newIndex - s2] = i + 1
//如果newIndex有值则是在新节点中存在,只是可能位置变了.不过这里只还是递归进行对比,因为此时prevChild应该和c2[newIndex]是key值对应的关系
          patch(prevChild, c2[newIndex], container, parentComponent, null)
//每配对成功一项计数器自增一次
          hasPatched++
        }

此时删除原理完成之后该进行第三步的移动和添加节点了,最关键的是要知道最长递增子序列的概念,用以下几个例子来概括一下

const arr = [4,5,3,7,2,9,6,5,3]
// 4, 5, 7, 9,
// 3, 7, 9,
// 2, 9

以上这个数组的递增子序列,去除掉像5, 7, 9这种被4, 5, 7, 9所包含的数列,剩下的只有以上三种,

他所存在的特征就是从左往右找,可以不相邻,但是一定要右边的数比左边的大,一旦后者再也没有大于前者的值,则子序列的最后一位就是这个前者。就像上面的9后面没有比他大的了,则他就是数列的最后一位。而我们只需要长度最长的那一组,然后再找出其数列里每一位在数组中对应的下标,返回的值就是diff算法所需要的依据。那这样已知数组和最长序列,那其对应下标的最长序列就为[0,1,3,5],这个算法可以通过以下函数来验证。

function getSequence(arr) {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

这里对递增数列的用处做一个说明,主要是确认出来的递增数列位置可以是固定的,就如同数组方法的sort排序从小到大,就是对比左右相邻的大小来决定是否换位置,如果是递增关系位置不会改变。也就是说在子序列中存在的元素位置不用改变,只需要移动乱序的部分,减少对dom的操作。

有了一个对比的关键之后第三步的移动和添加就可以实现了,但是须有对第二步骤删除逻辑部分的代码做修改,所以我就复制一份然后用符号标注出新增的代码

      let s1 = i
      let s2 = i
      const toPatched = e2 - s2
      let hasPatched = 0

      const currKeyMap = new Map()
***************
//这一步是先创建一个用于获取最长递增子数列的数组,然后因为需要对比部分的长度为toPatched,所以就把他传进去创建一个定宽数组,因为这样有助于性能的优化
      const newIndexContrastOldIndex = new Array(toPatched)
//然后对其初始化为0,因为0需要作为一个不存在的状态,也就是当取出对应下标的值为0时,需要添加该节点
      for(let i = 0; i < toPatched; i++){ newIndexContrastOldIndex[i] = 0 }
***************

      for (let i = s1; i <= e1; i++) {
        const currChild = c2[i]
        currKeyMap.set(currChild.key, i)
      }
      let currIndex
 for (let i = s2; i <= e2; i++) {
          const prevChild = c1[i]
          if (hasPatched > toPatched) {
            hostRemove(prevChild.el)
            continue
          }
          if (prevChild.key != null) {
            newIndex = currKeyMap.get(prevChild.key)
          } else {
            for (let j = s2; j <= s2; j++) {
              if (isSomeVNodeType(prevChild, c2[j])) {
                newIndex = j
                break
              }
            }
          }

          if (newIndex === undefined) {
            hostRemove(prevChild.el)
          } else {
***************
//newIndex是在整个新节点数组中的下标,所以要从下标0赋值就减去第一步左侧对比没变化的那一部分也就是最上方的s2,此时(newIndex - s2)的范围就是0到toPatched,i + 1则是已经说明0因为作为状态所以要被空出来
            newIndexContrastOldIndex[newIndex - s2] = i + 1
***************
            patch(prevChild, c2[newIndex], container, parentComponent, null)
            hasPatched++
          }  
   }//对应19行for循环的括号
--------以下都是新代码--------
//然后对创建好的数组进行处理,返回一个 最长递增数列 , 上面自然段有说此数列在算法中的作用所在
    const increasingSequence = getSequence(newIndexContrastOldIndex)
//这里是必须要使用一个倒叙,因为insertBefore是从插入到某一个元素前面,如果是正序的话,左侧元素排序的时候插入的右侧元素前面,但此时右侧元素的位置也还未确定,所以调换出来的顺序很可能会乱,所以需要倒叙遍历.那么最长数列的也一样需要到过来对比,所以声明了一个变量j
          let j = increasingSequence.length - 1
//这里i的范围就是所需更新区域最靠右的下标,
            for (let i = toPatched - 1; i >= 0; i--) {
//这里j<0是一个优化点,因为j<0时increasingSequence也就取不到东西了,可以直接终止循环
              if (j < 0 || increasingSequence[j] !== i) {
//这里同第一步的单侧对比一样,获取一个下标是当前需要添加子节点下标+1的元素用于做参考系用insertBefore插入,而currIndex则是因为i循环的最大值是先前做的一步toPatched - s2,所以要找到整个更新数组中的准确节点要再把s2加上
                const currIndex = i + s2
                const currChild = c2[currIndex]
//如果insertBefore传入的是null则插入到队尾
                const anchor = currIndex + 1 <= l2 ? c2[currIndex + 1].el : null
//这里是在41行没有赋值到的就是0,是因为newIndex没有获取到对应key值的下标,也就是新节点在老节点中不存在,所以添加
                if (increasingSequence[i] === 0) {
//patch第一步双端对比中说过,如果上一个值为null就代表不存在所以会创建一个出来
                  patch(null, currChild, container, parentComponent, anchor)
                } else {
//如果存在则是需要调换他的位置,因为54行的条件没过
                  hostInsert(container, currChild.el, anchor)
                }
              } else {
//因为是倒叙,如果对比上了就往下一位
                j--
              }
            }
          }
//这里传入的第一个形参是最开始传入的发生更新的所在容器,一开始有提到过,el则是64行新节点创建好的虚拟dom对象中保存的真实dom,此时只是还未挂载到页面上
        function hostInsert(parent, el, anchor){
            container.insertBefore(el, anchor || null)
        }

以上就是vue3中diff算法的实现,如果单看算法的话其实就有两个最关键的点,一个是双端对比确认更新的最小范围,再一个就是通过最长递增数列确定不用需要改变的元素减少dom操作,如果读懂了以上代码之后,其实还有比较显而易见的优化点,因为书面表达比较费劲,就没有可以标注上。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值