Vue3源码解读之Diff算法

1、前言

Vue的Diff算法是用来比较虚拟DOM和真实DOM的差异,并将差异应用到真实DOM上,以实现高效的更新。Diff算法主要包括三个步骤:树的遍历、节点的比较和差异的应用。

在树的遍历过程中,Diff算法会递归地遍历虚拟DOM树和真实DOM树,并比较它们的节点。这个过程是从根节点开始,逐层向下遍历,通过比较节点的标签、属性、子节点等信息,来确定是否有差异存在。

节点的比较是Diff算法的核心部分,它会对虚拟DOM和真实DOM的节点进行详细的比较。在比较过程中,会考虑节点的类型、标签、属性等方面的差异,并将这些差异记录下来。

差异的应用是将记录下来的差异应用到真实DOM上,以实现对DOM的更新。这个过程是通过操作真实DOM的API来实现的,比如添加、删除、修改节点等操作。

通过这些步骤,Vue的Diff算法能够准确、快速地更新DOM,提高应用的性能和用户体验。它能够最小化DOM的操作,只对有差异的部分进行更新,避免不必要的重绘和重排,从而提升页面的渲染效率。

2、流程概述

3、源码详解

3.1 patchChildren函数

patchChildren函数源码

当组件更新的时候会走到patchChildren函数,以下是patchChildren的函数的具体实现:

javascript 复制代码
const patchChildren: PatchChildrenFn = (...) => {
    // 取一哈新旧虚拟节点的子节点
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children
    const { patchFlag, shapeFlag } = n2
    if (patchFlag > 0) {
      // patchFlag > 0 就表示子节点含有动态属性,如:动态style、动态class、动态文案等
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 子节点带 key
        patchKeyedChildren(...)
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 子节点不带 key
        patchUnkeyedChildren(...)
        return
      }
    }
    // 子节点存在3种可能的情况:文本、数组、没有子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 新虚拟节点的子节点是文本
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 对应的旧虚拟节点的子节点是数组
        // 卸载旧虚拟节点的数组子节点
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
      }
      // 再挂载新虚拟节点的文本子节点
      if (c2 !== c1) {
        hostSetElementText(container, c2 as string)
      }
    } else {
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 旧虚拟节点的子节点是数组
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新虚拟节点的子节点也是数组,做全量diff
          patchKeyedChildren(...)
        } else {
          // 能走到这就说明新虚拟节点没有子节点,这里只需要卸载久虚拟节点的子节点
          unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
        }
      } else {
        // 走到这就说明
        // 旧虚拟节点的子节点要么是文本要么也没有子节点
        // 新虚拟节点的子节点要么是数组要么就没有子节点
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
          // 旧虚拟节点的子节点是文本,更新
          hostSetElementText(container, '')
        }
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新虚拟节点的子节点是数组,挂载
          mountChildren(...)
        }
      }
    }
  }

从patchChildren函数的源码中我们可以知道,Vue3在比较新旧两组子节点的时候会采用以下两种方法处理:

  • 如果新的子节点没有key属性,那么就会调用patchUnkeyedChildren函数来对新旧两组子节点进行Diff比较;
  • 如果新的子节点没有key属性,那么就会调用patchkeyedChildren函数来对新旧两组子节点进行Diff比较;

那么,patchUnkeyedChildren和patchkeyedChildren函数处理节点有什么区别呢?我们接着往下看。

3.2 patchUnkeyedChildren函数

如果新的子节点没有key属性,那么就会调用patchUnkeyedChildren函数来对新旧两组子节点进行Diff比较。该函数的代码如下所示:

patchUnkeyedChildren源码

javascript 复制代码
// 没有 key 标识的子节点的 patch 过程,即 diff 过程
  const patchUnkeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    // 旧子节点
    c1 = c1 || EMPTY_ARR
    // 新子节点
    c2 = c2 || EMPTY_ARR
    // 旧的一组子节点的长度
    const oldLength = c1.length
    // 新的一组子节点的长度
    const newLength = c2.length
    // 两组子节点的公共长度,即两者中较短的那一组子节点的长度
    const commonLength = Math.min(oldLength, newLength)
    let i
    // 遍历commonLength,调用patch函数进行更新
    for (i = 0; i < commonLength; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch(
        c1[i],
        nextChild,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    }
    // 如果旧的一组子节点的长度大于新的一组子节点的长度,说明有旧的子节点需要卸载
    if (oldLength > newLength) {
      // remove old
      unmountChildren(
        c1,
        parentComponent,
        parentSuspense,
        true,
        false,
        commonLength
      )
    } else {
      // 说明是有新子节点需要挂载
      // mount new
      mountChildren(
        c2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized,
        commonLength
      )
    }
  }

如上面代码所示,处理步骤如下:

  1. 求新旧两组子节点各自的长度,然后求出两组子节点的公共长度commonLength;
  2. 遍历commonLength,调用patch函数进行更新;
  3. 公共长度内的子节点更新完毕,进行以下操作:
    1. 如果新的一组子节点的长度更长,说明新的子节点需要挂载,调用mountChildren函数进行挂载;
    2. 否则说明旧子节点需要卸载,调用unmount函数卸载旧节点;

3.3 patchKeyedChildren函数

如果新的子节点有key属性,那么就会调用patchkeyedChildren函数来对新旧两组子节点进行Diff比较。该函数的代码如下所示:

patchkeyedChildren源码

3.3.1 快速diff算法预处理步骤

快速 Diff 算法包含预处理步骤,这其实是借鉴了纯文本 Diff 算法的思路。快速diff算法的预处理是分别处理新旧节点中的前置节点和后置节点。

3.3.2 处理前置节点

前置节点节点处理

javascript 复制代码
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更新节点
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    // 遇到了 key 不同的节点,那就直接退出循环,相同前置节点(a b)的更新处理完成
    break
  }
  i++
}

在这段代码中,我们使用while循环查找所有相同的前置节点,并调用patch函数进行打补丁,直到遇到key值不同的节点为止。这样就完成了前置节点的更新。

图1、前置节点处理

3.3.3 处理后置节点

后置节点处理

javascript 复制代码
// 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]))
  // 新旧后置节点相同,调用 patch 函数打补丁
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    // 遇到了 key 不同的节点,退出循环,相同后置节(b c)点的更新处理完成
    break
  }
  // 新旧后置节点索引递减,即从后往前遍历两组子节点
  e1--
  e2--
}

与处理前置节点一样,在while循环内,需要调用patch函数进行打补丁,然后递减两个索引e1、e2,直到遇到类型不同且key值不同的节点为止,这样就完成了后置节点的更新。当处理完后置节点后的状态如下图所示。

图2、处理后置节点

3.3.4 处理新增节点

新增节点处理

当前置节点和后置节点处理完毕后,旧的一组子节点已经全部被处理了,但是在新的一组子节点中,还遗留了一个未被处理的节点p-4,这个节点是新增的节点。如何得出这个结论呢?我们可以根据索引i、e1、e2之间的关系:

  • 条件一:e1 < i:说明在预处理过程中,所有的旧节点都处理完毕了;
  • 条件二:e2 ≥ i:说明在预处理过程中,在新的一组子节点中,仍然有未被处理的节点,而这些遗留的节点江北视为新增节点

如果条件一和条件二同时成立,说明在新的一组子节点中,存在遗留节点,且这些节点都是新增节点。因此,我们需要将它们挂载到正确的位置。

javascript 复制代码
// 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
// i > e1 说明在预处理的过程中,所有的旧子节点处理完毕额
if (i > e1) {
  // i <= e2 说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被视作新增节点
  if (i <= e2) {
    // 锚点的索引
    const nextPos = e2 + 1
    // 锚点元素
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    // 采用 while 循环,调用 patch 函数逐个挂载新增节点
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i])),
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      i++
    }
  }
}

在这段代码中:

  • 计算锚点的索引值(nextPos)为e2 + 1;
  • 如果小于新的一组子节点的数量,则说明锚点元素在新的一组子节点中,直接使用(c2[nextPos] as VNode).el,否则说明索引c2对应的节点已经是尾部节点了,这时锚点元素是parentAnchor;
  • 找到锚点元素后,使用一个while循环,将i和e2之间的节点作为新节点挂载;

如下图所示:

图3、处理新增节点

3.3.5 处理删除节点

删除节点处理

当相同的前置节点和后置节点全部被处理完毕后,新的一组子节点已经全部被处理完毕,而就得一组子节点中遗留了一个节点p-2,实际上,遗留的节点可能有多个,如下图所示:

javascript 复制代码
// 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 ≤ e1时,则开启一个while循环,并调用unmount函数将i到e1之间的节点全部删除。 到这里,基于理想情况下,处理完前置节点和后置节点后,新旧两组子节点总有一组的子节点全部被处理完毕。所有的操作只需要简单地更新、挂载、卸载节点即可。

图4、处理删除节点

3.3.6 非理想情况下的未处理节点

上面的给出例子都比较理想化,当完成前置和后置节点的处理后,新旧两组子节点总会有一组子节点全部被处理完毕,但有的情况比较复杂。

下面我们给出非理想情况下的例子,经过上面的前置和后置处理后,新子节点和旧子节点都有未被处理的节点,如下图所示:

图5、非理想情况,处理完前置和后置节点的状态

在这种非理想情况下,索引i、e1、e2不满足下面两个条件中的任何一个:

  • i > e1 && i < e2(新增节点的情况)
  • i > e2 && i < e1(卸载旧节点的情况)

我们需要添加新的else分支来处理这种非理想的情况,源码如下:

非理想情况处理源码

第1步:构建索引表keyToNewIndexMap

给新子节点构建一张索引表keyToNewIndexMap,目的是用来存储节点key和节点位置的索引,可以在O(n)时间复杂度下获取到旧节点,提高查找性能。构建keyToNewIndexMap索引表的源码如下:

构建索引表源码

javascript 复制代码
// 预处理完后,未处理节点的第一个未处理节点的索引位置
const s1 = i // prev starting index
const s2 = i // next starting index
// 构建新的一组子节点中未处理的key和索引位置的映射,是为了解决性能问题
// 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.`
      )
    }
    // 将新节点的key和索引位置添加到map集合中
    keyToNewIndexMap.set(nextChild.key, i)
  }
}

图6、构建keyToNewIndexMap索引表

第2步:构建newIndexToOldIndexMap数组

为了在后面快速找到需要移动的节点,需要构建newIndexToOldIndexMap数组,它的长度等于经过预处理后未处理的新子点数量,并且初始值都是0。其源码如下:

构建newIndexToOldIndexMap数组源码

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

第3步:填充newIndexToOldIndexMap数组

填充的目的是将newIndexToOldIndexMap数组中的元素存储的是新的一组子节点中的节点在旧的一组子节点中的位置索引。

填充newIndexToOldIndexMap数组

javascript 复制代码
// 遍历旧的一组子节点中剩余未处理的节点
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) {
    // 从索引表中获取与旧节点具有相同key的新节点在新的一组子节点中的位置索引
    newIndex = keyToNewIndexMap.get(prevChild.key)
  } else {
    // 旧子节点没有 key ,那么尝试在新的一组子节点中查找具有相同类型的没有key的新子节点
    // 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
      }
    }
  }
  // 如果在新的一组子节点中没有找到与旧的一组子节点中具有相同key 或相同类型的子节点,
  // 说明该旧子节点在新的一组子节点中已经不存在了,需要将其卸载
  if (newIndex === undefined) {
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    // 填充 索引映射数组
    newIndexToOldIndexMap[newIndex - s2] = i + 1
    // 通过比较 newIndex 和 maxNewIndexSoFar 的值来判断节点是否需要移动
    if (newIndex >= maxNewIndexSoFar) {
      // 如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点
      maxNewIndexSoFar = newIndex
    } else {
      // 否则需要移动
      moved = true
    }
    // 调用patch函数完成更新
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    // 每更新一个节点,都将patched变量+1
    patched++
  }
}

上面的源码解析如下:

  1. 使用for循环遍历旧子节点,在遍历过程如果发现已经更新的节点数量patched大于需要更新的节点数量toBePatched,则调用unmount将剩余的旧节点全部卸载掉;
  2. 拿旧节点的key值去索引表keyToNewIndexMap去查找该节点在新节点中的位置newIndex;
  3. 如果newIndex不存在,则说明该节点在新节点已经不存在,则调用unmount函数卸载它;
  4. 如果newIndex存在,则说明该节点在新节点中存在,则调用patch函数更新打补丁,并填充newIndexToOldIndexMap数组;

在这段代码中,增加了两个变量moved和maxNewIndexSoFar变量,其中,moved的初始值是false,代表是否需要移动节点,maxNewIndexSoFar初始值为0,代表遍历旧节点过程中遇到的最大索引值。如果在遍历过程中遇到的索引值呈递增趋势,则不需要移动节点,否则就需要移动节点。

图7、构建newIndexToOldIndexMap数组

第4步:判断节点是否需要移动

4.1 计算最长递增序列

tsx 复制代码
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap) // [0, 1]
  : EMPTY_ARR

getSequence函数返回的是最长递增序列中的元素在newIndexToOldIndexMap数组的位置索引。

图8、计算最长递增序列

4.2 重新编号

本步骤忽略掉经过预处理的前置节点和后置节点,对新旧未处理的节点索引值进行重新编号。

图9、重新编号

4.3 重置索引i,j的指向,辅助节点移动

索引j指向最长递增序列中最后一个节点的位置,索引i指向新子节点最后一个位置。

图10、重置索引i、j

然后开启一个for循环,让索引i、j按照箭头的方向移动,见第5步。

第5步:移动or挂载节点?

5.1 newIndexToOldIndexMap[i]的值为0:挂载节点

如果newIndexToOldIndexMap[i]的值为0,则说明索引为i的节点是全新的节点,使用patch函数将其挂载到容器中。

挂载节点源码

javascript 复制代码
if (newIndexToOldIndexMap[i] === 0) {
  // mount new
  patch(
    null,
    nextChild,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized
  )
}

5.2 i ≠ seq[j]时,移动节点

当索引i和索引j指向的子序列元素时,该节点对应的真实DOM需要移动。

移动节点源码

jsx 复制代码
if (j < 0 || i !== increasingNewIndexSequence[j]) {
  // 当指向新的一组子节点的元素索引 i 不等于索引 j指向的子序列的元素时,
  // 该节点对应的真实DOM元素需要移动
  move(nextChild, container, anchor, MoveType.REORDER)
}

图11、移动节点

如上图所示,此时索引 i 的值为 2 ,索引 j 的值为 1 ,因此 2 !== seq[1] 成立,因此,节点 p-2 对应的真实节点需要移动。

5.3 i == seq[j]时,无需移动节点

当i == seq[j]时,说明该位置的节点不需要移动,此时只需要让索引 j 按照图中箭头方向移动即可,即让变量 j 递减,进入下一次的循环比较。

图12、j---,继续下一次循环

如上图所示,此时索引 i 的值为 1 ,索引 j 的值也为 1 ,因此 1 === seq[1] 成立,节点 p-4 对应的真实节点不需要移动,只需要让变量 j 递减即可。移动挂载节点操作的完整代码如下:

javascript 复制代码
// 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) {
    // 这里是需要移动节点的情况
    // i 指向的是新的一组子节点中元素的位置索引
    // j 指向的是最长递增序列中元素的位置索引
    // 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]) {
      // 当指向新的一组子节点的元素索引 i 不等于索引 j指向的子序列的元素时,
      // 该节点对应的真实DOM元素需要移动
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      // 当i === seq[j]时,说明该位置的节点不需要移动,即让索引j递减
      j--
    }
  }
}

Vue3整个快速Diff过程的完整代码如下:

patchKeyedChildren算法源码

javascript 复制代码
// 有key标识的两组子节点的patch过程,即diff过程
  // can be all-keyed or mixed
  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更新节点
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        // 遇到了 key 不同的节点,那就直接退出循环,相同前置节点(a b)的更新处理完成
        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]))
      // 新旧后置节点相同,调用 patch 函数打补丁
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        // 遇到了 key 不同的节点,退出循环,相同后置节(b c)点的更新处理完成
        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
    // i > e1 说明在预处理的过程中,所有的旧子节点处理完毕额
    if (i > e1) {
      // i <= e2 说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被视作新增节点
      if (i <= e2) {
        // 锚点的索引
        const nextPos = e2 + 1
        // 锚点元素
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        // 采用 while 循环,调用 patch 函数逐个挂载新增节点
        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
    // i > e2 说明新的一组子节点已经全部处理完毕了
    else if (i > e2) {
      // i <= e1 说明在旧的一组子节点中还有遗留的节点未被处理,这些节点是需要卸载的
      while (i <= e1) {
        // 开启一个 while 循环,并调用 unmount 函数逐个卸载这些遗留节点
        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
      // 构建新的一组子节点中未处理的key和索引位置的映射,是为了解决性能问题
      // 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.`
            )
          }
          // 将新节点的key和索引位置添加到map集合中
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
      // 当组件的子节点发生变化时,需要对新旧子节点数组进行diff,并更具diff结果更新DOM。在diff过程中,可能会有一些旧子节点数组中剩余未被处理的节点。这些节点需要在 diff 结束后进行处理,包括尝试与新子节点数组中的节点进行匹配并 patch,以及删除已经不存在的节点。
      // 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) {
          // 从索引表中获取与旧节点具有相同key的新节点在新的一组子节点中的位置索引
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 旧子节点没有 key ,那么尝试在新的一组子节点中查找具有相同类型的没有key的新子节点
          // 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
            }
          }
        }
        // 如果在新的一组子节点中没有找到与旧的一组子节点中具有相同key 或相同类型的子节点,
        // 说明该旧子节点在新的一组子节点中已经不存在了,需要将其卸载
        if (newIndex === undefined) {
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          // 填充 索引映射数组
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          // 通过比较 newIndex 和 maxNewIndexSoFar 的值来判断节点是否需要移动
          if (newIndex >= maxNewIndexSoFar) {
            // 如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点
            maxNewIndexSoFar = newIndex
          } else {
            // 否则需要移动
            moved = true
          }
          // 调用patch函数完成更新
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          // 每更新一个节点,都将patched变量+1
          patched++
        }
      }
      // 计算最长递增序列
      // 5.3 move and mount
      // generate longest stable subsequence only when nodes have moved
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      // 索引j指向最长递增序列的最后一个元素
      j = increasingNewIndexSequence.length - 1
      // i指向新的一组子节点的最后一个元素
      // 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) {
          // 这里是需要移动节点的情况
          // i 指向的是新的一组子节点中元素的位置索引
          // j 指向的是最长递增序列中元素的位置索引
          // 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]) {
            // 当指向新的一组子节点的元素索引 i 不等于索引 j指向的子序列的元素时,
            // 该节点对应的真实DOM元素需要移动
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            // 当i === seq[j]时,说明该位置的节点不需要移动,即让索引j递减
            j--
          }
        }
      }
    }
  }

4、总结

4.1 Vue2的Diff算法-双端diff算法

Vue2采用的diff算法叫双端diff算法。这是一种用于虚拟DOM比较和更新的算法,它通过同时从新旧两个虚拟DOM树的两端开始遍历,以最小化操作的数量来实现高效的更新。

具体而言,双端diff算法分为以下几个步骤:

  1. 首先,算法会比较新旧两个虚拟DOM树的根节点。如果两个根节点不同,算法会直接替换旧的根节点。
  2. 接下来,算法会比较新旧两个根节点的子节点。它会从新旧两个子节点数组的两端开始遍历,同时比较相应位置的节点。如果节点相同,则继续比较下一个位置的节点;如果节点不同,则算法会根据一定的规则进行节点的替换、移动或删除操作。
  3. 如果新旧两个子节点数组的长度不同,算法会根据差异的位置进行节点的插入或删除操作。

总的来说,双端diff算法通过从两端同时遍历虚拟DOM树,能够更高效地找到节点的差异,并进行相应的更新操作。这种算法在实际应用中能够大大提升更新的性能和效率。

4.2 Vue3的diff算法-快速diff算法

Vue3与Vue2相比,引入了更高效的Diff算法-快速diff算法。它采用了预处理思路,先处理前置节点和后置节点。然后,算法会按照一定的规则,将虚拟DOM分成几个不同的情况进行处理,包括新旧虚拟DOM完全相同、只有部分节点发生变化、新增节点、删除节点等情况。针对不同的情况,算法会采取不同的策略来进行DOM更新。通过引入新的Diff算法,Vue3在性能上有了明显的提升。它能够更快速地响应用户的操作,并且在大规模数据更新时,也能够更高效地进行DOM更新。这使得Vue3在实际项目中能够处理更复杂的场景,并且提供更好的用户体验。

总结来说,Vue3的Diff算法相比Vue2,具有更高的性能和更好的用户体验。它能够更快速地进行DOM更新,并且通过一系列的优化策略,减少了不必要的操作,提升了整体的性能表现。

5、参考资料

[1] Vue官网

[2] Vuejs设计与实现

欢迎关注我的公众号:前端Talkking

相关推荐
世俗ˊ10 分钟前
CSS入门笔记
前端·css·笔记
子非鱼92110 分钟前
【前端】ES6:Set与Map
前端·javascript·es6
6230_15 分钟前
git使用“保姆级”教程1——简介及配置项设置
前端·git·学习·html·web3·学习方法·改行学it
想退休的搬砖人24 分钟前
vue选项式写法项目案例(购物车)
前端·javascript·vue.js
加勒比海涛38 分钟前
HTML 揭秘:HTML 编码快速入门
前端·html
啥子花道41 分钟前
Vue3.4 中 v-model 双向数据绑定新玩法详解
前端·javascript·vue.js
麒麟而非淇淋1 小时前
AJAX 入门 day3
前端·javascript·ajax
茶茶只知道学习1 小时前
通过鼠标移动来调整两个盒子的宽度(响应式)
前端·javascript·css
清汤饺子1 小时前
实践指南之网页转PDF
前端·javascript·react.js
蒟蒻的贤1 小时前
Web APIs 第二天
开发语言·前端·javascript