深度对比 React 与 Vue 的 Diff 算法
共 11938字,需浏览 24分钟
·
2024-08-05 22:39
昨天接了个广子,恰了点饭。你们的吐槽我的认了,哎呀,你们都说得对。所以我今晚连夜爆肝写了一篇专门用来面试的干货,总计 7000 字左右,分享给大家,必须要抢救一下想要取关的大佬们!
许多朋友会在简历上同时写自己会 React、Vue,但是倒霉的面试官一看到这种简历,就喜欢问它们有什么区别。其中频率比较高的一个问题就是 React 与 Vue 的 diff 算法有啥相同之处和不同之处...
很显然,这种问题对于面试考验开发者能力而言,没啥营养,就算知道了,对开发能力也不会有什么明显的提高,还不如更具体的问 key 值有什么用呢,但是没办法,有的面试官就是爱问,既然这样,那我们就答给他们看。
假说论
我们在思考算法问题的时候,一定要谨记一个前提,那就是没有完美的算法可以解决所有问题。 因此,在设计一个算法时,我们需要充分考虑应用场景,然后提出一个假说,从而极大的减少问题的复杂性,让解决方案变得更加简单。
在 React/Vue 的 diff 算法中,当我们要对比前后两棵树的差异时,我们的目标是尽可能少的创建节点。但是由于 DOM 操作的可能性太复杂了,因此如果要全部对比出来,复杂度就非常高。达到了 O(n^ 3) 这个级别。
之所以这么复杂的原因,就是因为节点不仅可以增加删除,还可以移动。我们要分辨节点是否从子元素移动到了父元素,或者增加了一个父元素,判断过程非常复杂。因此,在设计 dfiff 算法的时候,React/Vue 都放弃了这种情况的识别。
他们根据实际情况提出的假说是:在实际情况中,整棵 DOM 树里,关于父子节点移动的情况是比较少的,因此,没有必要为了这种少部分情况加剧算法的压力。只要放弃识别这种情况,算法就能够得到极大的简化。
几乎相同的比较策略
在上面我们提到的假说论之下,React/Vue 在 diff 算法上,都使用了非常类似的策略。
一、同层比较
如下图所示,diff 算法只需要比较相同层级的节点是否相同
比较之后又两种情况
-
如果相同:则直接复用,而不需要重新创建 -
如果不同:则直接默认为从该节点开始,以下的全部节点都发生了变化,需要重新创建。
如下图所示,虽然节点只是发生了移动,但是在 diff 过程中,会被认为 A 节点已经被删除,然后重新创建它。
因此,在开发过程中,我们可以通过避免跨层级操作节点的方式,来提高你应用程序的性能。
二、节点比较
在对比节点是否相同时,React 与 Vue 的处理策略也比较类似。了解节点的比较方式,是我们在做性能优化时的重要依据。
节点比较有两种情况,一种节点是默认支持的 DOM 节点,一种是通过自定义创造出来的自定义节点,在 React 中会区分这两种情况,但是在 Vue 中会统一只识别标签名 tag
。
除了这个情况之外,在结构类型上,还分为两种情况,一种是正常的树状结构,另外一种是列表结构。列表结构通常情况下我们就不能直接忽视移动这种情况。因此,针对于这种列表结构,React 和 Vue 都优先提倡使用传入 key 值的方式进行标记,从而极大的减小比较压力。
因此,在列表节点的比较中,React、Vue 都优先比较 key 值。
!不过针对列表的比较方式处理,是 React 和 Vue 在 diff 算法上最大的差别。我们后面单独介绍
然后,在 React 中,diff 算法会优先比较节点类型是否相同。例如下面的代码,div
与 span
属于不同的节点类型,那么就表示不是同一个节点。
然后会比较 props、state、context 等外部传入的参数是否相同,从而判断是否是同一个节点。当然,这里还有一个重要的细节,对于性能优化至关重要。
由于在 React 中,props 的传入都是通过类似于函数传参的方式传入,例如
前后两次执行
这里的细节就是,虽然两次都入参都是 {a: 1}
,但是他们是不同的内存地址,因此前后两次 props 的比较结果始终为 false
如果不解决这个问题,任何比较方式都是没有意义的,结果都是为 false。
所里这里 React 设计了一个巧妙的规则,那就是当我们判定元素节点的父节点未发生变化时,就不比较 props,从而跳过 props 的比较,来提高性能。我们可以利用这样的特性在 React 中写出性能非常高的代码。
除此之外,React 还设计了一个 api,React.memo
,该 api 可以改变 props 的比较规则。使用方式如下所示,memo 接收组件声明作为参数
使用 memo 包裹之后,props 的比较规则变成了依次比较第一层 key 值所对应的值。例如,上面的案例中,包裹之前的比较方式为
包裹之后的比较方式为
因此,在特定的场景中,使用 memo 可以有效命中可复用节点。
在 Vue 中,由于并不是那么强依赖 diff 算法,因此它的节点比较逻辑相对而言会简单直接一些,通过一个 sameVnode
方法来比较节点是否相同。
这里需要注意的是,如果我们要详细的聊清楚 React 和 Vue 在节点是否相同的比较方式上时,就要明白 React 是强依赖于这个,但是 Vue 依赖较弱,因为 Vue 的实现原理中更多的是通过依赖收集的方式来找到需要更新的节点,这也导致了 React 在这个上面的逻辑要更加复杂,Vue 则更加简单。因此,我们需要对他们各自的原理背景有一定的了解。
最大的区别,在列表上的处理
列表是性能问题频发的重要场景,因此,React 和 Vue 都针对长列表设计了特殊的处理方式。在聊之前,我们要明确处理场景,那就是,在列表中,我们就不能再忽视节点位置的移动了。
因为,一个简单的移动,就很容易会造成整个组件被判定为需要全部重新创建。所以,我们需要判断出来节点是只发生了移动,而不是需要重新创建。
给列表中的每一个节点,引入唯一 key 值,是他们都共同采用的技术手段。通过唯一key 值,我们可以在旧列表中找到新列表的节点是否已经存在,从而决定是移动节点的位置还是创建新的节点。我们通常会在数组中设定一个 id 用于表示唯一 key 值。
需要注意的是,这里的唯一 key 值,尽量不要使用递增的数字序列来表示。
这个问题也是面试中经常会聊到的话题:为什么尽量不要使用 index
序列作为 key。这是因为当我们在列表中新增一个内容时,每一项的 index 每次都会发生变化,从而让渲染结果出现混乱。
例如有一个数组为 [a, b, c]
,此时我们将 index 作为key值,那么数组项与 key 值的对应关系就是
此时,我们往数组头部添加一个数据,这个时候数组变成了 [p, a, b, c]
,然后再执行,数组项与 key 的对应关系就变成了
新增的项是 p,但是最终导致了数组项与key 之间的对应关系错乱,结果导致缓存的节点挂到了错误的数组项上去了。我们可以通过如下案例演示观察在 UI 上的表现差别
首先,我们的默认情况如下,上面的列表使用 index 作为 key 值,下面的列表使用 唯一 id 作为key 值。
当我新增一个项时,仔细观察两个列表的差异。
在引入了 key 值之后,为了追求在某些情况下更少的移动次数,Vue 中使用了更复杂的算法设计:双端比较以及最长子序列递增,这是他们最大的区别。
React 的比较方式
首先,我们假定有一个已经渲染好的列表节点如下
然后,我们在 A 的前面新增的了一个节点,P,理想的结果如下所示
在比较的过程中,我们会首先创建一个变量 lastIndex
,默认值为 0。然后使用一个指针 index
用来记录新数组中的当前项在旧列表中的索引值。
在比较的过程中,lastIndex 的变化规则如下
例如,我们开始遍历新数据 [P, A, B, C D]
1、第一个目标项为 P,我们会去旧列表中查询是否存在相同的 key=P
的项。发现没有,此时创建新节点
2、第二个目标项为 A,我们去旧列表中查询是否存在相同的,发现有,此时 index = 0,可复用节点
当满足条件 index < lastIndex 时,移动节点。此时 index = 0,lastIndex = 0,不满足条件,不移动,此时结果为
i注意:这里说的移动节点,指的是对真实 DOM 的操作。
✓这里需要多花点时间感受一下这个判断条件的合理性,
index < lastIndex
,他是为了确保索引的正确顺序而设计的规则
3、第三个目标项为 B,我们去旧列表中查询是否存在相同的key,发现有,此时 index = 1,可复用节点
不满足 index(1) < lastIndex(0)
,不移动,结果为
依次类推,最终我们发现,这种情况下,只需要创建一个新节点 P,不需要移动任何节点。
第二个案例
新旧列表节点如下
新的数据为 [B, A, D, C]
1、第一个目标节点为 B,发现在旧列表中存在相同 key,那么复用节点,此时,index = 1,当前结果为
2、第二个目标节点为 A,发现在旧列表中存在相同 key,那么复用节点,此时,index = 0,当前结果为
3、第三个目标节点为 D,发现在旧列表中存在相同 key,那么复用节点,此时,index = 3,当前结果为
4、第四个目标节点为 C,发现在旧列表中存在相同 key,那么复用节点,此时,index = 2,当前结果为
这个案例在 diff 之后,只需要真实 DOM 移动两次节点。就可以完成更新了。
相信通过这两个案例,大家应该能掌握 React 在列表中的对比规则,接下来,我们来了解一下 Vue 的双端对比。
Vue 双端比较
Vue 的双端对比的设计有一个目标,那就是在特定的场景之下,减少真实 DOM 的移动次数。我们来看一下这样一种场景。
如果按照 React 的比较规则,此时由于第一个目标 D 的 index 为 3,从一开始就变成了最大,因此,后续的 lastIndex 都为 3,所有的目标项都会满足 index < lastIndex
,因此,真实 DOM 的移动就会执行 3 次。
而 Vue 提出的双端比较,目标就是希望可以识别出来这种情况,只需要让移动只发生一次即可。就是 D 从最末尾移动到最前面。
双端比较会使用 4 个指针,分别记录旧列表和新列表的首尾两个节点位置。
以及对应的虚拟节点对象
比较的规则遵循:首-首比较,尾-尾比较,尾-首比较,首-尾比较的顺序。通过这样方式找出首尾是否有节点可以被复用。
例如下面案例
在首次经过四次比较之后,发现 新首-旧尾 key 值相同,可以复用,此时需要通过移动首尾索引指针构建新的新旧节点数组
然后新的新旧数组为
得到新的新旧数组,继续重复首-首比较,尾-尾比较,尾-首比较,首-尾比较。直到比较结束。
指针的移动方向,总体趋势是从两端往中间移动。
这里需要特别注意的是,这样的比较方式虽然快,但这是不充分比较,因此,在许多情况下,会导致节点存在复用,但是没有找出来。因此,如果没找到可复用的节点,比较还没有结束,还有后续的逻辑。
我们构建一个新的案例,如下所示
此时我们发现,通过首位的 4 次比较,结果一个可复用的节点都没找到,因此,此时需要回过头来重新完整的遍历,以新首 key 为当前目标,去旧列表中依次查找是否存在可复用节点
在这个案例中,可以找到,那么,我们就把 B 移动到首位
移动之后,通过改变指针位置,再将新的双端列表演变为
然后再以新尾 key 作为当前目标,去旧列表中依次遍历找寻。可以找到 C,将 C 移动到尾部。然后依次类推。
最后,如果双端对比结束之后,旧列表中还存在节点,则表示这些节点需要被批量删除
如果对比结束之后,新列表中还存在节点,则表示这些节点需要批量新增,简单处理即可。
Vue3 中的处理方式又发生了变化,但是时间来不及太详细的分析了,我这里就简单说一下,可以用于面试的术语:
先处理左侧相同部分,再处理右侧相同部分,锁定可复用的节点之后,再针对中间乱序部分,采用最长递增子序列的算法,计算出乱序部分可以复用的最长连续节点。通过这个递增数组,我们就可以知道哪些节点在变化前后的相对位置没有发生变化,哪些需要移动。
✓关于最长递增子序列大家可以通过力扣的算法题了解。涉及到的动态规划、二分、回朔等方式在评论都有大量的提到。他的目标依然是为了减少真实 DOM 的移动次数
https://leetcode.cn/problems/longest-increasing-subsequence/description/
总结
本文比较了 React 和 Vue 在 diff 算法上的相同的考虑与差异的处理,主要的作用是为了应对面试中的场景,在实践应用场景中用处不是很大。大家可以收藏起来慢慢看。
我在最后用了非常大量的篇幅介绍 React 与 Vue 在列表中对比的差异,Vue 由于在努力追求真实 DOM 能够以最小的移动来更新 DOM,因此在算法上更加特殊。
不过需要特别注意的是,在同一次事件循环中,由于浏览器在渲染时本身就会对 DOM 树进行统一的处理和绘制,因此,节点移动 1 次,还是移动 100 次,只要在这一帧中的最终结果是一样的,那么在渲染阶段所消耗的计算成本实际上是一样的,没有太大的区别。因此,我个人的观点是 Vue 这种算法对于渲染性能的提升效果应该是非常微弱的。