专栏名称: 奇舞精选
《奇舞精选》是由奇舞团维护的前端技术公众号。除周五外,每天向大家推荐一篇前端相关技术文章,每周五向大家推送汇总周刊内容。
目录
相关文章推荐
江西发改  ·  今天,央视《新闻联播》报道江西! ·  19 小时前  
都市现场  ·  2月底截止!江西居民抓紧办理 ·  2 天前  
江西环境  ·  正月初八,开工大吉 ·  3 天前  
51好读  ›  专栏  ›  奇舞精选

深度对比 React 与 Vue 的 Diff 算法

奇舞精选  · 公众号  ·  · 2024-08-05 18:00

正文

昨天接了个广子,恰了点饭。你们的吐槽我的认了,哎呀,你们都说得对。所以我今晚连夜爆肝写了一篇 专门用来面试的干货 ,总计 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 属于不同的节点类型,那么就表示不是同一个节点。

<div>
  <Counter />
div>

<span>
  <Counter />
span>

然后会比较 props、state、context 等外部传入的参数是否相同,从而判断是否是同一个节点。 当然,这里还有一个重要的细节 ,对于性能优化至关重要。

由于在 React 中,props 的传入都是通过类似于函数传参的方式传入,例如

function Counter({ a: 1 }{...}

前后两次执行

Counter({a1})
Counter({a1})

这里的细节就是,虽然两次都入参都是 {a: 1} ,但是他们是不同的内存地址,因此前后两次 props 的比较结果始终为 false

{a1} === {a1// false

如果不解决这个问题,任何比较方式都是没有意义的,结果都是为 false。

所以这里 React 设计了一个巧妙的规则,那就是当我们判定元素节点的父节点未发生变化时,就不比较 props,从而跳过 props 的比较,来提高性能。我们可以利用这样的特性在 React 中写出性能非常高的代码。

除此之外,React 还设计了一个 api, React.memo ,该 api 可以改变 props 的比较规则。使用方式如下所示,memo 接收组件声明作为参数

function Counter({ a: 1, b: 2 }{...}

export default React.memo(Counter)

使用 memo 包裹之后,props 的比较规则变成了依次比较第一层 key 值所对应的值。例如,上面的案例中,包裹之前的比较方式为

a1b2 } === { a1b2 } // 始终为 false

包裹之后的比较方式为

p1.a === p2.a && p1.b === p2.b // true

因此,在特定的场景中,使用 memo 可以有效命中可复用节点。

在 Vue 中,由于并不是那么强依赖 diff 算法,因此它的节点比较逻辑相对而言会简单直接一些,通过一个 sameVnode 方法来比较节点是否相同。

// Vue2 /src/core/vdom/patch.js
// 主要通过对key和标签名做比较
function sameVnode (a, b{
  return (
    a.key === b.key && // 标签名是否一样
    a.asyncFactory === b.asyncFactory && ( // 是否都是异步工厂方法
      (
        a.tag === b.tag && // 标签名是否一样
        a.isComment === b.isComment && // 是否都为注释节点
        isDef(a.data) === isDef(b.data) && // 是否都定义了data
        sameInputType(a, b)  // 当标签为input时,type必须是否相同
      ) || (
        isTrue(a.isAsyncPlaceholder) && // 是否都有异步占位符节点
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
function sameInputType(a, b{
  if (a.tag !== 'input'return true
  let i
  const typeA = isDef((i = a.data)) && isDef((i = i.attrs)) && i.type
  const typeB = isDef((i = b.data)) && isDef((i = i.attrs)) && i.type
  return typeA === typeB || (isTextInputType(typeA) && isTextInputType(typeB))
}

这里需要注意的是,如果我们要详细的聊清楚 React 和 Vue 在节点是否相同的比较方式上时,就要明白 React 是强依赖于这个,但是 Vue 依赖较弱,因为 Vue 的实现原理中更多的是通过依赖收集的方式来找到需要更新的节点,这也导致了 React 在这个上面的逻辑要更加复杂,Vue 则更加简单。因此,我们需要对他们各自的原理背景有一定的了解。


最大的区别,在列表上的处理

列表是性能问题频发的重要场景,因此,React 和 Vue 都针对长列表设计了特殊的处理方式。在聊之前,我们要明确处理场景,那就是,在列表中,我们就不能再忽视节点位置的移动了。

因为,一个简单的移动,就很容易会造成整个组件被判定为需要全部重新创建。所以,我们需要判断出来节点是只发生了移动,而不是需要重新创建。

给列表中的每一个节点, 引入唯一 key 值 ,是他们都共同采用的技术手段。通过唯一key 值,我们可以在旧列表中找到新列表的节点是否已经存在,从而决定是移动节点的位置还是创建新的节点。我们通常会在数组中设定一个 id 用于表示唯一 key 值。

const todoItems = todos.map((todo) =>
  <li key={todo.id}>
    {todo.text}
  li>

);

需要注意的是,这里的唯一 key 值,尽量不要使用递增的数字序列来表示。

const todoItems = todos.map((todo, index) =>
  // Only do this if items have no stable IDs
  <li key={index}>
    {todo.text}
  li>

);

这个问题也是面试中经常会聊到的话题:为什么尽量不要使用 index 序列作为 key。这是因为当我们在列表中新增一个内容时,每一项的 index 每次都会发生变化,从而让渲染结果出现混乱。

例如有一个数组为 [a, b, c] ,此时我们将 index 作为key值,那么数组项与 key 值的对应关系就是

[a:0, b:1, c:2]

此时,我们往数组头部添加一个数据,这个时候数组变成了 [p, a, b, c] ,然后再执行,数组项与 key 的对应关系就变成了

[p:0, a:1, b:2, c:3]

新增的项是 p,但是最终导致了数组项与key 之间的对应关系错乱,结果导致缓存的节点挂到了错误的数组项上去了。我们可以通过如下案例演示观察在 UI 上的表现差别

首先,我们的默认情况如下,上面的列表使用 index 作为 key 值,下面的列表使用 唯一 id 作为key 值。

当我新增一个项时,仔细观察两个列表的差异。

在引入了 key 值之后, 为了追求在某些情况下更少的移动次数 ,Vue 中使用了更复杂的算法设计: 双端比较 以及最长子序列递增,这是他们最大的区别。


React 的比较方式

首先,我们假定有一个已经渲染好的列表节点如下

// 使用 _ 表示旧节点列表
[_A] [_B] [_C] [_D]

然后,我们在 A 的前面新增的了一个节点,P,理想的结果如下所示

[P] [A] [B] [C] [D]

在比较的过程中,我们会首先创建一个变量 lastIndex ,默认值为 0。然后使用一个指针 index 用来记录新数组中的当前项在旧列表中的索引值。

在比较的过程中,lastIndex 的变化规则如下

lastIndex = max(index, lastIndex)

例如,我们开始遍历新数据 [P, A, B, C D]

1、第一个目标项为 P ,我们会去旧列表中查询是否存在相同的 key=P 的项。发现没有,此时创建新节点

lastIndex = 0

[P]

2、第二个目标项为 A ,我们去旧列表中查询是否存在相同的,发现有,此时 index = 0,可复用节点

当满足条件 index < lastIndex 时,移动节点。此时 index = 0,lastIndex = 0,不满足条件,不移动,此时结果为

// 结果为 0
lastIndex = max(index, lastIndex)

[P] [A]
i

注意:这里说的移动节点,指的是对真实 DOM 的操作。

这里需要多花点时间感受一下这个判断条件的合理性, index < lastIndex ,他是为了确保索引的正确顺序而设计的规则

3、第三个目标项为 B ,我们去旧列表中查询是否存在相同的key,发现有,此时 index = 1,可复用节点

不满足 index(1) < lastIndex(0) ,不移动,结果为

// 结果为 1
lastIndex = max(index, lastIndex)

[P] [A] [B]

依次类推,最终我们发现,这种情况下,只需要创建一个新节点 P,不需要移动任何节点。

第二个案例

新旧列表节点如下

旧列表:[A] [B] [C] [D] 
新列表:[B] [A] [D] [C]

新的数据为 [B, A, D, C]

1、第一个目标节点为 B ,发现在旧列表中存在相同 key,那么复用节点,此时,index = 1,当前结果为

index = 1
lastIndex = 0
index // false,不移动

lastIndex = max(index, lastIndex) // 1

[B]

2、第二个目标节点为 A ,发现在旧列表中存在相同 key,那么复用节点,此时,index = 0,当前结果为

index = 0
lastIndex = 1
index // true,移动

lastIndex = max(index, lastIndex) // 1

[B] [A]

3、第三个目标节点为 D ,发现在旧列表中存在相同 key,那么复用节点,此时,index = 3,当前结果为

index = 3
lastIndex = 1
index // false,不移动

lastIndex = max(index, lastIndex) // 3

[B] [A] [D]

4、第四个目标节点为 C ,发现在旧列表中存在相同 key,那么复用节点,此时,index = 2,当前结果为

index = 2
lastIndex = 3
index // true,移动

lastIndex = max(index, lastIndex) // 3

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

这个案例在 diff 之后,只需要真实 DOM 移动两次节点。就可以完成更新了。

相信通过这两个案例,大家应该能掌握 React 在列表中的对比规则,接下来,我们来了解一下 Vue 的双端对比。


Vue 双端比较

Vue 的双端对比的设计有一个目标,那就是在特定的场景之下, 减少真实 DOM 的移动次数 。我们来看一下这样一种场景。

旧:[A] [B] [C] [D]
新:[D] [A] [B] [C]

如果按照 React 的比较规则,此时由于第一个目标 D 的 index 为 3,从一开始就变成了最大,因此,后续的 lastIndex 都为 3,所有的目标项都会满足 index < lastIndex ,因此,真实 DOM 的移动就会执行 3 次。

而 Vue 提出的双端比较,目标就是希望可以识别出来这种情况,只需要让移动只发生一次即可。就是 D 从最末尾移动到最前面。

双端比较会使用 4 个指针,分别记录旧列表和新列表的首尾两个节点位置。

let oldStartIdx = 0
let oldEndIdx = prevChildren.length - 1
let newStartIdx = 0
let newEndIdx = nextChildren.length - 1

以及对应的虚拟节点对象

let oldStartVNode = prevChildren[oldStartIdx]
let oldEndVNode = prevChildren[oldEndIdx]
let newStartVNode = nextChildren[newStartIdx]
let newEndVNode = nextChildren[newEndIdx]

比较的规则遵循: 首-首 比较, 尾-尾 比较, 尾-首 比较, 首-尾 比较的顺序。通过这样方式找出首尾是否有节点可以被复用。







请到「今天看啥」查看全文