Vue3---diff算法(快速diff算法)解析

190 阅读10分钟

diff算法整体分为5步:

const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    parentAnchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index

    // 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      ...
    }

    // 2. sync from end
    // a (b c)
    // d e (b c)
    while (i <= e1 && i <= e2) {
      ....
    }

    // 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) {
      ...
    }

    // 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) {
     ....
    }

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

整个diff的分步共分为 5 步,分别为:

  1. sync from start:自前向后的对比
  2. sync from end:自后向前的对比
  3. common sequence + mount:新节点多于旧节点,需要挂载
  4. common sequence + unmount:旧节点多于新节点,需要卸载
  5. unknown sequence:乱序

5步的对比决定了一组DOM更新时的最优方案

diff算法详细解析:

第一步: sync from start 自前向后的对比

核心的目的是:把两组 dom 自前开始,相同的 dom 节点(vnode)完成对比处理

  const patchKeyedChildren = (
    oldChildren,
    newChildren,
    container,
    parentAnchor
  ) => {
    // 索引
    let i = 0
    
    // 新的子节点的长度
    const newChildrenLength = newChildren.length
    
    //旧的子节点最大(最后一个)下标
    let oldChildrenEnd = oldChildren.length - 1
    
    // 新的子节点最大(最后一个)下标
    let newChildrenEnd = newChildrenLength - 1

    // 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
    while (i <= oldChildrenEnd && i <= newChildrenEnd) {
      const oldVNode = oldChildren[i]
      const newVNode = normalizeVNode(newChildren[i])
      // 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
      if (isSameVNodeType(oldVNode, newVNode)) {
        patch(oldVNode, newVNode, container, null)
      }
      // 如果不被认为是同一个 vnode,则直接跳出循环
      else {
        break
      }
      // 下标自增
      i++
    }

主要进行了两大步的处理逻辑:

  1. 自前向后的 diff 比对中,会 依次获取相同下标的 oldChildnewChild

    1. 如果 oldChildnewChild相同的 VNode,则直接通过 patch 进行打补丁即可
    2. 如果 oldChildnewChild不相同的 VNode,则会跳出循环
  2. 每次处理成功,则会自增 i 标记,表示:自前向后已处理过的节点数量

第二步: sync from end:自后向前的对比

核心的目的是:把两组 dom 自后开始,相同的 dom 节点(vnode)完成对比处理

// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
  const oldVNode = oldChildren[oldChildrenEnd]
  const newVNode = normalizeVNode(newChildren[newChildrenEnd])
  if (isSameVNodeType(oldVNode, newVNode)) {
    patch(oldVNode, newVNode, container, null)
  } else {
    break
  }
  // 最后的下标递减
  oldChildrenEnd--
  newChildrenEnd--
}

第三步: common sequence + mount:新节点多于旧节点,需要挂载

新旧节点数量不一致的情况。具体可以分为两种:

  1. 新节点的数量多于旧节点的数量(如:arr.push(item)
  2. 旧节点的数量多于新节点的数量(如:arr.pop(item)
// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
    if (i <= newChildrenEnd) {
        const nextPos = newChildrenEnd + 1
        // 重点:找到锚点
        const anchor =
                nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
        while (i <= newChildrenEnd) {
                patch(null, normalizeVNode(newChildren[i]), container, anchor)
                i++
        }
    }
}

上面的代码可知:

  1. 对于 新节点多于旧节点 的场景具体可以再细分为两种情况:

    1. 多出的新节点位于 尾部
    2. 多出的新节点位于 头部
  2. 这两种情况下的区别在于:插入的位置不同

  3. 明确好插入的位置之后,直接通过 patch 进行打补丁即可。

第四步:common sequence + unmount:旧节点多于新节点,需要卸载

对于旧节点多于新节点时,对应的场景也可以细分为两种:

  1. 执行arr.pop():这样可以从 尾部 删除数据。即:多出的旧节点位于 尾部
  2. 执行arr.shift():这样可以从 头部 删除数据。即:多出的旧节点位于 头部
// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
    while (i <= oldChildrenEnd) {
        // 卸载 dom
        unmount(oldChildren[i])
        i++
    }
}

第五步: unknown sequence 乱序对比------ 最长递增子序列:减少移动的次数

通过源码可以发现:vue 通过 getSequence 函数处理的最长递增子序列

// 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 {
  // 旧子节点的开始索引:oldChildrenStart
  const s1 = i 
  // 新子节点的开始索引:newChildrenStart
  const s2 = i 

  // 5.1 创建一个 <key(新节点的 key):index(新节点的位置)> 的 Map 对象 keyToNewIndexMap。通过该对象可知:新的 child(根据 key 判断指定 child) 更新后的位置(根据对应的 index 判断)在哪里
  const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
  // 通过循环为 keyToNewIndexMap 填充值(s2 = newChildrenStart; e2 = newChildrenEnd)
  for (i = s2; i <= e2; i++) {
    // 从 newChildren 中根据开始索引获取每一个 child(c2 = newChildren)
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    // child 必须存在 key(这也是为什么 v-for 必须要有 key 的原因)
    if (nextChild.key != null) {
      // key 不可以重复,否则你将会得到一个错误
      if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
        warn(
          `Duplicate keys found during update:`,
          JSON.stringify(nextChild.key),
          `Make sure keys are unique.`
        )
      }
      // 把 key 和 对应的索引,放到 keyToNewIndexMap 对象中
      keyToNewIndexMap.set(nextChild.key, i)
    }
  }

  // 5.2 循环 oldChildren ,并尝试进行 patch(打补丁)或 unmount(删除)旧节点
  let j
  // 记录已经修复的新节点数量
  let patched = 0
  // 新节点待修补的数量 = newChildrenEnd - newChildrenStart + 1
  const toBePatched = e2 - s2 + 1
  // 标记位:节点是否需要移动
  let moved = false
  // 配合 moved 进行使用,它始终保存当前最大的 index 值
  let maxNewIndexSoFar = 0
  // 创建一个 Array 的对象,用来确定最长递增子序列。它的下标表示:《新节点的下标(newIndex),不计算已处理的节点。即:n-c 被认为是 0》,元素表示:《对应旧节点的下标(oldIndex),永远 +1》
  // 但是,需要特别注意的是:oldIndex 的值应该永远 +1 ( 因为 0 代表了特殊含义,他表示《新节点没有找到对应的旧节点,此时需要新增新节点》)。即:旧节点下标为 0, 但是记录时会被记录为 1
  const newIndexToOldIndexMap = new Array(toBePatched)
  // 遍历 toBePatched ,为 newIndexToOldIndexMap 进行初始化,初始化时,所有的元素为 0
  for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
	// 遍历 oldChildren(s1 = oldChildrenStart; e1 = oldChildrenEnd),获取旧节点(c1 = oldChildren),如果当前 已经处理的节点数量 > 待处理的节点数量,那么就证明:《所有的节点都已经更新完成,剩余的旧节点全部删除即可》
  for (i = s1; i <= e1; i++) {
    // 获取旧节点(c1 = oldChildren)
    const prevChild = c1[i]
    // 如果当前 已经处理的节点数量 > 待处理的节点数量,那么就证明:《所有的节点都已经更新完成,剩余的旧节点全部删除即可》
    if (patched >= toBePatched) {
      // 所有的节点都已经更新完成,剩余的旧节点全部删除即可
      unmount(prevChild, parentComponent, parentSuspense, true)
      continue
    }
    // 新节点需要存在的位置,需要根据旧节点来进行寻找(包含已处理的节点。即:n-c 被认为是 1)
    let newIndex
    // 旧节点的 key 存在时
    if (prevChild.key != null) {
      // 根据旧节点的 key,从 keyToNewIndexMap 中可以获取到新节点对应的位置
      newIndex = keyToNewIndexMap.get(prevChild.key)
    } else {
      // 旧节点的 key 不存在(无 key 节点)
      // 那么我们就遍历所有的新节点(s2 = newChildrenStart; e2 = newChildrenEnd),找到《没有找到对应旧节点的新节点,并且该新节点可以和旧节点匹配》(s2 = newChildrenStart; c2 = newChildren),如果能找到,那么 newIndex = 该新节点索引
      for (j = s2; j <= e2; j++) {
       // 找到《没有找到对应旧节点的新节点,并且该新节点可以和旧节点匹配》(s2 = newChildrenStart; c2 = newChildren)
        if (
          newIndexToOldIndexMap[j - s2] === 0 &&
          isSameVNodeType(prevChild, c2[j] as VNode)
        ) {
          // 如果能找到,那么 newIndex = 该新节点索引
          newIndex = j
          break
        }
      }
    }
    // 最终没有找到新节点的索引,则证明:当前旧节点没有对应的新节点
    if (newIndex === undefined) {
      // 此时,直接删除即可
      unmount(prevChild, parentComponent, parentSuspense, true)
    } 
    // 没有进入 if,则表示:当前旧节点找到了对应的新节点,那么接下来就是要判断对于该新节点而言,是要 patch(打补丁)还是 move(移动)
    else {
      // 为 newIndexToOldIndexMap 填充值:下标表示:《新节点的下标(newIndex),不计算已处理的节点。即:n-c 被认为是 0》,元素表示:《对应旧节点的下标(oldIndex),永远 +1》
      // 因为 newIndex 包含已处理的节点,所以需要减去 s2(s2 = newChildrenStart)表示:不计算已处理的节点
      newIndexToOldIndexMap[newIndex - s2] = i + 1
      // maxNewIndexSoFar 会存储当前最大的 newIndex,它应该是一个递增的,如果没有递增,则证明有节点需要移动
      if (newIndex >= maxNewIndexSoFar) {
        // 持续递增
        maxNewIndexSoFar = newIndex
      } else {
        // 没有递增,则需要移动,moved = true
        moved = true
      }
      // 打补丁
      patch(
        prevChild,
        c2[newIndex] as VNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      // 自增已处理的节点数量
      patched++
    }
  }

  // 5.3 针对移动和挂载的处理
  // 仅当节点需要移动的时候,我们才需要生成最长递增子序列,否则只需要有一个空数组即可
  const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR
  // j >= 0 表示:初始值为 最长递增子序列的最后下标
  // j < 0 表示:《不存在》最长递增子序列。
  j = increasingNewIndexSequence.length - 1
  // 倒序循环,以便我们可以使用最后修补的节点作为锚点
  for (i = toBePatched - 1; i >= 0; i--) {
    // nextIndex(需要更新的新节点下标) = newChildrenStart + i
    const nextIndex = s2 + i
    // 根据 nextIndex 拿到要处理的 新节点
    const nextChild = c2[nextIndex] as VNode
    // 获取锚点(是否超过了最长长度)
    const anchor =
      nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
    // 如果 newIndexToOldIndexMap 中保存的 value = 0,则表示:新节点没有用对应的旧节点,此时需要挂载新节点
    if (newIndexToOldIndexMap[i] === 0) {
      // 挂载新节点
      patch(
        null,
        nextChild,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } 
    // moved 为 true,表示需要移动
    else if (moved) {
      // j < 0 表示:不存在 最长递增子序列
      // i !== increasingNewIndexSequence[j] 表示:当前节点不在最后位置
      // 那么此时就需要 move (移动)
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor, MoveType.REORDER)
      } else {
        // j 随着循环递减
        j--
      }
    }
  }
}

乱序处理总结

1 对新子节点建立 key 与索引的 map:keyToNewIndexMap 。

2 遍历旧子节点对能够匹配到的旧节点进行 patch 操作,对于不会存在的旧子节点进行移除操作。

3 对新子节点进行移动或新增操作

总结:

  • diff 算法主要通过 Vue 中的 patchKeyedChildren 方法来实现。

  • patchKeyedChildren 方法主要分为五个步骤来处理各场景逻辑,分别是:自前向后自后向前新节点多于旧节点旧节点多于新节点乱序比对(核心)

  • 自前向后 逻辑主要通过 i 作为下标获取新旧节点元素,再判断新旧节点的 typekey 是否相同,执行 patch 方法进行挂载更新,还是 break 跳出该逻辑。

  • 自前向后自后向前 逻辑主要区别在于一个从前向后遍历,一个从后向前遍历。

  • 新节点多于旧节点 分为向前向后新增两种情况,主要通过判断获取 anchor 锚点值来决定多余的新节点插入位置。

  • 旧节点多于新节点 同样也分向前向后删除两种情况,主要通过 unmount 方法进行多余节点的卸载。

讲解视频地址

www.bilibili.com/video/BV1Xp…