Vue 3 Virtual Dom Diff源码阅读

前言

学完了React、Vue2的diff算法,又到了学Vue3的时候了,Vue3出来了一段时间,不了解一下说不过去~
这篇文章主要分为两部分:
1、diff算法大致的流程和实现思路
2、深刻源码,看看具体的实现html

核心diff思路

diff (2).png
咱们都知道,一般咱们对比时只有当是相同的父元素时,只有当父元素是相同的节点时,才会往下遍历。那咱们假设他们的父节点是相同的,直接开始进行子节点们的比较。为了区分不一样的场景下的思路,每个部分都会举的不一样的例子。vue

预处理优化

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

一、从头开始遍历

首先会遍历开始节点,判断新老的第一个节点是否一致,一致的话,执行patch方法更新差别,而后往下继续比较,不然break跳出。能够看到下图中,A vs A 是同样的,而后去比较B,B也是同样的,而后去比较C vs D,发现不同了,因而跳出当前循环。
image.pnggit

二、尾部开始

接着咱们开始从后往前遍历,也是找相同的元素,G vs G,一致,那么执行patch后往前对比,F vs F一致,继续往前diff,直到E和C不一致,跳出循环。
image.pnggithub

三、一方已经处理完毕

目前新节点还剩下一个新增节点,那么咱们就会去判断是否老节点遍历完毕,而后新增它。下图的C节点则是要新增。
若是是老节点还剩下一个多余节点,则会去判断新节点是否遍历完毕,而后卸载它。下图的I节点则是要卸载。
image.png算法


到了这一步,确定有人想问,为何要这么作呢?

但其实你们直觉都知道是为何,平时咱们在修改列表的时候,有一些比较常见场景,好比说列表中间节点的增长、删除、修改等,若是使用了这样的方式查找,能够减小diff的时间,甚至能够不用diff来达到咱们想要的结果,而且还能够减小后续diff的复杂度。这个预处理优化策略,是Neil Fraser提出的。数组


这里应该都有了一些了解,那么接下来尚未走到的场景是新老节点都还剩余有多个子节点存在的状况。那咱们再想想,若是是咱们去作这样的一个需求,咱们会怎么作呢?dom

我第一时间想到了Vue2的方式,新老节点去遍历查找而后进行移动。可是若是这样的话,好像跟Vue2相比好像不必定更好。在Vue2遍历时,咱们使用的是交叉遍历的方式。那这种方式解决的主要是什么问题呢?举个简单的例子:
image.png
这个例子若是在咱们刚刚的流程里,是不会作任何操做的,可是Vue2去遍历的时候会进行交叉首尾遍历,而后一个个的匹配到,而且在第一次匹配到G节点的时候,就会把G节点移动到A节点前面,后续匹配ABF节点的时候,只须要去patch,可是不须要move了,由于将G节点移动到A前面后,真实DOM节点的顺序就已经与新节点一致了。
按照前面我去遍历的思路,须要移动四次,如图:
image.pngoop

那么问题来了,接下来该怎么作可以在以前优化的基础上继续优化呢?优化

好像咱们找到持续递增的那列节点,就知道哪些节点是能够稳定不变的。
这里引入一个概念,叫最长递增子序列。
官方解释:在一个给定的数组中,找到一组递增的数值,而且长度尽量的大。
有点比较难理解,那来看具体例子:

const arr = [10, 9, 2, 5, 3, 7, 101, 18]
=> [2, 3, 7, 18, 30]
这一列数组就是arr的最长递增子序列

想更深刻了解它能够看一下这道题:最长增加子序列

因此若是咱们可以找到,老节点在新节点序列中顺序不变的节点们,就知道,哪一些节点不须要移动,而后只须要把不在这里的节点插入进来就能够了。在此以前,咱们先把全部节点都找到,在找到对应的序列。

四、找到新节点对应的老节点坐标

最后一个新例子,要diff这两组节点,上面是老节点,下面是新节点~经过上面的铺垫,咱们得知了,要找到这样一个数组[2, 3, 新增, 0],不过由于数组的初始值是0,表明的是新增的意思,因此咱们将这个坐标+1,新增的变为0,也就是[3, 4, 0, 1],咱们能够当作第1位,第2位,第3位的意思。
image.png

找到这个数组就很简单了,咱们先遍历老节点,找到对应的新节点,而后加入到新节点对应的坐标上。咱们开始遍历了,在遍历过程当中,会执行patch和卸载操做,以下图表格:

当前老坐标下标 当前找到的新节点坐标 新节点坐标下所对应的旧节点数组(初始值为0,表明新增,加进来坐标+1)
0 3 [0, 0, 0, 1]
1 卸载
2 0 [3, 0, 0, 1]
3 1 [3, 4, 0, 1]

遍历完数组后,最后获得的数组为[3, 4, 0, 1],而后咱们会找到它的最长增加子序列为3, 4,它所对应的是第一个节点D和第二个节点E,因此这两个节点是不须要动的。
最后咱们再遍历新节点,若是咱们当前的节点与在最长增加子序列中,则不移动,为0则直接新增,剩下的则移动到当前位置。

到这里大体的流程就已经结束了,咱们在跟着源码进行一次深刻的了解吧~

源码

源码文件路径: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()
        }
      }
    }
  }

咱们能够直接用文本描述一下这段代码:
一、得到当前新旧节点下的子节点们(c一、c2)
二、使用patchFlag进行按位与判断fragment的子节点是否有key(patchFlag是什么稍后下面说)
三、无论有没有key,只要匹配成功必定是数组,有key/部分有key则调用patchKeyedChildren方法进行diff计算,无key则调用patchUnkeyedChildren方法
四、不是fragment节点,那么子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点
五、若是new的子节点是文本节点:old有子节点的话则直接进行卸载,并为其设置文本节点
六、不然new的子节点是数组 or 无节点,在这个基础上:
七、若是old的子节点为数组,那么new的子节点也是数组的话,调用patchKeyedChildren方法,直接full diff,不然new没有子节点,直接进行卸载
八、最后old的子节点为文本节点 or 没有节点(此时新节点可能为数组,也可能没有节点),因此当old的子节点为文本节点,那么则清空文本,new节点若是是数组的话,直接新增
九、此时全部的状况已经处理完毕了,不过真正的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()
    }
  }

咱们继续文本描述一下逻辑:
一、首先会拿到新旧节点的最短公共长度
二、而后遍历公共部分,直接进行patch
三、若是旧节点的数量大于新节点数量,直接卸载多余的节点,不然新建节点

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. 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++
    }

1.png

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

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

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

二、第二步是进行尾部遍历,遇到相同节点则继续,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--
    }

2.png

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

三、若是旧节点已遍历完毕,而且新节点还有剩余,则遍历剩下的进行新增

// 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.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. 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.一、生成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.二、遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点

// 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++
        }
      }

这段代码比较长,可是总的来讲作了下面几件事:
一、拿到新节点对应的旧节点下标newIndexToOldIndexMap(下标+1,由于0表明的是新节点没有对应的旧节点,直接建立新节点),在咱们的图例中newIndexToOldIndexMap = [4, 5, 0, 3]

二、存在在遍历的过程当中,若是老节点找到对应的新节点,则进行打补丁,更新节点差别,找不到则删除该老节点

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

5.三、对剩下没有找到的新节点进行挂载,对须要移动的节点进行移动

// 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--
          }
        }
      }

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

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

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

到这里,diff算法的核心流程就了解得差很少了~有机会再把最长子序列求解补上。

参考资料:
源码: 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://www.cnblogs.com/Windr...
相关文章
相关标签/搜索