Vue diff 算法简析

445 阅读4分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

什么是 diff 算法呢?简单总结就是:diff 算法是一种对比算法,即对比新旧 VNodes 的过程(看看哪里需要变化,再去变化哪里)。对比过程中,实现在尽量不变动旧 VNodes 的前提下完成新 VNodes 的生成。

那么 diff 算法该如何实现呢?其实,上一篇文章我们已经讲了 3 种方案,性能有高有低。那么 Vue 是采用什么方案实现 diff 算法的呢?

事实上,Vue 是根据你有没有传入 key 来决定 diff 算法采用的方案的:

  • key 时,采用方案 3,执行 patchKeyedChildren() 方法;
  • 没有 key 时,采用方案 2,执行 patchUnkeyedChildren() 方法;

有关源码如下:

image-20210807211022449.png

我们先来看没有 key 的情况,拿前面的代码为例:

<ul>
  <li v-for="item in letters">{{ item }}</li>
</ul>

这时 Vue 会调用 patchUnkeyedChildren() 方法:

image-20210807220004307.png

图解如下:

image-20210809185456449.png

我们可以发现,上面的 diff 算法效率并不高:

  • CD 其实并不需要有任何改动;
  • 但由于 CF 使用了,导致后面所有的内容都要进行一次改动,并且最后再新增 D

再来看有 key 的情况,在前面的代码中添加 key 的绑定(这里因为没有 id,假设 item 是唯一的,就用 item 作为 key 值了):

<ul>
  <li v-for="item in letters" :key="item">{{ item }}</li>
</ul>

这时 Vue 会调用 patchKeyedChildren() 方法:

// 此方法在文件 packages/runtime-core/src/renderer.ts 中的 baseCreateRenderer 函数中

const patchKeyedChildren = (
  c1: VNode[], // 旧 VNodes,['A', 'B', 'C', 'D']
  c2: VNodeArrayChildren, // 新 VNodes,['A', 'B', 'F', 'C', 'D']
  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
  // 旧 VNodes 中最后一个 VNode 的位置(索引)
  let e1 = c1.length - 1 // prev ending index
  // 新 VNodes 中最后一个 VNode 的位置(索引)
  let e2 = l2 - 1 // next ending index
  // 1. sync from start
  // 从头部开始同步遍历新旧 VNodes
  // (a b) c
  // (a b) d e
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    if (isSameVNodeType(n1, n2)) { // 如果新旧 VNode 相同(type 相同并且 key 也相同)
      // 就进行 patch
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else { // 否则
      // 就跳出循环
      break
    }
    i++
  }
  // 2. sync from end
  // 从尾部开始同步遍历新旧 VNodes
  // 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)) { // 如果新旧 VNode 相同(type 相同并且 key 也相同)
      // 就进行 patch
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else { // 否则
      // 就跳出循环
      break
    }
    e1--
    e2--
  }
  // 3. common sequence + mount
  // 如果旧的 VNodes 遍历完了,新的 VNodes 还有剩余的,那就添加(挂载)这些剩余的新节点
  // (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 的第 1 个参数传入 null 时,后续会进行挂载操作
        patch(
          null,
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        i++
      }
    }
  }
  // 4. common sequence + unmount
  // 如果新的 VNodes 遍历完了,旧的 VNodes 还有剩余的,那就移除(卸载)这些剩余的旧节点
  // (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++
    }
  }
  // 5. unknown sequence
  // 如果是未知的节点序列(即中间存在不知道如何排列的位置序列),
  // 则用 key 建立 map 索引图,
  // 尽可能地从旧的 VNodes 中匹配新的 VNodes 中的 VNode(即最大限度地使用旧节点),然后移除旧的 VNodes 中剩余的 VNodes,
  // 之后是移动节点和挂载新节点
  // [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
    // 5.1 build key:index map for newChildren
    // 根据 key 建立 map 索引图
    const keyToNewIndexMap: Map<string | number, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (nextChild.key != null) {
        if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
          warn(
            `Duplicate keys found during update:`,
            JSON.stringify(nextChild.key),
            `Make sure keys are unique.`
          )
        }
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    // 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
    let patched = 0
    const toBePatched = e2 - s2 + 1
    let moved = false
    // used to track whether any node has moved
    let maxNewIndexSoFar = 0
    // works as Map<newIndex, oldIndex>
    // Note that oldIndex is offset by +1
    // and oldIndex = 0 is a special value indicating the new node has
    // no corresponding old node.
    // used for determining longest stable subsequence
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i]
      if (patched >= toBePatched) {
        // all new children have been patched so this can only be a removal
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
      }
      let newIndex
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // key-less node, try to locate a key-less node of the same type
        for (j = s2; j <= e2; j++) {
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j] as VNode)
          ) {
            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(
          prevChild,
          c2[newIndex] as VNode,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        patched++
      }
    }
    // 5.3 move and mount
    // generate longest stable subsequence only when nodes have moved
    // 拿到最长递增子序列进行 move 和 mount
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : EMPTY_ARR
    j = increasingNewIndexSequence.length - 1
    // looping backwards so that we can use last patched node as anchor
    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(
          null,
          nextChild,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (moved) {
        // move if:
        // There is no stable subsequence (e.g. a reverse)
        // OR current node is not among the stable sequence
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          j--
        }
      }
    }
  }
}

核心步骤如下:

image-20210811222300351.png

image-20210811223229645.png

图解如下:

  1. 从头部开始遍历比较:
    • 新旧节点相同时(type 相同并且 key 也相同),继续比较;
    • CFkey 不一样,跳出循环;

image-20210811224019456.png

  1. 从尾部开始遍历比较:
    • 新旧节点相同时(type 相同并且 key 也相同),继续比较;
    • BFkey 不一样,跳出循环;

image-20210811225236944.png

  1. 如果旧节点遍历完了,还有多余的新节点,那么就新增这些新节点:

image-20210812200203356.png

  1. 如果新节点遍历完了,还有多余的旧节点,那么就移除这些旧节点:

image-20210812200620697.png

  1. 最后一种情况就是未知的节点序列了,就是中间存在乱序的节点:

image-20210812202428680.png

所以,我们可以发现,Vue 在进行 diff 算法的时候,只要有 key,就会尽量利用 key 来进行优化操作:

  • 在没有 key 的时候,diff 算法效率较低;
  • 在进行插入或重置顺序的时候,保持相同的 key 可以让 diff 算法更高效;

现在,再回过头去看 关于 key 属性作用的官方解释,是不是就有更加清晰了呢?

总之,实际开发中,在使用 v-for 时,我们一般会加上 key 属性,并给 key 属性绑定上某个唯一的值(比如 id),这样在进行 diff 算法时,性能会更高。