Vue3源码解析之 diff(一)

本文为原创文章,未获授权禁止转载,侵权必究!

本篇是 Vue3 源码解析系列第 17 篇,关注专栏

前言

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

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

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

案例一

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

html 复制代码
<!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 方法:

ts 复制代码
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 方法:

ts 复制代码
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 中已经分别做了注释说明,我们先来看第一段逻辑,也就是 自前向后

ts 复制代码
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 方法:

ts 复制代码
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 更新,先看下之前页面呈现:

执行后,页面呈现:

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

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

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

案例二

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

html 复制代码
<!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 均未变化:

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

ts 复制代码
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 方法挂载更新,此时页面呈现:

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

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

案例三

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

html 复制代码
<!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>

新节点多于旧节点

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

ts 复制代码
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 值为:

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

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

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

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

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

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

案例四

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

html 复制代码
<!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 值为:

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

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

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

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

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

总结

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

Vue3 源码实现

vue-next-mini

Vue3 源码解析系列

  1. Vue3源码解析之 源码调试
  2. Vue3源码解析之 reactive
  3. Vue3源码解析之 ref
  4. Vue3源码解析之 computed
  5. Vue3源码解析之 watch
  6. Vue3源码解析之 runtime
  7. Vue3源码解析之 h
  8. Vue3源码解析之 render(一)
  9. Vue3源码解析之 render(二)
  10. Vue3源码解析之 render(三)
  11. Vue3源码解析之 render(四)
  12. Vue3源码解析之 render component(一)
  13. Vue3源码解析之 render component(二)
  14. Vue3源码解析之 render component(三)
  15. Vue3源码解析之 render component(四)
  16. Vue3源码解析之 render component(五)
  17. Vue3源码解析之 diff(一)
相关推荐
蟾宫曲4 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心4 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455664 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029404 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
LCG元5 小时前
Vue.js组件开发-使用vue-pdf显示PDF
vue.js
魏时烟6 小时前
css文字折行以及双端对齐实现方式
前端·css
哥谭居民00016 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
烟波人长安吖~7 小时前
【目标跟踪+人流计数+人流热图(Web界面)】基于YOLOV11+Vue+SpringBoot+Flask+MySQL
vue.js·pytorch·spring boot·深度学习·yolo·目标跟踪
2401_882726487 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203987 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github