全面解析 vue3.0 diff算法

前言:随之vue3.0beta版本的发布,vue3.0正式版本相信不久就会与咱们相遇。尤玉溪在直播中也说了vue3.0的新特性typescript强烈支持,proxy响应式原理,从新虚拟dom,优化diff算法性能提高等等。小编在这里仔细研究了vue3.0beta版本diff算法的源码,并但愿把其中的细节和奥妙和你们一块儿分享。html

在这里插入图片描述 首先咱们来思考一些大中厂面试中,很容易问到的问题:vue

1 何时用到diff算法,diff算法做用域在哪里? 2 diff算法是怎么运做的,到底有什么做用? 3 在v-for 循环列表 key 的做用是什么 4 用索引index作key真的有用? 到底用什么作key才是最佳方案。node

若是遇到这些问题,你们是怎么回答的呢?我相信当你读完这篇文章,这些问题也会迎刃而解。面试

一 何时用到了diff算法,diff算法做用域?

1.1diff算法的做用域

patch概念引入算法

在vue update过程当中在遍历子代vnode的过程当中,会用不一样的patch方法来patch新老vnode,若是找到对应的 newVnode 和 oldVnode,就能够复用利用里面的真实dom节点。避免了重复建立元素带来的性能开销。毕竟浏览器创造真实的dom,操纵真实的dom,性能代价是昂贵的。typescript

patch过程当中,若是面对当前vnode存在有不少chidren的状况,那么须要分别遍历patch新的children Vnode和老的 children vnode。后端

存在chidren的vnode类型数组

首先思考一下什么类型的vnode会存在children。浏览器

①element元素类型vnode微信

第一中状况就是element类型vnode 会存在 children vode,此时的三个span标签就是chidren vnode状况

<div>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</div>

复制代码

在vue3.0源码中 ,patchElement用于处理element类型的vnode

②flagment碎片类型vnode

在Vue3.0中,引入了一个fragment碎片概念。 你可能会问,什么是碎片?若是你建立一个Vue组件,那么它只能有一个根节点。

<template>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</template>

复制代码

这样可能会报出警告,缘由是表明任何Vue组件的Vue实例须要绑定到一个单一的DOM元素中。惟一能够建立一个具备多个DOM节点的组件的方法就是建立一个没有底层Vue实例的功能组件。

flagment出现就是用看起来像一个普通的DOM元素,但它是虚拟的,根本不会在DOM树中呈现。这样咱们能够将组件功能绑定到一个单一的元素中,而不须要建立一个多余的DOM节点。

<Fragment>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</Fragment>
复制代码

在vue3.0源码中 ,processFragment用于处理Fragment类型的vnode

1.2 patchChildren

从上文中咱们得知了存在children的vnode类型,那么存在children就须要patch每个 children vnode依次向下遍历。那么就须要一个patchChildren方法,依次patch子类vnode。

patchChildren

vue3.0中 在patchChildren方法中有这么一段源码

if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) { 
         /* 对于存在key的状况用于diff算法 */
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
         /* 对于不存在key的状况,直接patch */
        patchUnkeyedChildren( 
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      }
    }
复制代码

patchChildren根据是否存在key进行真正的diff或者直接patch。

既然diff算法存在patchChildren方法中,而patchChildren方法用在Fragment类型和element类型的vnode中,这样也就解释了diff算法的做用域是什么。

1.3 diff算法做用?

经过前言咱们知道,存在这children的状况的vnode,须要经过patchChildren遍历children依次进行patch操做,若是在patch期间,再发现存在vnode状况,那么会递归的方式依次向下patch,那么找到与新的vnode对应的vnode显的如此重要。

咱们用两幅图来向你们展现vnode变化。

在这里插入图片描述 在这里插入图片描述 如上两幅图表示在一次更新中新老dom树变化状况。

假设不存在diff算法,依次按照前后顺序patch会发生什么

若是不存在diff算法,而是直接patchchildren 就会出现以下图的逻辑。

在这里插入图片描述

第一次patchChidren 在这里插入图片描述 第二次patchChidren 在这里插入图片描述 第三次patchChidren在这里插入图片描述

第四次patchChidren

在这里插入图片描述 若是没有用到diff算法,而是依次patch虚拟dom树,那么如上稍微修改dom顺序,就会在patch过程当中没有一对正确的新老vnode,因此老vnode的节点没有一个能够复用,这样就须要从新创造新的节点,浪费了性能开销,这显然不是咱们须要的。

那么diff算法的做用就来了。

diff做用就是在patch子vnode过程当中,找到与新vnode对应的老vnode,复用真实的dom节点,避免没必要要的性能开销

二 diff算法具体作了什么(重点)?

在正式讲diff算法以前,在patchChildren的过程当中,存在 patchKeyedChildren patchUnkeyedChildren

patchKeyedChildren 是正式的开启diff的流程,那么patchUnkeyedChildren的做用是什么呢? 咱们来看看针对没有key的状况patchUnkeyedChildren会作什么。

c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    const oldLength = c1.length
    const newLength = c2.length
    const commonLength = Math.min(oldLength, newLength)
    let i
    for (i = 0; i < commonLength; i++) { /* 依次遍历新老vnode进行patch */
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch(
        c1[i],
        nextChild,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    }
    if (oldLength > newLength) { /* 老vnode 数量大于新的vnode,删除多余的节点 */
      unmountChildren(c1, parentComponent, parentSuspense, true, commonLength)
    } else { /* /* 老vnode 数量小于于新的vnode,创造新的即诶安 */
      mountChildren(
        c2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized,
        commonLength
      )
    }

复制代码

咱们能够获得结论,对于不存在key状况 ① 比较新老children的length获取最小值 而后对于公共部分,进行重新patch工做。 ② 若是老节点数量大于新的节点数量 ,移除多出来的节点。 ③ 若是新的节点数量大于老节点的数量,重新 mountChildren新增的节点。

那么对于存在key状况呢? 会用到diff算法 , diff算法作了什么呢?

patchKeyedChildren方法究竟作了什么? 咱们先来看看一些声明的变量。

/* c1 老的vnode c2 新的vnode */
    let i = 0              /* 记录索引 */
    const l2 = c2.length   /* 新vnode的数量 */
    let e1 = c1.length - 1 /* 老vnode 最后一个节点的索引 */
    let e2 = l2 - 1        /* 新节点最后一个节点的索引 */
复制代码

①第一步从头开始向尾寻找

(a b) c (a b) d e

/* 从头对比找到有相同的节点 patch ,发现不一样,当即跳出*/
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
        /* 判断key ,type是否相等 */
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container, 
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      i++
    }
复制代码

第一步的事情就是从头开始寻找相同的vnode,而后进行patch,若是发现不是相同的节点,那么当即跳出循环。

具体流程如图所示 在这里插入图片描述

isSameVNodeType

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}
复制代码

isSameVNodeType 做用就是判断当前vnode类型 和 vnode的 key是否相等

②第二步从尾开始同前diff

a (b c) d e (b c)

/* 若是第一步没有patch完,当即,从后往前开始patch ,若是发现不一样当即跳出循环 */
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = (c2[e2] = optimized
        ? cloneIfMounted(c2[e2] as VNode)
        : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      e1--
      e2--
    }

复制代码

经历第一步操做以后,若是发现没有patch完,那么当即进行第二部,从尾部开始遍历依次向前diff。

若是发现不是相同的节点,那么当即跳出循环。

具体流程如图所示 在这里插入图片描述

③④主要针对新增和删除元素的状况,前提是元素没有发生移动, 若是有元素发生移动就要走⑤逻辑。

③ 若是老节点是否所有patch,新节点没有被patch完,建立新的vnode

(a b) (a b) c i = 2, e1 = 1, e2 = 2 (a b) c (a b) i = 0, e1 = -1, e2 = 0

/* 若是新的节点大于老的节点数 ,对于剩下的节点所有以新的vnode处理( 这种状况说明已经patch完相同的vnode ) */
    if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) {
          patch( /* 建立新的节点*/
            null,
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
          i++
        }
      }
    }

复制代码

i > e1

若是新的节点大于老的节点数 ,对于剩下的节点所有以新的vnode处理( 这种状况说明已经patch完相同的vnode ),也就是要所有create新的vnode.

具体逻辑如图所示 在这里插入图片描述

④ 若是新节点所有被patch,老节点有剩余,那么卸载全部老节点

i > e2

(a b) c

(a b)

i = 2, e1 = 2, e2 = 1

a (b c)

(b c)

i = 0, e1 = 0, e2 = -1

else if (i > e2) {
   while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
   }
}
复制代码

对于老的节点大于新的节点的状况 ,对于超出的节点所有卸载 ( 这种状况说明已经patch完相同的vnode )

具体逻辑如图所示 在这里插入图片描述

⑤ 不肯定的元素 ( 这种状况说明没有patch完相同的vnode ),咱们能够接着①②的逻辑继续往下看

diff核心

在①②状况下没有遍历完的节点以下图所示。 在这里插入图片描述

剩下的节点。 在这里插入图片描述

const s1 = i  //第一步遍历到的index
      const s2 = i 
      const keyToNewIndexMap: Map<string | number, number> = new Map()
      /* 把没有比较过的新的vnode节点,经过map保存 */
      for (i = s2; i <= e2; i++) {
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
      let j
      let patched = 0 
      const toBePatched = e2 - s2 + 1 /* 没有通过 path 新的节点的数量 */
      let moved = false /* 证实是否 */
      let maxNewIndexSoFar = 0 
      const newIndexToOldIndexMap = new Array(toBePatched)
       for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      /* 创建一个数组,每一个子元素都是0 [ 0, 0, 0, 0, 0, 0, ] */ 
复制代码

遍历全部新节点把索引和对应的key,存入map keyToNewIndexMap中

keyToNewIndexMap 存放 key -> index 的map

D : 2 E : 3 C : 4 I : 5

接下来声明一个新的指针 j,记录剩下新的节点的索引。 patched ,记录在第⑤步patched新节点过的数量 toBePatched 记录⑤步以前,没有通过patched 新的节点的数量。 moved表明是否发生过移动,我们的demo是已经发生过移动的。

newIndexToOldIndexMap 用来存放新节点索引和老节点索引的数组。 newIndexToOldIndexMap 数组的index是新vnode的索引 , value是老vnode的索引。

接下来

for (i = s1; i <= e1; i++) { /* 开始遍历老节点 */
        const prevChild = c1[i]
        if (patched >= toBePatched) { /* 已经patch数量大于等于, */
          /* ① 若是 toBePatched新的节点数量为0 ,那么统一卸载老的节点 */
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
         /* ② 若是,老节点的key存在 ,经过key找到对应的index */
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else { /* ③ 若是,老节点的key不存在 */
          for (j = s2; j <= e2; j++) { /* 遍历剩下的全部新节点 */
            if (
              newIndexToOldIndexMap[j - s2] === 0 && /* newIndexToOldIndexMap[j - s2] === 0 新节点没有被patch */
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) { /* 若是找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex */
              newIndex = j
              break
            }
          }
        }
        if (newIndex === undefined) { /* ①没有找到与老节点对应的新节点,删除当前节点,卸载全部的节点 */
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          /* ②把老节点的索引,记录在存放新节点的数组中, */
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            /* 证实有节点已经移动了 */
            moved = true
          }
          /* 找到新的节点进行patch节点 */
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
          patched++
        }
 }

复制代码

这段代码算是diff算法的核心。

第一步: 经过老节点的key找到对应新节点的index:开始遍历老的节点,判断有没有key, 若是存在key经过新节点的keyToNewIndexMap找到与新节点index,若是不存在key那么会遍历剩下来的新节点试图找到对应index。

第二步:若是存在index证实有对应的老节点,那么直接复用老节点进行patch,没有找到与老节点对应的新节点,删除当前老节点。

第三步:newIndexToOldIndexMap找到对应新老节点关系。

到这里,咱们patch了一遍,把全部的老vnode都patch了一遍。

如图所示 在这里插入图片描述 可是接下来的问题。

1 虽然已经patch过全部的老节点。能够对于已经发生移动的节点,要怎么真正移动dom元素。 2 对于新增的节点,(图中节点I)并无处理,应该怎么处理。

/*移动老节点建立新节点*/
     /* 根据最长稳定序列移动相对应的节点 */
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i
        const nextChild = c2[nextIndex] as VNode
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
        if (newIndexToOldIndexMap[i] === 0) { /* 没有老的节点与新的节点对应,则建立一个新的vnode */
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
        } else if (moved) {
          if (j < 0 || i !== increasingNewIndexSequence[j]) { /*若是没有在长*/
            /* 须要移动的vnode */
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }    
复制代码

⑥最长稳定序列

首选经过getSequence获得一个最长稳定序列,对于index === 0 的状况也就是新增节点(图中I) 须要重新mount一个新的vnode,而后对于发生移动的节点进行统一的移动操做

什么叫作最长稳定序列

对于如下的原始序列 0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15 最长递增子序列为 0, 2, 6, 9, 11, 15.

为何要获得最长稳定序列

由于咱们须要一个序列做为基础的参照序列,其余未在稳定序列的节点,进行移动。

总结

通过上述咱们大体知道了diff算法的流程

1 从头对比找到有相同的节点 patch ,发现不一样,当即跳出。

2若是第一步没有patch完,当即,从后往前开始patch ,若是发现不一样当即跳出循环。

3若是新的节点大于老的节点数 ,对于剩下的节点所有以新的vnode处理( 这种状况说明已经patch完相同的vnode )。

4 对于老的节点大于新的节点的状况 , 对于超出的节点所有卸载 ( 这种状况说明已经patch完相同的vnode )。

5不肯定的元素( 这种状况说明没有patch完相同的vnode ) 与 3 ,4对立关系。

1 把没有比较过的新的vnode节点,经过map保存
记录已经patch的新节点的数量 patched 没有通过 path 新的节点的数量 toBePatched 创建一个数组newIndexToOldIndexMap,每一个子元素都是[ 0, 0, 0, 0, 0, 0, ] 里面的数字记录老节点的索引 ,数组索引就是新节点的索引

开始遍历老节点

① 若是 toBePatched新的节点数量为0 ,那么统一卸载老的节点.

② 若是,老节点的key存在 ,经过key找到对应的index

③ 若是,老节点的key不存在 1 遍历剩下的全部新节点 2 若是找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex

④ 没有找到与老节点对应的新节点,卸载当前老节点。

⑤ 若是找到与老节点对应的新节点,把老节点的索引,记录在存放新节点的数组中,
1 若是节点发生移动 记录已经移动了 2 patch新老节点 找到新的节点进行patch节点
遍历结束

若是发生移动

① 根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列
② 对于 newIndexToOldIndexMap -item =0 证实不存在老节点 ,重新造成新的vnode 
③ 对于发生移动的节点进行移动处理。 
复制代码

三 key的做用,如何正确key。

1key的做用

在咱们上述diff算法中,经过isSameVNodeType方法判断,来判断key是否相等判断新老节点。 那么由此咱们能够总结出?

在v-for循环中,key的做用是:经过判断newVnode和OldVnode的key是否相等,从而复用与新节点对应的老节点,节约性能的开销。

2如何正确使用key

①错误用法 1:用index作key。

用index作key的效果实际和没有用diff算法是同样的,为何这么说呢,下面我就用一幅图来讲明:

在这里插入图片描述

若是所示当咱们用index做为key的时候,不管咱们怎么样移动删除节点,到了diff算法中都会从头至尾依次patch(图中:全部节点均未有效的复用)

②错误用法2 :用index拼接其余值做为key。

当已用index拼接其余值做为索引的时候,由于每个节点都找不到对应的key,致使全部的节点都不能复用,全部的新vnode都须要从新建立。都须要从新create

如图所示。 在这里插入图片描述

③正确用法 :用惟一值id作key(咱们能够用先后端交互的数据源的id为key)。

如图所示。每个节点都作到了复用。起到了diff算法的真正做用。

在这里插入图片描述

四 总结

咱们在上面,已经把刚开始的问题通通解决了,最后用一张思惟脑图来重新整理一下整个流程。diff算法,你学会了吗? 在这里插入图片描述 微信扫码关注公众号,按期分享技术文章

在这里插入图片描述