Vue 3 Virtual Dom Diff源码阅读

丶Vin

前言

Vue3出来一段时间了,对diff算法进行了一波优化。
在阅读之前,最好需要了解一些diff算法的基础:
1、vNode是什么?
2、为什么需要使用diff算法?
传送门:VNode - 源码版

本文主要分为三个部分:
一、diff算法的流程和思路
二、深入源码,看看具体的实现以及代码的优化
三、React 16以下Vue2移动dom的方式,以及Vue3 diff的优化

Vue3 diff 思路

image.png
了解过React或者Vue2的小伙伴应该都知道,通常diff对比只有在拥有相同的父元素时,才会往下遍历。那现在假设他们的父节点是相同的,现在直接开始进行子节点们的比较。为了区分不同的场景下的思路,每一个部分都会举的不同的例子。

第一个例子在头尾遍历预处理时使用:
image.png

预处理优化

Vue2双向遍历不一样,先来看看下面这两组简单的节点对比,在Vue3中首先会进行头尾的单向遍历,进行预处理优化。

1、从头开始遍历

首先会遍历开始节点,判断新老的第一个节点是否是同一个节点,相同的话,执行patch方法更新差异,然后往下继续比较,否则break跳出。可以看到下图中,A vs A是一样的,然后去比较BB也是相同的节点,再去比较C vs F,发现不一样了
image.png

2、尾部开始遍历

接着我们开始从后往前遍历,也是找相同的元素,G vs G一致,那么执行patch后往前对比,F vs F一致,一方遍历完毕,跳出循环。
image.png

3、一方已经处理完毕

根据上面的操作,目前新节点还剩下一个新增节点C,此时会去判断是否老节点已经遍历完毕,然后直接新增真实的dom节点C
image.png
那如果是老节点还剩下一个多余节点(下图为新例子),则会去判断新节点是否遍历完成,下图的I节点则是要卸载。
image.png


到了这一步,比较核心的场景还没有出现,如果运气好,可能到这里就结束了,那我们也不能全靠运气。剩下的一个场景是新老节点都还有多个子节点存在的情况。那接下来看看,Vue3是怎么做的。为了结合move新增卸载的操作,在这里引入另一个全新的例子,。
image.png

每次在对元素进行移动的时候,我们可以发现一个规律,如果想要移动的次数最少,就意味着需要有一部分元素是稳定不动的,那么究竟能够保持稳定不动的元素有一些什么规律呢?
可以看一下上面这个例子:C H D E vs D E I C,在比对的时候,凭着肉眼可以看出只需要将C进行移动到最后,然后卸载H,新增I就好了。D E可以保持不动,可以发现D E在新老节点中的顺序都是不变的,DE的后面,下标处于递增状态

这里引入一个概念,叫最长递增子序列。
官方解释:在一个给定的数组中,找到一组递增的数值,并且长度尽可能的大。
有点比较难理解,那来看具体例子:

const arr = [10, 9, 2, 5, 3, 7, 101, 18]
=> [2, 3, 7, 18]
这一列数组就是arr的最长递增子序列,其实[2, 3, 7, 101]也是。
所以最长递增子序列符合三个要求:
1、子序列内的数值是递增的
2、子序列内数值的下标在原数组中是递增的
3、这个子序列是能够找到的最长的
但是我们一般会找到数值较小的那一组数列,因为他们可以增长的空间会更多。

那接下来的思路是:如果能找到老节点在新节点序列中顺序不变的节点们,就知道,哪一些节点不需要移动,然后只需要把不在这里的节点插入进来就可以了。因为最后要呈现出来的顺序是新节点的顺序,移动是只要老节点移动,所以只要老节点保持最长顺序不变,通过移动个别节点,就能够跟它保持一致。所以在此之前,先把所有节点都找到,再找对应的序列。最后其实要得到的则是这一个数组:[2, 3, 新增 , 0]。其实diff移动的思路已经清楚了,接下来就是看看怎么从代码逻辑中去实现这段逻辑了。
image.png

4、patch && unmount

通过上面的铺垫,得知了要找到这样一个数组[2, 3, 新增, 0],不过因为数组的初始值是0,代表的是新增的意思,所以其他元素坐标顺延+10仅代表新增,最后也就是[3, 4, 0, 1],可以看成第1位,第2位,第3位的意思。

找到这个数组就很简单了,先初始化一个数组:[0, 0, 0, 0],再遍历老节点,找到对应的新节点,然后加入到新节点对应的坐标上。
开始遍历了,在遍历过程中,会执行patchunmount操作,如下图表格:

当前老坐标下标当前找到的新节点坐标新节点坐标下所对应的旧节点数组(初始值为0,代表新增,加进来坐标+1)
03[0, 0, 0, 1]
1卸载,执行unmount方法
20[3, 0, 0, 1]
31[3, 4, 0, 1]

跟着上面的表格,可以看元素变化图,以及真实的dom节点做了什么操作:

1、遍历老节点,拿到第一个节点C,去新节点列表中找相同的节点,找到新节点中有C,在第三位,下标为3,于是在数组的第3位下标中,把当前老节点的下标加进去,由于前面说过,坐标都要+1,所以此时数组为[0, 0, 0, 1],并且此时也会去执行patch方法,会将新旧节点的差异部分对齐,比如新旧C节点仅有class不一致,此时便会去执行更新class的方法。
image.png
2、遍历到第二位HH在新节点中找不到,所以会直接执行unmount方法,去卸载H,此时真实dom也发生了变化。
image.png
3、遍历到第三位D,继续去新节点列表中找相同的节点D,下标为0,于是在数组的第0位下标中,把当前老节点的下标+1塞进数组,所以此时数组为[3, 0, 0, 1],并且此时也会去执行patch方法。
image.png
4、遍历到第四位E,同理,在新节点中找到后把当前老节点的下标+1塞进数组,所以此时数组为[3, 4, 0, 1],并且此时也会去执行patch方法。
image.png

遍历完后,最后得到了一个[3, 4, 0, 1]的数组,并且此时已经执行了有相同节点的patch方法和多余节点的unmount方法。
通过肉眼可以看到,它的最长增长子序列为[3, 4](本文不做最长递增子序列求解,想了解求解,请移步最长递增子序列求解传送门)。[3, 4]的下标为[0, 1],也就是说新节点的第0位D和第1位E不需要动。

5、move && mount

它所对应的是第一个节点D和第二个节点E,所以这两个节点是不需要动的。
最后再遍历数组[3, 4, 0, 1],如果当前的节点与在最长增长子序列中,则不移动,为0直接新增,剩下的则move到当前位置。接下来想深入了解的话,可以看一下第二部分的源码,Vue3 diff的这段源码还是比较清晰的。

源码

源码文件路径:packages/runtime-core/src/renderer.ts
源码仓库地址:vue-next

patchChildren

我们从patchChildren方法开始,进行子节点之间的比较。

const patchChildren: PatchChildrenFn = () => {
    // 获得当前新旧节点下的子节点们
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fragment有两种类型的静态标记:子节点有key、子节点无key
    if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 子节点全部或者部分有key
        patchKeyedChildren()
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 子节点没有key
        patchUnkeyedChildren()
        return
      }
    }

    // 子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 匹配到当前是文本节点:卸载之前的节点,为其设置文本节点
      unmountChildren()
      hostSetElementText()
    } else {
      // old子节点是数组
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 现在(new)也是数组(至少一个子节点),直接full diff(调用patchKeyedChildren())
        } else {
          // 否则当前没有子节点,直接卸载当前所有的子节点
          unmountChildren()
        }
      } else {
        // old的子节点是文本或者没有
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
          // 清空文本
          hostSetElementText(container, '')
        }
        // 现在(new)的节点是多个子节点,直接新增
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新建子节点
          mountChildren()
        }
      }
    }
  }

我们可以直接用文本描述一下这段代码:
1、获得当前新旧节点下的子节点们(c1、c2);
2、使用patchFlag进行按位与判断fragment的子节点是否有key(patchFlag是什么稍后下面说);
3、不管有没有key,只要匹配成功一定是数组,有key/部分有key则调用patchKeyedChildren方法进行diff计算,无key则调用patchUnkeyedChildren方法;
4、不是fragment节点,那么子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点;
5、如果new的子节点是文本节点:old有子节点的话则直接进行卸载,并为其设置文本节点;
6、否则new的子节点是数组 or 无节点,在这个基础上:

如果old的子节点为数组,那么new的子节点也是数组的话,调用patchKeyedChildren方法,直接full diff,否则new没有子节点,直接进行卸载。
最后old的子节点为文本节点 or 没有节点(此时新节点可能为数组,也可能没有节点),所以当old的子节点为文本节点,那么则清空文本,new节点如果是数组的话,直接新增。

7、此时所有的情况已经处理完毕了,不过真正的diff还没开始,那我们来看一下没有key的情况下,是如何进行diff的。

patchUnkeyedChildren

没有key的处理比较简单,直接上删减版源码

const 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
    // 遍历新旧节点,进行patch
    for (i = 0; i < commonLength; i++) {
      // 如果新节点已经挂载过了(已经过了各种处理),则直接clone一份,否则创建一个新的vnode节点
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch()
    }
    // 如果旧节点的数量大于新节点数量
    if (oldLength > newLength) {
      // 直接卸载多余的节点
      unmountChildren( )
    } else {
      // old length < new length => 直接进行创建
      mountChildren()
    }
  }

我们继续文本描述一下逻辑:
1、首先会拿到新旧节点的最短公共长度
2、然后遍历公共部分,直接进行patch
3、如果旧节点的数量大于新节点数量,直接卸载多余的节点,否则新建节点

patchKeyedChildren

到了Diff算法比较核心的部分,我们先看一个大概预览,了解一下流程~再把patchKeyedChildren源码内部拆分一下,逐步来看。

  const patchKeyedChildren = () => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index

    // 1. 进行头部遍历,遇到相同节点则继续,不同节点则跳出循环
    while (i <= e1 && i <= e2) {}

    // 2. 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
    while (i <= e1 && i <= e2) {}

    // 3. 如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增
    if (i > e1) {
      if (i <= e2) {}
    }

    // 4. 如果新节点已遍历完毕,并且旧节点还有剩余,则直接卸载
    else if (i > e2) {
      while (i <= e1) {}
    }

    // 5. 新旧节点都存在未遍历完的情况
    else {
      // 5.1 创建一个map,为剩余的新节点存储键值对,映射关系:key => index
      // 5.2 遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点
      // 5.3 拿到最长递增子序列进行move or 新增挂载
    }
  }

1、第一步是进行头部遍历,遇到相同节点则继续,下标 + 1,不同节点则跳出循环

    // 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      // 如果新节点已经挂载过了(已经经历了各种处理),则直接clone一份,否则创建一个新的vnode节点
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      // 相同节点,则继续执行patch方法  
      if (isSameVNodeType(n1, n2)) {
        patch()
      } else {
        break
      }
      i++
    }

image.png

此时i = 2, e1 = 6, e2 = 7, 旧节点剩下C、D、E、F、G,新节点剩下D、E、I、C、F、G

这里判断是否为相同节点的方法isSameVNodeType,是通过类型和key来进行判断,在Vue2中是通过key和sel(属性选择器:tag + id + class)来判断是否是相同元素。这里的类型指的是ShapeFlag,也是一个标志位,是对元素的类型进行不同的分类,比如:元素、组件、fragment、插槽等等

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

2、第二步是进行尾部遍历,遇到相同节点则继续,length - 1,不同节点则跳出循环

    // 2. sync from end
    // a (b c)
    // d e (b c)
    // 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
    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()
      } else {
          break
      }
      e1--
      e2--
    }

image.png

此时i = 2, e1 = 4, e2 = 5, 旧节点剩下C、D、E,新节点剩下D、E、I、C

3、如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增

    // 3.common sequence + mount
    // (a b)
    // (a b) c
    // i = 2, e1 = 1, e2 = 2
    // (a b)
    // c (a b)
    // i = 0, e1 = -1, e2 = 0
    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]) // 节点新增(伪代码)
          i++
        }
      }
    }

因为我们上面的图例(i < e1)走不到这段逻辑,所以我们可以直接看一下代码注释(注释真的写得非常详细了,patchKeyedChildren里面的原注释我都保留了)。如果旧节点遍历完毕,开头或者尾部还剩下了新节点,则进行节点新增(通过传参,patch内部会处理)。

4、如果新节点已经遍历完毕,则说明多余的节点需要卸载

    // 4.common sequence + unmount
    // (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++
      }
    }

因为我们上面的图例(i < e2)依然走不到这段逻辑,所以我们可以继续看一下原注释。i > e2意味着新节点遍历完毕,如果新节点遍历完毕,开头或者尾部还剩下了旧节点,则进行节点卸载unmount

5、新旧节点都没有遍历完成的情况

    // 5. unknown sequence
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e d c h] f g
    // i = 2, e1 = 4, e2 = 5
    else {
      const s1 = i // prev starting index
      const s2 = i // next starting index
      
      ...
    }

按照上面图的例子来看,s1 = 2, s2 = 2,旧节点剩下C、D、E,新节点剩下D、E、I、C需要继续进行diff

5.1、生成map对象,通过键值对的方式存储新节点的key => index

      // 5.1 build key:index map for newChildren
      // 创建一个空的map对象
      const keyToNewIndexMap = new Map()
      // 遍历剩下没有patch的新节点,也就是D、E、I、H
      for (i = s2; i <= e2; i++) {
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i]))
        // 如果剩余的新节点有key的话,则将其存储起来,key对应index
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

执行完上面的方法,得到keyToNewIndexMap = {D => 2, E => 3, I => 4, C => 5},keyToNewIndexMap主要用来干嘛呢~请继续往下看

5.2、遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点

      // 5.2 loop through old children left to be patched and try to patch
      // matching nodes & remove nodes that are no longer present
      
      let j
      // 记录即将被patch过的新节点数量
      let patched = 0
      // 拿到剩下要遍历的新节点的长度,按照上面的图示toBePatched = 4
      const toBePatched = e2 - s2 + 1
      // 是否发生过移动
      let moved = false
      // 用于跟踪是否有任何节点移动
      let maxNewIndexSoFar = 0
      
      // works as Map<newIndex, oldIndex>
      // 注意:旧节点 oldIndex偏移量 + 1
      // 并且oldIndex = 0是一个特殊值,代表新节点没有对应的旧节点
      // newIndexToOldIndexMap主要作用于最长增长子序列
      // newIndexToOldIndexMap从变量名可以看出,它代表的是新旧节点的对应关系
      const newIndexToOldIndexMap = new Array(toBePatched)
      
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      // 此时newIndexToOldIndexMap = [0, 0, 0, 0]
      // 遍历剩余旧节点的长度
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i]
        if (patched >= toBePatched) {
          // patched大于剩余新节点的长度时,代表当前所有新节点已经patch了,因此剩下的节点只能卸载
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
        if (prevChild.key != null) {
          // 旧节点的key存在的话,则通过旧节点的key找到对应的新节点的index位置下标
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 旧节点没有key的话,则遍历所有的新节点
          for (j = s2; j <= e2; j++) {
            // newIndexToOldIndexMap[j - s2]如果等于0的话
            // 代表当前新节点还没有被patch,因为在下面的运算中
            // 如果找到新节点对应的旧节点位置,newIndexToOldIndexMap[j - s2]则会等于旧节点的下标 + 1
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) {
              // 当前新节点还没有被找到,并新旧节点相同,则将新节点的位置赋予newIndex
              newIndex = j
              break
            }
          }
        }
        
        if (newIndex === undefined) {
          // 当前旧节点没有找到对应的新节点,则进行卸载
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          // 找到了对应的新节点,则将旧节点的位置存储在对应的新节点下标
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          // maxNewIndexSoFar如果不是逐级递增,则代表有新节点的位置前移了,那么需要进行移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          // 更新节点差异
          patch()
          // 找到一个对应的新节点,+1
          patched++
        }
      }

这段代码比较长,但是总的来说做了下面几件事:
1、拿到新节点对应的旧节点下标newIndexToOldIndexMap(下标+1,因为0代表的是新节点没有对应的旧节点,直接创建新节点),在我们的图例中newIndexToOldIndexMap = [4, 5, 0, 3]

2、存在在遍历的过程中,如果老节点找到对应的新节点,则进行打补丁,更新节点差异,找不到则删除该老节点

3️、通过新节点下标的顺序是否递增来判断,是否有节点发生过移动

5.3、对剩下没有找到的新节点进行挂载,对需要移动的节点进行移动

      // 5.3 move and mount
      // 仅在有节点需要移动的时候才生成最长递增子序列
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      // 此时图示中的increasingNewIndexSequence = [4, 5]
      // 从后面开始遍历,将最后一个patch的节点用作锚点
      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) {
          // mount new
          patch( )
        } else if (moved) {
          // 移动的条件:当前最长子序列的length小于0(没有稳定的子序列),或者当前的节点不在稳定的序列中
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }
        }
      }

最后这段源码用到了一个优化方法,最长上升子序列,这段大致的流程就是:
1、通过moved来判断当前是否有节点进行了移动,如果有的话则通过getSequence(newIndexToOldIndexMap)拿到最长上升子序列,我们的图示中拿到的是increasingNewIndexSequence = [4, 5]

2、遍历剩余新节点的长度,从后面开始遍历,判断newIndexToOldIndexMap[i] === 0,当前的新节点是否有对应的老节点,如果等于0,就是没有,直接新增;

3、否则通过moved判断是否有移动,有移动的话,如果当前最长子序列的length < 0,或者当前的节点不在稳定的序列中,则意味着现在没有稳定的子序列,每个节点需要进行移动,或者,最后一个新节点,不在末尾的子序列中,子序列的末尾另有他人,那当前也需要进行移动。若是不符合移动的条件,则说明当前新节点在最长上升子序列中,不需要进行移动,只用等待别的节点去移动。

到这里,diff算法源码核心流程就了解得差不多了,接下来看看Vue3对比React/Vue2的优化点。

React、Vue2、Vue3

这里可以大体回顾一下ReactVue2移动dom的思路,用两个比较典型的例子进行介绍,这里主要对比的是,相同父元素的子元素之间的diff移动操作,所以在下面的例子中,都是在同一层面下,A、B、C、D为各自的key
image.png

React 16以下的版本

React在移动dom节点时,是只会往右边进行移动的
从新节点开始遍历,在老节点中找相同的节点

左边:普遍情况
1、B:在老节点中的第二位找到了相同的元素,由于上面说过,React只会往右移动,它会用一个变量记录所有找到的老节点的下标,这个变量叫做lastIndex,初始值为0,每找到一个比lastIndex大的老节点下标都会更新它,所以它永远是找到过的老节点下标的最大的那个值。并且每次找到的老节点的下标一定要小于lastIndex才可以往右移动,这句话这么理解,因为我们是要往右边进行移动的,往右边走是增大的,所以每次找到的老节点下标,如果比上次找到的老节点下标大的话,那就说明位置顺序是正常的,没有往右边移动的必要了。

那么此时,因为lastIndex的初始值是0,找到的老节点的下标为1,则无需移动,因为找到的老节点已经比较靠右了,但需要更新lastIndex
image.png

2、A:上次记录的lastIndex已经更新到了1,此时找到的老节点A的位置为0,则需要进行右移。无需更新lastIndex
image.png

3、D:当前lastIndex还是1,此时找到的老节点D的位置为3,是大于上次找到的老节点的最大位置的,所以无需移动。lastIndex更新为3
image.png

4、ClastIndex更新到了3,此时找到的老节点C的位置为2,因为实际上新节点中C是靠右的,不能比上次找到的老节点位置小,所以C需要移动。
image.png

我们可以看到,在这种情况下,真实dommove了两次,patch了四次。而实际上,这种移动次数也是最少的。但是往右移动这种方式,仔细想想,好像并不是所有场景下都那么完美的。比如,上面我们提到的右边的那种情况。

右边:极端情况
image.png

通过我们上面方法的比对,来看看移动路径:
1、D:由于找到的老节点在最右边,已经为最右边了,不能往右边移动了,所以暂定不动。
image.png

2、A:移动到最右边。
image.png

3、B:移动到最右边。
image.png

4、C:移动到最右边。
image.png

明眼人都可以看出来,其实最好的移动方式只要移动一次就能够达到我们想要的效果,但是React需要移动三次。

Vue2

刚刚在React中右边例子的情况还有优化空间,那可以看看Vue2是怎么解决这个问题的。

Vue2为双向遍历,同时从前后往中间进行遍历
遍历的顺序:新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾

右边:极端情况
1、按照上面说的遍历的顺序,先是进行新旧节点当前首位A vs D进行diff,不相同让新旧节点当前末位 D vs C进行diff,不相同继续让旧节点当前首位和新节点当前末位 A vs C进行diff,不相同继续让新节点当前首位和旧节点当前末位 D vs D进行diff,哦豁,皇天不负有心人,第四次寻找终于找到了,因为新旧节点的位置是一前一后,显然不应该,于是找到D后将它挪到前面来。之后两边的指针各挪一步。
image.png

2、紧接着,重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾这个遍历顺序,但是第一次就找到了A vs A,他们都是第一个,不需要移动,然后指针+1。
image.png

3、继续重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾这个遍历顺序,找到了B vs B,他们都是第一个,不需要移动,然后指针+1。
image.png

4、继续重复进行新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾这个遍历顺序,找到了C vs C,他们都是第一个,不需要移动,然后指针+1。
image.png

最后的结果是,所有的都遍历完了,最后只是将D挪到了第一位而已,对上面的情况进行了优化。当然,这是一个非常理想的状态,那接下来来看看,普遍的情况是怎么做的。

左边:普遍情况
前面我们说过了那个遍历的顺序:新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾,但是总不能每次都能够在最两边找到元素,接下来就来看看,如果前面的规则走完之后该怎么办。

1、前面的四次判断没有发现相同的新节点的话,则会遍历老节点找当前首位新节点B(箭头指向的第一位新节点),遍历老节点发现了B在第二位,那我们现在才遍历到第一位,显然与我们想要的位置不符,于是将B的真实dom移动到第一位,并将老节点列表原本B的位置设置为undefined,之后遇到undefined的元素一律跳过。然后新节点初始下标+1。
image.png

2、新节点的下标+1后,我们的循环又重新开始了,又会按照前面的顺序进行判断(新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾),于是遍历到了A vs A,新旧节点的指针往后增加一位。
image.png
3、由于前面我们说过,B已经是undefined了,所以会跳过该元素,直接到C,新旧节点的C分别是末位和首位,于是移动C的真实dom到最后一位。旧节点首位指针+1,新节点末位指针-1。
image.png
4、最后只剩下了D元素,也就不需要移动了。
image.png

Vue2在移动dom的上面已经做的比较好了,并且只会移动必须要挪动的那部分元素。通过前面的了解,Vue3好像并没有在移动dom上有更多的优化,那Vue3在diff的过程中优化了什么呢?

优化

1、预处理优化
平时我们在修改列表的时候,有一些比较常见场景,比如说列表中间节点的增加、删除、修改等,如果使用了这样的方式查找,可以减少diff的时间,甚至可以不用diff来达到我们想要的结果,并且还可以减少后续diff的复杂度。这个预处理优化策略,是Neil Fraser提出的。
举个栗子:
像下面这些情况,在预处理中能够很快的做完这些处理。但是在Vue2中,会需要一个接一个的重复新头 vs 旧头、新尾 vs 旧尾、旧头 vs 新尾、新头 vs 旧尾。当然,Vue3也不是在所有场景中都是最优的。
image.png

2、PatchFlags静态节点优化
Vue2patch阶段时会进行全量diff,但是有的节点只声明了动态文本或者动态class,明明可以知道他是不变的,为什么还需要去diff它呢?所以Vue3在这个过程中做了一些优化,我们来看看这个叫做PatchFlags的东西是什么(只截了部分代码)。

export const enum PatchFlags {
  // 动态文本
  TEXT = 1,

  // 动态class
  CLASS = 1 << 1,

  // 动态style
  STYLE = 1 << 2,

  // 动态props
  PROPS = 1 << 3,

  // 动态变化的属性,比如:[attr] = "foo"
  FULL_PROPS = 1 << 4,

  ...
}

那翻译一下,其实这个枚举是长这样的:

export const enum PatchFlags {
  // 动态文本
  // 十进制: 1
  // 二进制: 0000 0001
  TEXT = 1, 

  // 动态class
  // 十进制: 1
  // 二进制: 1往左移一位: 0000 0001
  CLASS = 1 << 1,

  // 动态style
  // 十进制: 1
  // 二进制: 1往左移两位: 0000 0010
  STYLE = 1 << 2,

  // 动态props
  // 十进制: 1
  // 二进制:1往左移三位: 0000 0100
  PROPS = 1 << 3,

  ...
}

举个例子:
image.png
这个div,在编译的过程中,会执行这样一段代码(伪代码):

// 有动态绑定的class将执行
if (hasClassBinding) {
    patchFlag |= PatchFlags.CLASS
}
// 有动态绑定的style将执行
if (hasStyleBinding) {
    patchFlag |= PatchFlags.STYLE
}
// 有动态文本节点将执行
if (hasDynamicTextChild) {
    patchFlag |= PatchFlags.TEXT
}

因为上面的例子中只有动态class和style,没有动态文本节点,所以只会执行:

patchFlag |= PatchFlags.CLASS
patchFlag |= PatchFlags.STYLE    

patchFlag |= PatchFlags.CLASS执行过程:

// 因为patchFlag初始值为0,所以第一次执行或运算时:
// 按位或:二进制位进行比对计算,有一个是1,结果的对应位就是1。
// 0000 0000
// 0000 0010
// ---------
// 0000 0010

那么此时二进制10等于十进制2,所以patchFlag = 2
紧接着执行patchFlag |= PatchFlags.STYLE:

// 因为patchFlag此时为2,所以执行或运算时使用二进制10:
// 0000 0010
// 0000 0100
// ---------
// 0000 0110

那么此时二进制110等于十进制6,所以patchFlag = 6

然后在diff阶段执行patch方法的时候会将patchFlag取出来然后执行这一段代码,这段代码意味着,只有当括号内计算为的时候才会执行if内的方法:
image.png

首先会判断patchFlag & PatchFlags.TEXT是否为真,这一次是会进行与运算,执行过程:

// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110  ----当前patchFlag
// 0000 0001  ----常量PatchFlags.TEXT
// ---------
// 0000 0000

换算之后的结果等于0,所以不会执行修改文本节点的方法。当然,在我们的例子中文本节点是静态节点,也无需去比对修改。
接着会判断patchFlag & PatchFlags.CLASS

// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110  ----当前patchFlag
// 0000 0010  ----常量PatchFlags.CLASS
// ---------
// 0000 0010

此时十进制结果为2,则认为是动态节点,需要去执行修改class的方法,之后再判断patchFlag & PatchFlags.STYLE

// 按位与:二进制位进行比对计算,两个都是1,结果的对应位就才是1。
// 因为patchFlag此时等于6,所以第一次执行或运算时:
// 0000 0110  ----当前patchFlag
// 0000 0100  ----常量PatchFlags.STYLE
// ---------
// 0000 0100

同理,也会执行修改style的方法。

当然Vue3做的优化不止这么多,大家可以多看看源码,会有很多代码优化上的收获。

参考资料:
源码:https://github.com/vuejs/vue-...
diff优化策略:https://neil.fraser.name/writ...
inforno:https://github.com/infernojs/inferno
https://blog.csdn.net/u014125...
https://zhuanlan.zhihu.com/p/...
https://hustyichi.github.io/2...
https://segmentfault.com/a/11...
阅读 5k

harding

178 声望
13 粉丝
0 条评论

harding

178 声望
13 粉丝
文章目录
宣传栏