浅析react和vue中的diff策略

514 阅读15分钟

浅析react和vue中的diff策略

一、diff概念

作为前端工程师,我们都知道,无论是在react中还是vue中都涉及到虚拟DOM的概念,我们可能知道虚拟DOM的出现是为了减少直接操作DOM的性能开销。但从另外的角度来看,它也是为了更好的为Diff去服务;

那什么是diff呢?用过react或者vue的开发者都知道,无论是哪一种框架从会通过某种操作使得组件的状态发生更新,那么框架就会基于旧的虚拟DOM和最新的状态生成一份新的虚拟DOM。这个时候我们有新旧两份虚拟DOM,而真实的DOM都是虚拟DOM的映射而已,因此我们可以有两种方式来实现我们的更新;

方法一:暴力的直接渲染新的虚拟DOM,依次创建整棵DOM树;
方法二:通过某种策略找到新旧DOM直接的差异的部分,只去更新差异的部分;

这里我需要提到的是无论是vue还是react都不约而同地选择了第二种方式,而他们都会使用所谓的某种策略就是我下面要提到的diff算法;

但是这里我需要解释一下为什么他们都不约而同地选择了方案二,我们可以对比一下两者的消耗,方案一中主要的消耗只有一个就是 全量的js对象(虚拟DOM)--> 全量的真实DOM,而方案二有 js对象之间的diff消耗 变动部分的js --> 真实DOM;虽说好像方案二需要做的事情更多了一样,但是从工程化的角度来看,完全是值得的,因为将js对象转为真实DOM的消耗和纯js之间的计算的消耗,完全不在同一个量级;将变动的部分通过消耗更小的js计算任务先提取出来,再去渲染那些不可避免的部分;从某种程度上将也是一种贪心的策略;因此vue和react都会选择方案二来实现自己框架的更新策略;

所以我们可以总结一下,所谓diff算法就是从两个对比对象之间,找到其中不同的部分;

二、经典diff算法

在聊react和vue的diff之前,我们来思考一个问题,这两大框架用的算法是最优策略么?

当初的我就天真的以为毕竟这么经典的开源框架,肯定是最优策略呀,这不废话么!

这里我需要说明的是,react和vue的策略均非最优解,我们一起来好好聊一聊;

什么是最优解:我们先确定一下,对比的对象是两颗含有n个节点的树;(a树、b树)

所以最优解的定义应该是能够使得a树当中的节点达到最大程度的复用;并通过最小的改动使其成为b树;

来看一张图片

node

这是经典的diff策略,我们可以看到如果想要让左树成为右树,我们不需要创建任何新的节点,只需要通过移动就可以使得两棵树变得'一样';这就是最优的解;

但是遗憾的是vue和react都没有采取这样的策略;为什么呢?

通俗来讲,就是因为这样的方法好是好,牛是真的牛,但是太耗时了;

我们简单算一下它的时间复杂度,大概思路是在每遍历到左树的一个节点的时候(O(n))都需要遍历右树(O(n))看看是否可以复用,然后与此同时将这个复用节点移动到合适的位置(O(n));所以总的算下来应该是O(n**3)的时间复杂度;

如果DOM节点有限的情况下,这种方案倒也可以接受,但是哪怕是小项目DOM节点可以也会到达成千上万个,这种情况下,耗费的时间恐怕是呈指数级的上升。因此这种方式好是好但是确实太耗时了;我们的框架不能采用;

三、最适合的diff

既然不能够使用时间复杂度过高的算法;就必须舍弃最优解;好在从工程的角度来讲其实对于任何一颗DOM树而言,他们的变量在大部分场景下其实是几乎没有隔层的复用的,我们大部分的业务场景其实就是同层之间的变化,要么就第n层的某一个节点不同,要不就是第n层的节点部分不同;因此无论是vue还是react都做了一个大胆的设计;

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。

采用时间复杂度为O(n)的算法,只遍历一遍,在这一遍的过程中,当前层如果只有一个节点的话,对比新树与旧树的该节点,如果dom类型不同,则这个节点对应的子树会全部重新创建,如果类型相同则看key是否相同,如果也相同,则复用该dom节点,不用创建;如果该层是一个多节点,那么这就是diff要解决的最核心问题,对于某一层如果从n个节点,变为m个节点,那么框架的任务就是找到他们的不同;

我们假设字母代表开发者为节点添加的key值,将以上的问题抽象一下,可以得到下面的问题;

截屏2022-06-18 下午4.46.40.png

从 ABCD 变成 BADC 怎样实现最大的复用?

该解决方案vue和react的实现都是不一样的;接下来我们就一起来看一看?

四、vue的diff策略

首先需要说明的是即便是vue2和vue3他们的diff策略都是不相同的,我们先来聊聊vue2的diff策略;

vue2采用双指针的方式进行diff;针对题目我们可以看一下 ABCD --> BADC ,这是一个经典的移动场景;从肉眼我们可以看出所有节点均可复用;

截屏2022-06-18 下午5.16.24.png

vue2首先会创建上面4个指针,接下来采用以下的策略;

oldStart 和 newStart 比较 oldEnd 和 newEnd 比较 oldStart 和 newEnd 比较 oldEnd 和 newStart 比较 newStart 和 old中的每个元素进行比较

在比较的过程中,判断是否可以复用,可以复用则将 相对应的指针进行移动;不可复用进入下一个判断;

我们可以一起实验一次;

[ A , B , C , D ]
[ B , A , D , C ]

第一轮 A 比较 B 不同 --> 下一步 D 比较 C 不同 --> 下一步 A 比较 C 不同 --> 下一步 D 比较 B 不同 --> 下一步 B 和 old 逐一比较 发现这个 B 相同;说明 B从当前位置 移动到了 newStart的位置(0) 将newStart++ 并且将B从old中标记已比较,这里我抽象成删除;进入第二轮

[ A , * , C , D ]
[ * , A , D , C ]

第二轮 A 比较 A 相同 --> 复用 进入下一轮

[ * , * , C , D ]
[ * , * , D , C ]

第三轮 C 比较 D 不同 --> 下一步 D 比较 C 不同 --> 下一步 C 比较 C 相同 --> 复用 且得知C从newStart位置移动到了oldEnd的位置;进入最后一轮

[ * , * , * , D ]
[ * , * , D , * ] 第四轮 D 比较 D 相同 --> 复用 且得知D从newStart位置,移动到了 oldStart的位置;

以上便是vue2在diff时的策略;我们也可以模拟其他增删改的所有案例,都是可以这种方式diff出正确的结果的;此外,我们可以看一下源码对照;这里就不直接贴代码了;

五、vue3的diff策略

vue3其实针对vue2做了很多的优化,其中就有diff的优化;

vue3依然保留了vue2中

oldStart 和 newStart 比较 oldEnd 和 newEnd 比较

这前2步的策略;头和头 尾和尾相比较之后,可以保证,剩下的部分,他们的节点个数不一定相同,但是可能会有复用的可能;我们举个例子:

新 ABCDE 旧 ADCBE

通过前两步,是不是就可以把 A和E给去除了,因为通过前两步比较,可以省下的部分是可以有待商榷的部分;而这一部门vue3不再采用暴力的用newStart依此和old中的每一个比较,因为这样子的时间复杂度也接近于O(n**2)了,因此vue3采取了一种更好的方式;称之为最长递增子序列算法;

我们抽象出一个问题:

旧:ABCDE

新:BCDEA

注意对于上面的问题,是经历过前两步之后剩下的问题;我们可以通过最长递增子序列来解决;

截屏2022-06-18 下午10.47.54.png

拿到这个问题之后,我们可以思考一下,其实我们想要的就是把old以最少的消耗变成new;做这种移动操作是基于old去做的,因此可以想到一个思路;当移动任何一个节点的时候,不仅仅是该节点移动了,其实其他的节点也被迫移动了,例如,如果我将A插入到C和D的中间,那么B和C就会被迫的相对于往左移动了;根据这个特性,我们要最好让旧树得到最小的破坏;

根据old生成一张map标,然后很容易就会得到new的下标是图上的样子;只要我们找到new中index最长的递增的子序列,然后保持他们不动;只移动其他的节点,那么就可以使用最小的破坏得到正确的diff结果;

假设我们通过某种算法得到new的最长递增子序列是1234,并且返回给我们剩下的节点A当前在new中的位置是4,我们就可以让old树中的1234位置的节点保持不动,然后只用将A节点移动到4的位置就可以了;这个其实就是vue3做的主要的优化;而最长递增子序列是一道经典的算法题;并且时间复杂度可以做到O(n*logn)的水平,从O(n**2)到 O(n*logn)还是很值得实现的;

接下来我们仔细看看vue3是如何diff的;

大概的流程如下; oldStart 和 newStart 比较
oldEnd 和 newEnd 比较
比较old和new的节点个数; 生成最长递增子序列 移动其他的节点;

我们试一试这样一个例子:

old:[ A, B, C, D, E, F, G ]
new:[ A, B, F, C, D, E, H, G ]

第一轮:相同就复用,不相同则继续

A 比较 A 相同 下一步

old:[ *, B, C, D, E, F, G ]
new:[ *, B, F, C, D, E, H, G ]

B 比较 B 相同 下一步

old:[ *, *, C, D, E, F, G ]
new:[ *, *, F, C, D, E, H, G ]

C 比较 F 不相同 下一步

old:[ *, *, C, D, E, F, G ]
new:[ *, *, F, C, D, E, H, G ]

G 比较 G 相同 下一步

old:[ *, *, C, D, E, F, * ]
new:[ *, *, F, C, D, E, H, * ]

C 比较 F 不相同 下一步

old:[ *, *, C, D, E, F, * ]
new:[ *, *, F, C, D, E, H, * ]

F 比较 H 不相同 下一轮

old:[ *, *, C, D, E, F, * ]
new:[ *, *, F, C, D, E, H, * ]

第二轮

生成oldMap

{
  C : 2,
  D : 3,
  E : 4,
  F : 5
}

比较 old 和 new 的个数; old(4) < new(5) 说明一定有新增;

遍历 new 根据 oldMap设置index

[ *, *, F, C, D, E, H, * ]  // 节点
[ *, *, 5, 2, 3, 4, -1,* ]  //old索引
[ 0, 1, 2, 3, 4, 5, 6, 7 ]  //old索引

遍历过程中如果是遇到在oldMap中找不到,即索引为-1,说明是新增节点直接创建新的节点;插入6的位置;

根据最长递增子序列算法得出234,则old中CDE是不用动的; 则需要移动的就是F,即索引为5的节点,移到哪个地方呢;

[ *, *, F, C, D, E, H, * ]  //new节点
[ *, *, 5, 2, 3, 4, -1,* ]  //old索引
[ 0, 1, 2, 3, 4, 5, 6, 7 ]  //old索引

看当前F节点在new中的位置2的地方;

所以最后的移动策略就是对于old:


old:`[ *, *, C, D, E, F, * ]` 

CDE保持不动,移动到2的位置,其他节点复用,创建一个新的节点H插入6的位置;

最终可以得到new:[ A, B, F, C, D, E, H, G ]

关于最长递增子序列如何求,以及vue3中的具体实现,我会再下一篇文章中解析;本文主要是浅析;详情可以刷一道leetcode最长递增子序列的题哈;

六、react的diff策略

由于是基于fiber架构的diff算法,react的diff有自己的独特的特色,确实很不相同,接下来我们一起来看一下;

可以看到选择策略的不同大概率是根据数据结构的不同来确定的;vue选择双指针的策略,是因为diff的对象是两个数组;确实可以很好的使用双指针;但是react中fiber架构启用后;对比的对象不是两个数组;

而是一个数组和一个fiber结构;例如:

<div>
  <span key="A">A</span>
  <span key="B">B</span>
  <span key="C">C</span>
  <span key="D">D</span>
<div>

更新后

<div>
  <span key="B">B</span>
  <span key="D">D</span>
  <span key="A">A</span>
  <span key="C">C</span>
<div>

对比的对象应该是根据新的jsx的数组类型diff出需要变动的旧的fiber并且变动的部分打上标记;生成新的fiber结构;

所以是:

//old    new    diff   
fiber + 数组jsx ---->  新fiber

了解过fiber结构的同学可能都知道fiber的数据结构类似链表;所以使用双指针无法通过所以直接从oldStart 直接 跳到 oldEnd , 如果需要这样,还得先遍历一遍,因此双指针的策略肯定是行不通的;

截屏2022-06-19 下午2.03.01.png

react采用右移策略,对于需要变动的节点,始终都将其往最右侧移动。那么那些节点是移动的呢;以及以什么样的顺序移动呢?

经过两轮的遍历:

第一轮:

new 的第一个节点和 old的第一个节点比较,相同就复用,不相同跳出循环进入下一轮;

第二轮:

同样记录一张map表;

{
  A : 0,
  B : 1,
  C : 2,
  D : 3
}

记录lastIndex = 0;

  • 遍历 new 的每一个节点;当前节点的索引和lastIndex比较
  • 如果大于则不移动;并将lastIndex赋值为当前节点在map中的索引
  • 如果小于,则将当前节点移动到最右侧;

经历过以上的操作,则就会将old变成新的new的样子;接下来我们一起跑一下案例;

old [A , B , C , D]
new [B , D , A , C]

第一步:

A 和 B 比较,不同,跳出循环

第二步:生成map表;

{
  A : 0,
  B : 1,
  C : 2,
  D : 3
}
old [A , B , C , D]
     0   1   2   3
new [B , D , A , C]
     1   3   0   2 

此时lastIndex = 0;以下简称l

遍历new

B(1) > l(0) 所以不移动; 并将 l 赋值为 1 D(3) > l(1) 所以不移动; 并将 l 赋值为 3 A(0) < l(3) 记录一下 将A移动到最右侧 C(2) < l(3) 记录一下 将C移动到最右侧

结束diff,生成的待操作表是 1 将A移动到最右侧 2 将C移动到最右侧

我们可以看到,只要按照这样操作,old的确会变成new的样子;也就是说这种diff策略确实是可以的;感兴趣的同学可以多试几个自己随便的例子;按照这样的策略都是可以的;

那接下来我们来讨论一下这样的策略性能怎么样呢;

坦白来讲我认为react的最右移策略,在某些情况下其实并不能算是效果非常高;举个简单的例子;

old [A, B, C, D]

new [D, A, B, C]

其实我们明显的能感知到最好的策略其实把old中的D移动到最前面就可以了;一步就可以; 但是按照react的diff,会经过3步,将A,B,C依此往最右侧移动;

但是虽说有这样一个问题;但是也是react升级成为fiber架构够应该做出的一个牺牲;除了这种极端的例子,在大部分情况react的diff都是有很好的效率的;

从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。

七、总结

这里我想聊一聊vue和react中他们在diff过程中的一些异同点,方便我们可以灵活的应对面试过程中关于diff的问题;

相同点:

  • 都是为了解决最小量更新的问题,避免全量的创建DOM;

  • 都做了同样的最优解的牺牲,不进行跨层比较,只解决同层的diff

  • 都需要开发者提供key进行辅助对比;

不同点:

  • 实际的diff策略有所不同,vue2,vue3,react的策略都不相同,具体就是整个博客的内容;

  • 实际操作DOM的阶段是不一样的

    对于vue来讲,实际操作DOM的阶段就是在diff的过程中完成的;diff到不同直接更新;

    而对于react来讲,实际操作操作是在commit阶段,而diff是发生在render的beginWork阶段的;diff仅仅是打上tag做标记而已;并不实际操作DOM;

  • diff的量是不一样的

    由于vue具有依赖收集,某个响应式变量触发之后,知道自己需要更新的范围,因此diff的量是局部的,是较为轻量的; 而react是从rootFiber开始自始至终向下调和,属于全量的diff;

  • diff的对象不同

    对于vue而言,diff的对象就是新旧虚拟DOM,他们的数据结构完全一样;结果是创建新的DOM;直接更新视图;

    而对于react,diff的对象是新的jsx(也就是creatElememt(...)返回的虚拟DOM)和旧的fiber树,结果是生成新的fiber树;

  • diff的中断性不同;

    对于vue而言diff的整个过程中,是不可中断的同步更新,采用递归版本的深度优先完成;

    而在react中,如果启用的是concurrent模式,那么diff的过程其实也就是beginWork的过程是可中断的,这个过程中,如果有更优先级的任务可以执行更具有优先级的任务;之后在恢复中断的任务就好;

八、参考资料

vue3diff
react技术揭秘

好了,以上便是本篇文章的所有内容,如果有关于diff的疑问,欢迎评论区批评指正;

同时如果有想要一起研究react源码的伙伴,欢迎一起加微信组队研究;

截屏2022-06-19 下午3.01.29.png