likes
comments
collection
share

Vue3源码解析之 diff(一)

作者站长头像
站长
· 阅读数 3

前言

前面文章中我们都是讨论了单个子节点的更新,如果新旧子节点为多个,那么它们更新时就存在节点位置的交换、新增、删除、插入等场景。Vue 中主要通过 patchKeyedChildren 方法来实现,也就是我们所说的 diff 算法。

它分为五个步骤,分别是:自前向后自后向前新节点多于旧节点旧节点多于新节点乱序比对(核心)。针对这五个场景分别做了不同的逻辑处理,其中 乱序比对diff 算法的核心,这块逻辑我们放到下一篇来着重讲解。

下面我们先来讲解第一个场景 自前向后,依旧通过案例的形式来逐一分析。

案例一

首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b ckey1 2 3,两秒后更新渲染 vnode2 对象,我们将第三个子节点内容修改为 dkey 保持不变。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="../../../dist/vue.global.js"></script>
  </head>

  <body>
    <div id="app"></div>
    <script>
      const { h, render } = Vue

      const vnode1 = h('ul', [
        h('li', { key: 1 }, 'a'),
        h('li', { key: 2 }, 'b'),
        h('li', { key: 3 }, 'c')
      ])

      render(vnode1, document.querySelector('#app'))

      setTimeout(() => {
        const vnode2 = h('ul', [
          h('li', { key: 1 }, 'a'),
          h('li', { key: 2 }, 'b'),
          h('li', { key: 3 }, 'd')
        ])

        render(vnode2, document.querySelector('#app'))
      }, 2000)
    </script>
  </body>
</html>

自前向后

diff 算法的触发依旧通过 render 函数的 patchChildren 方法:

const patchChildren: PatchChildrenFn = (
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized = false
  ) => {
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fast path
    if (patchFlag > 0) {
      // 省略
    }

    // children has 3 possibilities: text, array or no children.
    // 新节点为 text 节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 省略
    } else {
      // 旧节点为 array 节点
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // prev children was array
        // 新节点为 array 节点
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // two arrays, cannot assume anything, do full diff
          patchKeyedChildren(
            c1 as VNode[],
            c2 as VNodeArrayChildren,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else {
         // 省略
        }
      } else {
        // 省略
      }
    }
  }

当前新旧子节点都为数组,执行 patchKeyedChildren 方法:

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) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      i++
    }

    // 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(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      e1--
      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) {
      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,
            slotScopeIds,
            optimized
          )
          i++
        }
      }
    }

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

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

      // 5.1 build key:index map for newChildren
      const keyToNewIndexMap: Map<string | number | symbol, 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
      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--
          }
        }
      }
    }
  }

可以看出 Vue 中已经分别做了注释说明,我们先来看第一段逻辑,也就是 自前向后

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) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  i++
}

l2 为新子节点的长度,当前为 3e1e2 分别为新旧子节点最后一个元素的下标,当前为 2。之后从前向后扫描,执行第一遍历。当前 n1n2 节点都为 akey 都为 1,根据判断 if (isSameVNodeType(n1, n2)),我们再看下 isSameVNodeType 方法:

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
  ) {
    // HMR only: if the component has been hot-updated, force a reload.
    return false
  }
  return n1.type === n2.type && n1.key === n2.key
}

这里除了节点的 type 类型相同,且 key 也必须相同才能表示两个节点是相同的,所以这就是为什么 v-for 需要设置 key 的原因。

由于 n1n2 相同,执行 patch 方法第一次挂载更新完成。之后第二次遍历,当前新旧节点都为 bkey 都为 2,再次执行 patch 方法挂载更新。第三次遍历,n1 旧子节点内容为 cn2 新子节点内容为 d,由于新旧子节点的 typekey 均相同,执行 patch 更新,先看下之前页面呈现:

Vue3源码解析之 diff(一)

执行后,页面呈现:

Vue3源码解析之 diff(一)

至此,自前向后 逻辑执行完毕,我们大致可以总结为:

  1. 它会根据 i 作为下标获取到新旧节点的元素。
  2. 如果新旧节点的 type 类型和 key 相同,则会执行 patch 方法进行挂载更新。
  3. 如果不相同则会 break 跳出循环,结束该逻辑。

我们再来看下第二段逻辑 自后向前

案例二

首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b ckey1 2 3,两秒后更新渲染 vnode2 对象,我们将第一个子节点的 key 修改为 4,第三个子节点的内容修改为 d

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="../../../dist/vue.global.js"></script>
  </head>

  <body>
    <div id="app"></div>
    <script>
      const { h, render } = Vue

      const vnode1 = h('ul', [
        h('li', { key: 1 }, 'a'),
        h('li', { key: 2 }, 'b'),
        h('li', { key: 3 }, 'c')
      ])

      render(vnode1, document.querySelector('#app'))

      setTimeout(() => {
        const vnode2 = h('ul', [
          h('li', { key: 4 }, 'a'),
          h('li', { key: 2 }, 'b'),
          h('li', { key: 3 }, 'd')
        ])

        render(vnode2, document.querySelector('#app'))
      }, 2000)
    </script>
  </body>
</html>

自后向前

根据案例二,先执行 自前向后 的逻辑,第一遍历,由于 n1n2 新节点的 key 不同直接 break 跳出循环,所以此时 ie1e2 均未变化:

Vue3源码解析之 diff(一)

接着执行第二段逻辑即 自后向前

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,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  e1--
  e2--
}

可以看出该逻辑是从后向前遍历,执行第一次遍历,此时 n1cn2d,新旧节点的 typekey 相同执行 patch 方法挂载更新,此时页面呈现:

Vue3源码解析之 diff(一)

执行第二次遍历,新旧节点 typekey 相同,内容都为 b,执行 patch 方法挂载更新,之后执行第三次遍历,新旧节点 key 不同,执行 break 跳出循环,当前 ie1e2 均为 0,最终页面呈现:

Vue3源码解析之 diff(一)

接下来我们再看下 新节点多于旧节点 逻辑。

案例三

首先引入 h 、 render函数,先渲染 vnode1 对象,它包含两个子节点 a bkey1 2,两秒后更新渲染 vnode2 对象,相比 vnode1 对象多了个子节点 c

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="../../../dist/vue.global.js"></script>
  </head>

  <body>
    <div id="app"></div>
    <script>
     const { h, render } = Vue

      const vnode1 = h('ul', [
        h('li', { key: 1 }, 'a'),
        h('li', { key: 2 }, 'b')
      ])

      render(vnode1, document.querySelector('#app'))

      setTimeout(() => {
        // 在后面新增
        const vnode2 = h('ul', [
          h('li', { key: 1 }, 'a'),
          h('li', { key: 2 }, 'b'),
          h('li', { key: 3 }, 'c')
        ])
        
        // 在前面新增
        // const vnode2 = h('ul', [
        // h('li', { key: 3 }, 'c'),
        // h('li', { key: 1 }, 'a'),
        // h('li', { key: 2 }, 'b')
        // ]) 

        render(vnode2, document.querySelector('#app'))
      }, 2000)
    </script>
  </body>
</html>

新节点多于旧节点

新节点多于旧节点情况分为两种,一种是在后面增加,一种是在前面增加。我们先看下在后面增加情况,执行第三段逻辑即 新节点多于旧节点

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,
        slotScopeIds,
        optimized
      )
      i++
    }
  }
}

根据案例三,先执行 自前向后自后向前 两块逻辑,ab 节点挂载更新完毕,当前 ie1e2l2 值为:

Vue3源码解析之 diff(一)

根据判断 i > e1i <= e2 表示旧节点小于新节点,通过 e2 + 1 获取新节点下一个节点的下标位置。再根据判断获取到锚点 anchor,该锚点也就代表新节点要被插入到哪一个位置上去,当前 parentAnchornull。之后执行 patch 方法挂载更新新节点 c,页面呈现:

Vue3源码解析之 diff(一)

我们再看下在前面增加新节点的情况,修改案例三,将 vnode2 设置为:

const vnode2 = h('ul', [
    h('li', { key: 3 }, 'c'),
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b')
])

先执行 自前向后 逻辑,由于第一个节点比较, key 不同直接 break 跳出循环。接着执行 自后向前 逻辑,ab 节点相同,patch 挂载更新完后,当前 ie1e2l2 值为:

Vue3源码解析之 diff(一)

之后同之前逻辑获取锚点 anchor,当前 nextPos < l2,获取下一个节点元素的 el,即 c2[1].el,也就是节点为 ael 元素,最后通过 patch 方法挂载更新 c2[i]c2[0],也就是节点为 c 的元素插入到节点为 a 的元素前,页面呈现:

Vue3源码解析之 diff(一)

我们再看下 旧节点多于新节点 的逻辑。

案例四

首先引入 h 、 render函数,先渲染 vnode1 对象,它包含三个子节点 a b ckey1 2 3,两秒后更新渲染 vnode2 对象,相比 vnode1 对象少了个子节点 c

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="../../../dist/vue.global.js"></script>
  </head>

  <body>
    <div id="app"></div>
    <script>
     const { h, render } = Vue

       const vnode1 = h('ul', [
         h('li', { key: 1 }, 'a'),
         h('li', { key: 2 }, 'b'),
         h('li', { key: 3 }, 'c')
       ])

      // const vnode1 = h('ul', [
      //   h('li', { key: 3 }, 'c'),
      //   h('li', { key: 1 }, 'a'),
      //   h('li', { key: 2 }, 'b')
      // ])

      render(vnode1, document.querySelector('#app'))

      setTimeout(() => {
        const vnode2 = h('ul', [
          h('li', { key: 1 }, 'a'),
          h('li', { key: 2 }, 'b')
        ])

        render(vnode2, document.querySelector('#app'))
      }, 2000)
    </script>
  </body>
</html>

旧节点多于新节点

根据案例四,先执行 自前向后 逻辑,ab 节点相同,patch 挂载更新,之后执行 自后向前新节点多于旧节点 逻辑,当前 ie1e2l2 值为:

Vue3源码解析之 diff(一)

接着执行第四段逻辑即 旧节点多于新节点

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

根据判断 i > e2 表示 旧节点多于新节点,执行 unmount 方法 卸载,页面呈现:

Vue3源码解析之 diff(一)

该场景同样也分为向前向后两种情况,大家不妨可以修改下案例四,重新调试执行,再看下结果是否一致。

下一篇我们来着重分析第五段逻辑,即 乱序比对(核心)

总结

  1. diff 算法主要通过 Vue 中的 patchKeyedChildren 方法来实现。
  2. patchKeyedChildren 方法主要分为五个步骤来处理各场景逻辑,分别是:自前向后自后向前新节点多于旧节点旧节点多于新节点乱序比对(核心)
  3. 自前向后 逻辑主要通过 i 作为下标获取新旧节点元素,再判断新旧节点的 typekey 是否相同,执行 patch 方法进行挂载更新,还是 break 跳出该逻辑。
  4. 自前向后自后向前 逻辑主要区别在于一个从前向后遍历,一个从后向前遍历。
  5. 新节点多于旧节点 分为向前向后新增两种情况,主要通过判断获取 anchor 锚点值来决定多余的新节点插入位置。
  6. 旧节点多于新节点 同样也分向前向后删除两种情况,主要通过 unmount 方法进行多余节点的卸载。

Vue3 源码实现

vue-next-mini

Vue3 源码解析系列