学透 Vue3 重头戏之 diff 算法

e47f5e86ff390592b32a2e8c5817c07f.png

微信图片_20210407172754.jpg

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

前言

终于迎来了DOM diff流程的重头戏:diff算法,前面的流程只能算是附加项,重要的是各种节点是如何进行对比,然后进行更新。下面就对每一种节点的对比流程进行分析。

f2fd31da458770528b77eb00af3e186e.png
image.png

在vue3.2 初始化的时候做了什么?[1]文章的的末尾,提到了传入effect的回调函数和响应式数据之前产生一个依赖关系,等同于产生了一个watcher。当数据发生变化的时候,会以参数二的方法执行参数一,具体细节和调度器有关,以后再说,最终会进入componentUpdateFn函数中,我们就直接进入到更新阶段的componentUpdateFn

patch之前的处理

c3db8bf3bf3e2bd4c578734115d5b022.png
image.png

在开始执行patch函数之前,会先执行一些生命周期钩子函数,有beforeUpdateVNodehook:beforeUpdate

281f91ebdc3686a87c44b3aea8825b43.png
image.png
bf2dd68139d1add596350c475a69d143.png
image.png

最主要的一点,如果是父组件数据变化而导致的子组件更新,会多执行一个东西,里面会进行更新propsslots以及换成新的VNode,做完这些之后可能会导致更新,需要在patch之前把它们执行。(PS:更新propsslots流程可以看看我前面的文章《Vue3.2 vDOM diff 流程之一:插槽的初始化和更新》[2]和《Vue3.2 vDOM diff流程分析之一:props和attrs的初始化和更新》[3])

e97634377c831ec3f63b39d974413a31.png
image.png

做完这一些,就可以产生新的VNode,将新旧VNode传入patch开始进行对比,SuspenseTeleportdiff已经在前面的文章中说明,这里就不在提及。

对比元素类型节点

820dc2119a15259d81460ff2cd8bde24.png
image.png
bd9a773b0337b96a5e5b8a0e7986dc17.png
image.png

对比元素进入processElement,这次是进入更新流程,执行patchElement。n1是旧VNode,n2是新VNode

d25b1902ef3ecfe788cb54f74793baf1.png
image.png

函数开头,需要重新通过旧节点的patchFlag重新确认新节点patchFlag,因为用户可以克隆由complie产生的VNode,或许可能添加一些新的props,比如cloneVNode(vnode, {class: 'cloneVNode'}),它将选择FULL_PROPS

c3a6bcaed7327471c1b161e239252562.png
image.png

紧接着执行新的VNode自定义指令的beforeUpdate生命周期函数,如果在dev模式下且HMR正在更新,则放弃优化且把dynamicChildren清空,使用全量diff。这会影响后面diff,但是prod模式下一般都是优化模式,使用areChildrenSVG是判断新VNode是不是SVG。

d327842418297e0f1558f4b1577a3352.png
image.png

这里分为优化模式和非优化模式,这里进入优化模式的条件是dynamicChildren不为空,非优化模式是optimizedtrue,但是这两个是互斥的,一个存在另一个肯定不存在。

优化模式下进行diff
04e1825ea21800b2c34197c68fb1aa0d.png
image.png

进入到这个函数,他会遍历新VNodedynamicChildren,并从旧的VNodedynamicChildren取出按索引顺序一致的节点进行对比。

在这之前,先要找到parent node,也就这一大坨的三元运算符,不要慌张,逐个逐个条件分析,oldVNode.el是为了在异步组件的情况下确保元素节点的真实DOM要存在。

oldVNode.el存在的情况下,并且符合以下三个条件中的其中一个:1. oldVNode的节点类型是Fragment、2.oldVNodenewVNode不是同一种元素(key值不一样也算)、3.oldVNode是组件,就组件而言,它可以包含任何东西。container就是oldVNode.elparent。不然在其他的情况下,实际上没有父容器,因此传递一个block元素,避免parentNode,就是传递fallbackContainer(是n2的真实DOM),

确认好container就和oldVNodenewVNode再次传递给patch,接下来就要根据newVNode的节点类型从而确定走哪个分支进行diff

c2275182bfd996a3bfa6c2297e79c74b.png
image.png

diff流程结束之后还需要做一件事,在dev模式下,如果parentComponent存在并且parentComponent启用 HMR,需要递归寻找或者是定位旧的el 以便在更新节点进行引用 防止更新阶段会抛出el is null。优化模式分析完毕。

非优化模式下进行全量diff
142305f1a80c84d475f9a7fd2d8a6fd8.png
image.png

非优化模式下交给patchChildren处理,在diff之前先要拿到一些东西:n1、n2的Children和n2的shapeFlag。接下来的流程分为很多种情况,一一分析。

快速diff

首先根据n2的patchFlag判断能不能快速更新,也就是“靶向更新”,进入之后又分为两种情况,是否键控(是否绑定了key),键控可以是完全键控也可以是混合键控(一部分带key,一部分不带key),分别交给patchKeyedChildrenpatchUnKeyedChildren处理。

不带有key的对比
e5c3fd78442ab587f303218eb47230fa.png
image.png

由于带有key的对比有点复杂,我放的后面说,这里先看没有带key。没有带key的对比简单粗暴,因为不确保n1和n2都有children列表,没有就默认给一个空数组。需要注意这里获取长度,从新旧children列表两个列表长度中取出长度的最小的作为基准,接下来的对比最多只会对比到这个位置。具体用图解释。

33f2bde7e52be7c833cd8431dc35405f.png
image.png

如图所示,旧children列表长度是5,新children列表长度是3,取小的也就是3,代表在循环一对一对比中只会对比前三个,剩下会交给下面的流程。

acf3f3a7af3bfc8254b1ec6fff229ddc.png
image.png

剩下流程分为两种情况,在循环对比后,如果是新children列表比旧children列表长度长说明有新节点,就会去挂载新节点,反之说明有不需要的旧节点,就会去卸载。流程结束。

key的对比

回到patchChildren中,我们看带有key是如何对比,将会结合图一步步分析。

eaac16ad1276d60095bcb92db6d2029a.png
image.png

这里先拿到一些东西,l2是新children列表的长度,e1是旧children列表中最后一位的索引,e2是新children列表最后一位的索引。i这里有特殊意义,代表对比的开始索引。带有key的对比主要有五个流程,

假如有如下新旧children列表,可以准确看出只有2移动了位置,下面就看经过五个流程是如何进行对比的。

c24ba22cfaa46a5bac04bb6d6e7e9216.png
image.png

1.流程一:对比开始位置

c69c877591ae88a30770744a1e78e911.png
image.png

在这一阶段会遍历新旧children列表,只有新旧节点是用一种元素才会交给patch函数对比,每过一对新旧子节点,i就会加一,如果有一方遍历到最后一个就会结束或者是遍历到两个是不同元素。例子中,前面没有相同的节点,所以不会有任何操作

08e6c134f8f691875fed71f63a0e4289.png
image.png

2.流程二:对比末尾位置

2ab12fce1869a372e620f848fa7ee54b.png
image.png

在这一阶段一样会遍历新旧children列表,和阶段一一样,新旧节点是同一种元素才会交给patch函数对比,不同的是从末尾开始对比子节点,每过一对子节点,新旧最大位置索引同时会减一。例子中,从末尾的3、4、5是相同元素可以排除。

e8a47aff37ddff98d850e11fc5f16f78.png
image.png

走完前面的两个,说明新旧children列表中首尾的相同节点已经被处理了,就剩下中间的部分,接下来的三个流程是挂载列表中的新节点和卸载不需要的旧节点以及无序对比。

但这三个流程中只会执行其中一个或者都不执行,总共有三种情况:1. 只需要安装新节点、 2. 只需要卸载旧节点、 3. 无序。这和前面的讲到的全量diff和像,这就要看i了,如果i大于e1并且小于或者等于e2说明有新节点,执行流程三,如果i是大于e2说明有不需要的旧节点,执行流程四。都不符合执行流程五

3.流程三:挂载新节点(此流程不一定执行)

c9add542713621c9de6f115f6aed7a57.png
image.png

nextPos是用来确定新增节点的位置,一般到了这一阶段e2是没有处理的新节点列表的最大索引,要加一是因为vue新增节点的方式了,vue新增元素是通过insert,实现原理是insertBefore,所以这里会拿到将要插入元素的位置的后一个。具体看下面的示意图。(ps:红色框内是被处理过的)

2443730382769808eeff03611f38c076.png
image.png

在这个案例中,6是新增的节点,因为经过了流程一和二的处理,i变成了5,e2变成5,e2正好是节点6的索引,如果我们需要把它插入列表中,我们需要知道他的后一个节点是谁,以便做为瞄点,这就要加一后去新children列表中找。

120e63a4b07a2fa5e39384c5bd1cd2de.png
image.png

但是还有第二种情况,如果新增的节点是新children列表中的最后一个,那么加一就会超出其长度,那么就会把parentAnchor作为瞄点,parentAnchor是当前列表的父容器中的最后一个节点,一般都是空字符串,(注意:这里是节点,不是元素节点)。例子中不符合,不会执行该流程

4.流程四:卸载不需要的旧节点(此流程不一定执行)

4d610246de1e3bbf80c813c44e0c124e.png
image.png

卸载旧节点的操作就比较简单了,每卸载一个i就加一,通过unmount方法进行卸载,实现原理是通过找到要卸载的节点的父节点,调用removeChildren进行卸载。前提是i大于e2但小于等于e1。例子中不符合,不会执行该流程

5.流程五:无序对比(此流程不一定执行)

如果到了流程五,说明children列表中有一部分是无序的,前面的流程无法处理,需要进行无序对比。这流程五分为三部分。

a17b2643e57a278ba5054f82091aec14.png这第一部分是为了产生index和新children列表中的key的映射图,它会拿i作为新旧children列表的开始索引,当找到newChildren,准确来说是找到newChild身上的key,就会连同i一起保存进keyToNewIndexMap中。

1fed78141623dbf95e40a3f55516a055.png
code.png

这第二部分是循环旧节点列表 以匹配需要更新的节点和删除不需要的节点,先提前创建一个数组(newIndexToOldIndexMap),长度是还需要进行对比(toBePatched)的数量,作为新旧索引对应的存放(默认全部都是0)

开始循环旧children列表,当patched大于toBePatched时就都是卸载节点,但是一开始patched是0并不会大于,继续往下走,开始找newIndex,先从在前面保存的key:index的映射图中找,没找到就尝试在旧children列表中定位同一种类型没有key的节点的索引。还是没有就只能undefined

最后,如果newIndexundefined,说明旧节点没有对应的新节点直接卸载,不然,会修改newIndexToOldIndexMap中对应索引位置,如果newIndex小于新节点最大位置(maxNewIndexSoFar),说明这个节点移动了,不然maxNewIndexSoFar就赋值成newIndex。过了这么多,终于可以传递给patch进行对比,patched也会加一。

7d7989120208aa81c5d623072493810c.png这最后一部分,主要是为了移动节点和新增节点,如果有需要移动节点它会先根据新旧节点索引的映射产生一个最长递增子序列。而从最后开始循环也便于我们可以使用最后一个修补的节点作为瞄点,找出新节点中的最长递增子序列,移动不在这个范围内的节点,如果映射的oldIndex是0说明是新增节点,需要进行挂载。在例子中,就会移动1。

6d49242a4e64e3b49b264876830f80f8.png
image.png

这流程五是最复杂的,其中不仅包含了挂载和卸载,还包含了移动节点,提高了对节点利用,到此patchKeyedChildren流程结束。

其他情况

回到patchChildren中,继续看patchFlag不存在如何进行对比,这要根据新旧节点的情况进行更新

c4ac04cbc7b574e5766a9c68497e1b15.png
code.png

看起来复杂其实很简单,先说如果新节点是TEXT_CHILDREN,如果旧节点是ARRAY_CHILDREN,会先卸载所有旧节点,再挂载新节点,旧节点也是TEXT_CHILDREN需要和新节点对比确认不同后再更新。

如果两个都是ARRAY_CHILDREN,需要走patchKeyedChldren,但也有可能只是卸掉旧的并没有新节点,卸载所有旧节点。

当旧节点是TEXT_CHILDREN新节点是ARRAY_CHILDREN时,会先将其变为空字符串,再进行挂载新节点。

后面对比props的部分,在我之前的文章Vue3.2 vDOM diff流程分析之一:props和attrs的初始化和更新[4]中讲过,感兴趣可以去看看,到这里对比元素的流程结束。

对比组件类型节点

2a0985e3854dd3980cccf7162ca96920.png
image.png

patch函数中,对比组件分支执行的是processComponent,最终会执行updateComponent,组件更新新的会继承旧的实例。

更新前他会执行shouldUpdateComponent判断是否需要更新。但是属实是情况太多,这里就不一一列举了,具体可以到源码中查看`shouldUpdateComponent`[5]函数。

164b25ebd0742c2369ceaefe54605416.png
image.png

进入需要更新的流程,他是会优先处理Suspense(存在asyncDepasyncResolved不存在),不是Suspense就正常更新,把新VNode(instance.next)赋值成n2,如果当前组件已经在更新队列中,请将它移除,避免重复更新同一组件,然后就可以调用实例上的更新器进行更新了。

b13003b6b58214b12462e6549d76da15.png
image.png

注意这里的instance.next,如果这个存在,在调用componentUpdateFn中会调用updateComponentPreRender函数,这是因为组件数据变化导致其子组件更新,所以需要去更新实例中的VNode以及propsslots,顺带把更新props导致的更新执行了。如果只是单纯的数据变化,没有影响到子组件,那next就会是原本实例上的VNode

后面的就是正常调用生命周期函数和钩子函数,产生新的VNode和旧的VNode一起交给patch进行对比,后面的就要看组件里面是啥东西然后走哪个流程。

对比文本类型、注释类型、静态节点类型节点

  • 文本类型

f703ab0666ca3b7bf6195cad61b21ca4.png
image.png

文本类型节点的更新在processText中,会先进行对比,不同才会更新文本

  • 注释类型

304990d62df57bf305ed4bd358a4f743.png
image.png

注释节点的更新在processCommentNode中,但是因为不支持动态更新注释,所以是直接拿以前的。

  • 静态节点类型

7ef7fe7b52df15ad3125fa8f99b19190.png
image.png

静态节点的更新执行的是patchStaticNode,因为vue会把静态节点进行序列化成字符串所以可以直接进行字符串对比,相同只会赋值以前的elanchor,不同会先循环移除旧的,连带着anchor一起移除,再挂载新的静态节点。

总结

本篇文章分析了vue中diff算法的处理,清楚vue中diff算法的处理流程,知道每一个节点对比如何进行,如何书写模板可以进行最优的对比、复用节点,从而提高性能,在列表对比中,优化模式只会对比dynmaicChildren中的节点,也就是动态节点,非优化模式下,虽然说是全量diff但是可以复用节点也不会损耗太多性能。

好了,到了文章的最后,还是希望各位哥哥姐姐能指导指导。有说错或者遗漏的欢迎在评论区讲解,谢谢。

关于本文

作者:咸鱼是如何练成的

https://juejin.cn/post/7072321805792313357

Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章2. 订阅官方博客 www.inode.club 让我们一起成长

点赞和在看就是最大的支持❤️
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值