vue3-快速diff-严格最长递增子序列-个人记录

前言

        看完了《vue.js设计与实现》中关于快速diff介绍和《vue技术内幕》中关于最长递增子序列算法的内容,在此做一个梳理。

为什么用快速diff

《vue.js设计与实现》:快速diff算法,最早应用于ivi和inferno两个框架,vue3借鉴并扩展了它。

《vue.js设计与实现》书中还用一张图说明了快速diff比vue2的双端diff更快。

        总之,快速diff更快,所以用。

快速diff处理逻辑

        分为五种场景,“前前对比”、“后后对比”、“旧的没剩余”、“新的没剩余”、“都有剩余”,其中“前前对比”和“后后对比”又称为“预处理”。

预处理:前前比较和后后比较

        先从两数组的头部开始往后比较,判断两虚拟节点是否相似,如果相似就patch后再对比下一组,如果不相同则开始“后后对比” 。

        “后后对比”则是两数组最后的虚拟节点对比(也可称为尾部),如果判定是相似虚拟节点就patch后向前对比下一组。如果判定不相似,预处理就结束了。    

       接下来思考代码实现,先看图-1。

 【图1-预处理逻辑图。 来自《vue.js设计与实现》】

  假设有以下虚拟节点数组,

新虚拟节点数组newChildren:  [ p-1 , p-4 , p-2 , p-3 ]

旧虚拟节点数组oldChildren:      [ p-1 , p-2 , p-3 ]

        前面讲过,先“前前对比”,那定义个变量j作为索引,用来指定两数组的第一个(即头部),利用这个索引 j 就能拿到两个虚拟节点,判定两个虚拟节点(如果虚拟节点的type和key相同就认为相似。type为标签名。key是看做虚拟节点的id,也就是v-for中指定的key,如果不指定key,不会进入diff算法核心)是否相似。

        在上例中,p-1和p-1是相似的,就让两虚拟节点patch(也叫“打补丁”,标签文本、属性等如果有变动就用新的,只用改变真实节点的属性,不用创建真实节点),然后j++,开始下一轮对比(利用while循环开启那一轮对比)。

        由于“p-4”和“p-2”不相似,所以“前前对比”结束,开始“后后对比”。

        定义两个变量newEndIndex和oldEndIndex作为指向数组尾部元素的索引,那现在newEndIndex就指向新虚拟节的最后一个,即“p-3”;oldEndIndex指向旧虚拟节点数组的最后一个,也是“p-3”。显而易见这个两个虚拟节点是相似的,那就patch这两个虚拟节点,然后newEndIndex和oldEndIndex需要减1(也就是从后往前),进行下一轮对比。

        拿到此时newEndIndex和oldEndIndex指向的虚拟节点,“p-2”和“p-2”,判定相似,剩余操作如上一段,又可以开始下轮对比了。

        此时“p-4”和“p-1”对比不相似,“后后对比”结束,预处理也结束了。

暂停分析

        在做其他对比之前分析下当前的局面。以下是当下的各个变量值。

变量
j1
newChildren[ j ]p-4
oldChildren[ j ]p-2
newEndIndex      (  即图-2中的newEnd )1
oldEndIndex0
newChildren[ newEndIndex ]p-4
oldChildren[ oldEndIndex  ]p-1

 【图2-两轮“后后对比”后。 来自《vue.js设计与实现》】

        明显旧虚拟节点数组中的虚拟节点都已经对比过了,总结下就知道diff时秉持“一个旧虚拟节点最多对应一个新虚拟节点,旧虚拟节点只对比一次”的原则。所以说,旧虚拟节点都用完了,新虚拟节点还有剩的,那就要走“旧的没剩余”判断。总结下形成第三场景的条件(也就是图-2的局面):

j <= newEndIndex && j > oldEndIndex

旧的没剩余

        “旧的没剩余”证明新的虚拟节点有多的,这些新的虚拟节点一定是要挂载到真实dom上去的,接下来就是要确定一个锚点元素用来挂载,最好的锚点元素就是最近处理的新虚拟节点(因为在每次patch虚拟节点(其实除了删除节点,都需要为新虚拟节点设置对应的真实节点)后都会将真实节点赋值给处理时的新虚拟节点的elm,所以用这个新虚拟节点对应的真实节点elm作为锚点元素是最准、最保险的)。那最近处理的新虚拟节点的下标是多少?从执行的顺序推论出答案是newEndIndex+1。

      确定锚点元素后,用while去遍历剩余的未处理的新虚拟节点,创建真实节点并挂载到锚点元素之前,直到把新虚拟节点也处理完。也就是用“p-4”创建真实节点,然后挂载到“p-2”这个虚拟节点对应的真实节点之前。

        至此,两组diff完了。

新的没剩余

        再看下另一对经过预处理后的两组虚拟节点,见图-3.

新虚拟节点数组newChildren:  [ p-1 ,  p-3 ]

旧虚拟节点数组oldChildren:      [ p-1 , p-2 , p-3 ]

【图3-预处理后。 来自《vue.js设计与实现》】

        从图-3可以看出,新虚拟节点处理完毕,而旧虚拟节点还剩下“p-2”未处理,对于剩下的旧虚拟节点显而易见需要移除其对应的真实节点。

        总结下进入“新的没剩余”的条件:

j <= oldEndIndex && j > newEndIndex 

都有剩余

        下面是两组虚拟节点与处理后的结果图。

【图-4   来自《vue.js设计与实现》】

        此时不满足“旧的没剩余”或者“新的没剩余”条件,那就是“都有剩余”,那又应该怎样处理呢?

        我们应该先从容易的动手,那就是先删除后续不会用到的旧虚拟节点,比如上图的“p-6”,具体怎样锁定“p-6”并删除呢?

        可以先构建一个新虚拟节点的key与其下标的映射keyIndex_ContainerObj(就是个对象),然后遍历未处理的旧虚拟节点数组,使其每一项访问前面映射的key得出其value,如果value是undefined就证明该虚拟节点在新虚拟节点数组中没有出现,需要删除。操作如图-5

 【图-5】

        从上图可知“p-6”对应的值是undefined,所以需要删除"p-6",删除完毕后(旧虚拟节点有自己的真实节点,直接removeChild)就只剩下可能的“移动”和“创建”两种操作。

        此时我们思考下后续是否需要移动节点,观察图-5中红框的数字为【3,1,2】,如果现在顺序是【1,2,3】就代表旧虚拟节点和新虚拟节点是一一对应的,想当然就知道是不用移动节点的。但是现在顺序不是这样,很明显,需要将3对应的旧虚拟节点(具体是移动旧虚拟节点对应的真实节点)移到2对应的旧虚拟节点后面就满足【1,2,3】的顺序了。

        所以在代码层面,我们可以定义个两个变量moved(布尔,表示是否需要移动)和pos(初始0,记录遍历时遇到的最大值),然后遍历结果数组【3,1,2,undefined】,只要遍历过程中当前值k小于pos就说明数组不呈递增趋势,证明顺序有错位,需要移动,相应的moved设为true。

        上一段只是解决了判断是否需要移动,而现在还需要解决如何确定具体的移动节点。

        答案是利用“最长递增算法”求出最长递增序列,不满足最长递增子序列的节点就是需要移动的节点。

        比如说现在的顺序【3,1,2】,求解的最长递增子序列是【1,2】(注意:这里最长递增子序列结果的数字代表源数组的下标),所以知道需要移动的节点是3对应的那个节点,也就是“p-2”。

        我们现在确实找到了需要移动的节点,但是前面求解的是旧虚拟节点在新虚拟节点数组中的位置的数组的最长递增子序列,只能用这个子序列去和旧虚拟节点数组去对比。但是对比旧虚拟节点数组是不准的(最准顺序在已经处理完的新虚拟节点上),后续挂载也会有问题,那有什么补救办法呢?

        既然用“旧虚拟节点在新虚拟节点中的位置的数组”的最长子序列去对比有问题,那我们就用“新虚拟节点在旧虚拟节点位置的数组”的最长递增子序列去对比新虚拟节点数组。我们可以得到下图-6

 【图-6】

        对于新虚拟节点没能在旧虚拟节点数组中找到对应的旧虚拟节点,我们用-1填充(-1就正好代表该新虚拟节点是需要新增的),最后就能得到数组【2,3,1,-1】,用这个数组求最长递增子序列,结果是【0,1】,同样能证明值1对应的节点“p-2”是需要移动的。

        我们再从后往前遍历剩余的未处理的新虚拟节点数组,遍历过程中的下标等于-1就是新增,下标不在最长递增子序列中就代表要移动,下标在子序列中就不需要移动,直接patch。

        对于需要移动的虚拟节点,最重要就是确定锚点元素,由于我们是从后往前遍历新虚拟节点数组,那锚点元素就是遍历过程中上一次处理的新虚拟节点的真实节点,就是当前节点的上一个节点对应的真实节点(写的好啰嗦。。。),遍历完也就diff完了。

优化

       图-5中的映射结果数组和图-6中的映射其实都可以不用真实创建出来,因为我们对于图-5的映射结果数组只是用到了其值中的undefined,还有各个值在那比大小而已,图-6的映射也只是用来构建映射访问结果数组最后用于求最长递增子序列的。

        我们可以遍历新虚拟节点数组时构建索引表,遍历旧虚拟节点数组时用个变量不断去存储映射访问结果数组的值k,用这个k值去比大小判断是否需要移动。

        而图-6中的映射访问结果数组,则在遍历旧虚拟节点数组时,用旧虚拟节点的key去访问图-5中的映射其值为k,找不到为undefined时,证明该旧虚拟节点在新虚拟节点数组中不存在,需要移除这个旧虚拟节点;不为undefined就将用前面的值k作为source(source先用-1填充)的下标,遍历旧虚拟节点数组时的当前下标y为值,最终构建出图-6的新虚拟节点数组访问映射的结果(新虚拟节点在旧虚拟节点数组中的位置)。代码如下:

 // 遍历新剩余虚拟节点数组
    for (let x = newStartIndex; x <= newEndIndex; x++) {
      // 先在剩余新虚拟节点数组中,保存各个虚拟节点对应的key和下标x
      keyIndex_ContainerObj[newChildren[x].key] = x

    }
    // 遍历旧剩余虚拟节点数组
    for (let y = newStartIndex; y <= oldEndIndex; y++) {
      const oldCurVnode = oldChildren[y]
      const k = keyIndex_ContainerObj[oldCurVnode.key]
      if (k !== undefined) {
        // 证明能找到对应的旧虚拟节点,不用卸载节点
        source[k - newStartIndex] = y
        // 判断节点是否需要移动
        if (k < pos) {
          moved = true
        } else {
          pos = k
        }

      } 

严格最长递增子序列

        啥是严格递增子序列,假定有数组【3,1,4,6】,以数组每一项为开头,在数组中从前往后对比,前一项要比后一项小(即呈递增趋势),相等也不行,把找到的数拿出来构建的数组叫递增子序列,而“最长递增子序列”则是这些子序列中最长的那个。所以可以构建出图-7。

        【图-7】

         所以数组的最长递增子序列可以是【3,4,6】或者【1,4,6】。

        看到这里,不难得出以下结论。

  1. 当数组的长度为1时,其最长递增子序列也是1。
  2. 数组不论长度是多少,其最小的“最长递增子序列”也可能是1,即一个递减的数组【3,2,1】

        那我们可以先假定每一项开头的子序列都是1,即图-8

        开始比较,用两个for循环从3开始往后开始比较?轻轻松松时间复杂度来到O(n^2) ,这样效率不高,其实从6开始往后比较是更好的。

        由于6的最长递增子序列长度一定是1,所以直接从4开始比较。

        4和6比较,后值6大于当前值4,并且后值6的最长递增子序列的长度+1后大于当前值4的最长递增子序列的长度,所以需要1+1为2填在绿色格子里,结果如下:

         接下来就以1为当前值开始比较。后值4比当前值1大,4的最长递增子序列长度+1后的结果也是比当前值1的,也就是2+1后填在黄色格子里。第一次比较完成,进入下一次比较。后值为6比当前值1大,但是6的最长递增子序列长度+1(1+1=2)后不大于当前值的,所以不进行填写,此轮比较完成结果如下:

 

         下一个当前值就是3,同前面两步一样,往后比较。由于后值1不大于当前值3,那啥也不做,开启下一个后值做比较,最终的结果如下图:

         至此,把每一项开头的最长递增子序列的长度是搞出来了,但是用于d快速iff是需要输出子序列对应的下标。下面开始找最长递增子序列对应的下标。

        因为数组最后一项的最长递增子序列的长度一定是1,所以我们可以从后往前遍历,找2,之后再找3,以此类推,直到遍历完。如果当前项的最长递增子序列满足我们要找的,就把这一项存在数组里面,最后输出数组就行了。

        最后附上代码:

// const seq = [0, 8, 4, 12, 2, 10]
const seq = [2, 2, 5, 4, 5]
function lis(seq) {
	const valueToMax = {}
	let len = seq.length
	// 先构建格子,1填充
	for (let i = 0; i < len; i++) {
		valueToMax[seq[i]] = 1
	}

	let i = len - 1 //最后
	let last = seq[i]
	let prev = seq[i - 1] // prev才是当前比较基值。 跳过最后一个,直接从倒数第二个开始比较,因为最后一个一定是1

	while (typeof prev !== 'undefined') {
		// 从后往前,两两比较
		let j = i // 后值的下标
		while (j < len) {
			last = seq[j]
			if (prev < last) {
				// 当前值比后面的值小
				const currentMax = valueToMax[last] + 1 // 记录此时比较下的最大值,即后面值的最长子序列长度+1
				if (currentMax > valueToMax[prev]) {
					/*
						比较两个值的最长子序列长度,
						如果 “后面值的子序列长度+1” 后比 当前值的子序列 长,
						那么当前子序列长度重新赋值,取大的。
					*/
					valueToMax[prev] = currentMax
				}
				// valueToMax[prev] =valueToMax[prev] !== 1 ? valueToMax[prev] > currentMax ? valueToMax[prev] : currentMax : currentMax
			}
			j++
		}
		i--
		last = seq[i]
		prev = seq[i - 1]
	}

	const lis = []
	i = 1
	while (--len >= 0) {
		// 从后往前找,1 -> 2 -> 3...
		const n = seq[len]
		if (valueToMax[n] === i) {
			i++
			lis.unshift(len)
		}
	}

	return lis
}

console.log(lis(seq)) //得到的序列中的位置索引

快速diff的总结

  1. 先预处理,就是先“前前对比”,然后“后后对比”
  2. 预处理完成,如果此时新虚拟节点没剩余,而还剩下没有处理旧虚拟节点,就删除旧虚拟节点对应的真实节点;如果旧虚拟节点都对比完了,新的还有,那就新增这些新虚拟节点。
  3. 如果新旧虚拟节点都有剩余,把剩余未处理的新旧虚拟节点逻辑抽离为两个新数组,构建一个索引表中存放新虚拟节点的key及其下标,用旧虚拟机节点的key去访问索引表能得到一个旧虚拟节点在新虚拟节点数组中位置的数组,这个数组如果不是严格递增的就需要移动节点,并且数组中如果有undefined项,就需要移除undefined对应的真实节点。
  4. 最后还需要构建新虚拟节点在旧虚拟节点数组位置的数组,新旧虚拟节点没有对应的话就用-1填充,然后求这个数组的最长递增子序列。再从后往前遍历剩余的新虚拟节点数组,遇到-1就代表是需要新增虚拟节点,如果当前虚拟节点的下标不在最长递增子序列中,就证明该节点是需要移动的,否则不移动。至此快速diff结束

个人总结

        能力和理解问题,文章写得啰嗦,等对diff有了新的认识再来完善。

git地址

vue3的快速diff和最长递增子序列: 博客中: vue3的快速diff和最长递增子序列 (gitee.com)

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值