Vue3 源码阅读(9):渲染器 —— diff 算法

这篇文章讲解 Vue 中常说的 diff 算法,既会讲解 Vue3 的版本,也会讲解 Vue2 的版本。

1,前置知识

1-1,diff 算法的作用

diff 算法用于更新元素节点的子节点

1-2,元素子节点的类型

元素的子节点有三种类型,分别是:空、文本、元素节点(一个或者多个)。

新旧节点的子节点各有三种情况,所以总共有 9 中情形,分别是:

newVNode 的子节点 oldVNode 的子节点 需要进行的操作
1 不做任何操作
2 文本 移除 oldVNode 对应真实 DOM 节点中的文本
3 元素节点 移除 oldVNode 对应真实 DOM 节点中的元素
4 文本 将文本直接设置到 oldVNode 对应真实 DOM 节点中
5 文本 文本 判断新旧文本是否相同,如果不相同的话,设置新的文本到 oldVNode 对应真实 DOM 节点中
6 文本 元素节点 移除所有元素子节点,并设置文本到 oldVNode 对应真实 DOM 节点中
7 元素节点 创建元素节点,并添加到 oldVNode 对应真实 DOM 节点中
8 元素节点 文本 移除文本,创建元素节点,并添加到 oldVNode 对应真实 DOM 节点中
9 元素节点 元素节点 最复杂的情况,在下面详解

上面的表格就是可能出现的情形以及需要做的操作,一共有 9 种情形,当然在源码层次不可能写这么多分支进行处理,我们先看看处理这一块的源码。

Vue2 版本:

// 更新节点的方法
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // 获取对比 vnode 对应的真实的 DOM 节点
  const elm = vnode.elm = oldVnode.elm

  const oldCh = oldVnode.children
  const ch = vnode.children

  // 在 Vue2 中,使用 vnode.text 属性存储文本子节点,使用 vnode.children 存储元素子节点。
  // 判断 newVNode 内部有没有文本,如果没有文本的话,进入 if 分支
  if (isUndef(vnode.text)) {
    // 如果新旧节点都有元素子节点的话,使用 updateChildren 进行处理
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 这个分支用于处理新节点有元素子节点,旧节点没有元素子节点的情况
      // 判断旧节点中有没有文本,有的话,清空文本
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      // 创建新的元素子节点,并将其添加到 elm 元素节点中
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 这个分支用于处理旧节点有元素子节点,新节点为空的情况
      // 此时移除所有的元素子节点即可
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 这个分支用于处理旧节点中有文本,新节点为空的情况
      // 直接将文本清空即可
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 如果 newVNode 内部有文本的话,进入当前分支。
    // 在这里,判断新旧文本是否相同,如果不相同的话,将新的文本设置到 DOM 中
    nodeOps.setTextContent(elm, vnode.text)
  }
}

直接看注释即可。

Vue3版本:

// diff 算法
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
  
  // children has 3 possibilities: text, array or no children.
  // 如果新节点有文本子节点的话,进入 if 分支
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // text children fast path
    // 如果旧节点有元素子节点的话,进行元素的卸载操作
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
    }
    // 如果新旧节点的文本子节点内容不一样的话,将新的文本内容设置到 DOM 中
    if (c2 !== c1) {
      hostSetElementText(container, c2 as string)
    }
  } else {
    // 新节点没有文本子节点,进入 else 分支,此时新节点的子节点只有两种情况:元素子节点、空
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // prev children was array
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // two arrays, cannot assume anything, do full diff
        // 该分支用于处理新旧节点都有元素子节点的情况,对应情形 9
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        // no new children, just unmount old
        // 该分支用于处理新节点为空,旧节点有元素子节点的情况,此时将元素全部卸载即可
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
      }
    } else {
      // 代码执行到这里,说明:
      // 旧节点的子节点为 文本 或者 空
      // 新节点的子节点为 元素 或者 空

      // 如果旧节点的子节点是文本子节点的话,将文本进行清空
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '')
      }
      // 如果新节点的子节点是元素的话,创建所有的新子节点,并将新子节点插入到真实 DOM 中
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      }
    }
  }
}

Vue3 中,无论是元素子节点还是文本子节点,都是将子元素数据存储在 children 属性中,VNode 的 shapeFlag 属性用于存储子元素的类型。

无论是 Vue2 还是 Vue3,总体思路都是一样的。在上面的各个分支中,最复杂的情况是新老节点的子节点都是元素子节点的情况,接下来,对这种情形进行详细解读。

2,Vue2 中的双端 diff 算法

diff 算法用于处理新老 VNode 的子节点都是元素节点的情况,为了尽可能的提高性能,我们需要将 oldChildren 和 newChildren 中相同的节点进行复用,想要进行复用,我们需要先找到 oldChildren 和 newChildren 两个数组中相同的节点,Vue2 的做法是先声明四个指针,这四个指针分别指向 newChildren 的第一个节点(简称:新前)和最后一个节点(新后)以及 oldChildren 的第一个节点(旧前)和最后一个节点(旧后),源码如下所示:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 标识 "旧前" 的下标
  let oldStartIdx = 0
  // 标识 "新前" 的下标
  let newStartIdx = 0
  // 标识 "旧后" 的下标
  let oldEndIdx = oldCh.length - 1
  // 标识 "新后" 的下标
  let newEndIdx = newCh.length - 1

  // "旧前" 节点
  let oldStartVnode = oldCh[0]
  // "旧后" 节点
  let oldEndVnode = oldCh[oldEndIdx]
  // "新前" 节点
  let newStartVnode = newCh[0]
  // "新后" 节点
  let newEndVnode = newCh[newEndIdx]
}

接下来,Vue2 进行这四个节点的比较,比较的双方分别是:

  1. 新前 -- 旧前
  2. 新后 -- 旧后
  3. 新后 -- 旧前
  4. 新前 -- 旧后

如果比较的双方是相同的节点的话,就会进行节点的复用,源码如下所示:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  ......
  // 这里使用四个变量实现从两边到中间的逻辑,
  // 每处理一组 "新老” 节点,就会将 "前" 节点向后移动一位,将 "后" 节点向前移动一位。
  // 如果 newChildren 和 oldChildren 两个节点有一个循环完毕,while() 会就结束,此时就有两种情况需要说明。
  // 1,如果 while() 循环结束,newChildren 还有未处理的节点,则这些未处理的节点都是新增节点,需要进行创建和插入的操作。
  // 2,如果 while() 循环结束,oldChildren 还有未处理的节点,则这些未处理的节点都是要删除的节点,从 DOM 中将它们删除即可。
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (sameVnode(oldStartVnode, newStartVnode)) {
      // 新前和旧前比较
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 新后和旧后比较
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 新后和旧前比较
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // 新前和旧后比较
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    }
  }
}

我们根据上面的四种比对进行一一解读

(1)新前 -- 旧前 之间的比较

如果新前和旧前是相同节点的话,说明旧子节点的第一个节点对应的真实 DOM 在新版本中还是排第一个,此时单纯进行节点的更新操作即可,不用进行移动操作,我们调用 patchVnode 进行节点的更新操作。节点更新完成后,我们还需要更新指针,由于此时是新前和旧前之前的操作,所以 oldStartIdx 和 newStartIdx 加一即可。

(2)新后 -- 旧后 之间的比较

如果新后和旧后是相同节点的话,说明旧子节点的最后一个节点对应的真实 DOM 在新版本中还是排最后,此时进行节点的更新即可,不用进行移动操作,我们调用 patchVnode 进行节点的更新操作。更新操作完成后,我们还需要更新指针,将 oldEndIdx 和 newEndIdx 减一即可。

(3)新后 -- 旧前 之间的比较

如果新后和旧前是相同节点的话,说明旧子节点的第一个节点对应的真实 DOM 在新版本中排最后一个,此时既需要进行节点的更新操作,也需要进行移动操作,更新操作使用 patchVnode 方法即可。至于移动操作,对应的真实 DOM 需要移动到父节点的最后一位,使用 insertBefore 方法即可实现功能,这个方法有三个参数,第一个参数是父节点,第二个参数是需要移动的节点,第三个参数是参考节点,insertBefore 方法会将需要移动的节点移动到参数节点的前面,如果参数节点为空的话,则将需要移动的节点移动到父节点的最后一位。

更新和移动操作完成后,更新指针。

(4)新前 -- 旧后 之间的比较

如果新前和旧后是相同节点的话,说明旧子节点的最后一个节点对应的真实 DOM 在新版本中需要排到第一个,此时既需要更新操作,也需要移动操作,使用 patchVnode 和 insertBefore 完成更新和移动操作即可,最后更新指针。

在最理想的情况下,上面的四种比较每次都可以命中,直至所有的子节点都更新玩。但是真实情况肯定不会这么理想,当上面四种情况都没有命中的话,Vue2 会在 oldChildren 中查找有没有和新前节点一样的节点,对应源码如下所示:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  ......
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 新前和旧前比较
      ......
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 新后和旧后比较
      ......
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 新后和旧前比较
      ......
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // 新前和旧后比较
      ......
    } else {
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // idxInOld 是目标旧子节点在 oldChildren 中的下标
      idxInOld = isDef(newStartVnode.key)
        // 如果新子节点中定义了 key 属性的话,则直接从 oldKeyToIdx 对象中获取对应旧子节点在 oldChildren 中的下标
        ? oldKeyToIdx[newStartVnode.key]
        // 如果新子节点中没有定义 key 属性的话,此时说明第二种优化策略也无法实现效果了
        // 此时需要循环旧子节点列表寻找目标旧子节点,实现的逻辑在 findIdxInOld 方法中
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        // 如果目标旧子节点的下标没有找到的话,说明当前循环的节点是新增节点,进行创建和插入操作即可
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
      } else {
        // 如果获取到下标的话,从 oldChildren 中获取目标旧子节点 — vnodeToMove
        vnodeToMove = oldCh[idxInOld]
        // 判断对比的新老节点是不是同一节点
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果是的话,进行更新节点的操作
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          // 因为旧子节点已经处理过了,所以需要将 oldCh[idxInOld] 设置为 undefined,防止出现重复处理的情况
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 如果程序员给子节点标注的 key 属性不规范的话,就有可能出现 key 相同,但根本不是同一节点的情况,
          // 此时将当前循环的新子节点当做新增节点接口
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
}

对应的源码在最后一个分支。

在这部分代码中,Vue2 会先创建一个对象,对象的 键 是旧子节点的 key 属性,值 是对应旧子节点在 oldChildren 中的下标。这个对象创建完成后,判断新前节点有没有 key 属性,如果有的话,直接通过新前的 key 属性获取其对应旧子节点在 oldChildren 中的下标,如果新前没有配置 key 属性的话,则需要遍历 oldChildren 数组,寻找与新前节点相同的节点,这一步性能是很差的,需要遍历,无论通过哪种方法,计算到的对应旧子节点的下标值存储到 idxInOld 属性中。

接下来,判断 idxInOld 是否定义,如果定义了的话,说明找到了对应的旧子节点,如果没有定义的话,说明新前节点是一个新增节点。如果是新增节点的话,则创建出对应节点,然后将新增节点添加到 oldStartVnode.elm 的前面即可。如果找到了对应的旧子节点的话,则需要进行节点的更新操作和移动操作,移动的位置是旧前节点对应真实 DOM 的前面,移动和操作完成后,由于这一个旧子节点已经处理完了,所以需要将 oldCh[idxInOld] 设为 undefined,由于子节点数组中的数据有可能是 undefined,所以在 while 循环的前面需要进行判断和处理,如果当前节点是 undefined 的话,则更新指针。

在上面的代码中,我们发现 while 的判断条件是 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)。在真实的业务中,有可能新老子节点数组中有一方已经处理完了,但另一方还有没有处理的元素。

当 oldChildren 处理完了,newChildren 还没有处理完时,说明 newChildren 中还没有处理的节点是新增节点。

当 newChildren 处理完了,oldChildren 还没有处理完时,说明 oldChildren 中还没有处理的节点是需要移除的节点。

这一部分对应的源码如下所示:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  ......

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { ...... }

  if (oldStartIdx > oldEndIdx) {
    // 1,如果 while() 循环结束,newChildren 还有未处理的节点,则这些未处理的节点都是新增节点,需要进行创建和插入的操作。
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 2,如果 while() 循环结束,oldChildren 还有未处理的节点,则这些未处理的节点都是要删除的节点,从 DOM 中将它们删除即可。
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

如果 oldStartIdx 大于 oldEndIdx 的话,说明 newChildren 中还未处理的节点是新增节点,此时创建出对应节点并插入到 newCh[newEndIdx + 1].elm 元素的前面即可。

如果 newStartIdx 大于 newEndIdx,说明 oldChildren 中还未处理的节点是需要移除的节点,调用 removeVnodes 完成移除操作即可。

3,Vue3 中的快速 diff 算法

Vue3 中的 diff 算法在 runtime-core/src/renderer.ts ==> function patchKeyedChildren(){} 方法中,我们一步步进行解析。

3-1,预处理:处理相同的前置节点和后置节点

当 oldChildren 和 newChildren 的节点是下面这种时:

oldChildren:(a b) c (f g)

newChildren:(a b) d e (f g)

我们有必要进行相同前置节点和后置节点的处理,因为在上面的 oldChildren 和 newChildren 中,开头和结尾的节点都是相同的,不同点只在中间的部分,头部和尾部的节点只需要进行节点的更新即可,不需要节点的移动操作。这部分源码解释看注释即可,很简单,源码如下所示:

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  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
  // oldChildren 的最大索引
  let e1 = c1.length - 1
  // newChildren 的最大索引
  let e2 = l2 - 1

  // 1. sync from start
  // (a b) c
  // (a b) d e
  // 预处理:处理相同的前置节点
  while (i <= e1 && i <= e2) {
    // 获取索引为 i 的 newVNode 和 oldVNode
    const n1 = c1[i]
    const n2 = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    // 判断新老 VNode 是不是相同的节点,如果是的话,进行节点的更新操作
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      // 如果当前比较的 n1 和 n2 不是相同节点话,前置节点的处理结束
      break
    }
    // i + 1,循环处理下一对前置节点
    i++
  }

  // 2. sync from end
  // a (b c)
  // d e (b c)
  // 预处理:处理相同的后置节点
  while (i <= e1 && i <= e2) {
    // 使用 e1 和 e2 获取需要对比的新老 VNode
    const n1 = c1[e1]
    const n2 = (c2[e2] = optimized
      ? cloneIfMounted(c2[e2] as VNode)
      : normalizeVNode(c2[e2]))
    // 如果 n1 和 n2 是相同类型节点的话,则进行节点的更新操作
    if (isSameVNodeType(n1, n2)) {
      patch(
        n1,
        n2,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    } else {
      // 如果当前比较的 n1 和 n2 不是相同节点的话,后置节点的处理结束
      break
    }
    // 更新下标,循环比对下一对后置节点
    e1--
    e2--
  }
}

3-2,预处理完成后,oldChildren 和 newChildren 有可能有一方已经全部处理完了

预处理完成后,会出现四种情况,如下所示:

  1. oldChildren 和 newChildren 中都还有未处理的节点。
  2. oldChildren 中的节点都处理完了,但 newChildren 中还有未处理的节点,此时需要进行节点的新增操作。
  3. newChildren 中的节点都处理完了,但 oldChildren 中还有未处理的节点,此时需要进行节点的移除操作。
  4. oldChildren 和 newChildren 中的节点都处理完了。

第一种情况我们在下面进行细讲,这一小节,看第二和第三种情况。

第二种情况对应的 oldChildren 和 newChildren 如下所示:

oldChildren:(a b) (f g)

newChildren:(a b) d e (f g)

可以发现预处理结束后,newChildren 中还有 d e 两个未处理的节点,这两个节点是新增节点,源码如下所示:

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  ......
  // 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
  // 前置节点和后置节点处理完成后,有可能 oldChildren 已经处理完了,但是 newChildren 还有未处理的,
  // 这些未处理的节点是新增节点,下标为 i 到 e2 的 newVNode 都是新增节点。
  if (i > e1) {
    if (i <= e2) {
      // 新增节点插入的时候需要一个锚点节点,由于新增节点的最后一个节点的下标是 e2,所以锚点节点的下标为 e2 + 1
      // 锚点节点的下标为 nextPos = e2 + 1,每次插入新增节点的时候,都插入到 nextPos 锚点节点的前面即可。
      const nextPos = e2 + 1
      const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
      // while 循环插入新增节点
      while (i <= e2) {
        // 调用 patch 函数进行新节点的创建和插入操作
        patch(
          null,
          // 新增节点的 VNode
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        i++
      }
    }
  }
}

看注释即可。

第二种情况对应的 oldChildren 和 newChildren 如下所示:

oldChildren:(a b) d e (f g)

newChildren:(a b) (f g)

可以发现预处理结束后,oldChildren 中还有 d e 两个未处理的节点,这两个节点是需要移除的节点,源码如下所示:

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  ......
  // 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
  // 前置节点和后置节点处理完成后,有可能 newChildren 已经处理完了,但是 oldChildren 还有未处理的,
  // 这些未处理的节点是需要移除的节点,下标 i 到 e1 都是需要移除的节点
  else if (i > e2) {
    while (i <= e1) {
      // 使用 unmount 进行节点的移除操作
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }
}

3-3,预处理完成后,oldChildren 和 newChildren 中都还有未处理的节点

上面小节说的情形都是比较理想化的情形,在大部分业务中,很可能 oldChildren 和 newChildren 中都还有未处理的节点,如下面的节点数组

oldChildren:  (a b) c d e f (g h)

newChildren:(a b) e c d u (g h)

仔细分析上面的 oldChildren 和 newChildren 可以发现,预处理完成后,还需要进行以下这些操作才能完成更新。

  1. 移除 f 节点。
  2. 新增 u 节点。
  3. 更新 c d e 节点。
  4. 移动 e 节点。

源码中功能实现的核心主要看 newIndexToOldIndexMap 数组和其最长递增子序列 increasingNewIndexSequence,newIndexToOldIndexMap 数组的长度是 newChildren 中未处理节点的个数,数组中的元素和 newChildren 中未处理节点是一一对应的,newIndexToOldIndexMap 数组用来存储 newChildren 中 newVNode 对应的 oldVNode 在 oldChildren 数组中的下标,increasingNewIndexSequence 是 newIndexToOldIndexMap 数字数组的最长递增子序列。

我们以上面的业务需求为出发点,看源码是如何一步步进行处理的,首先看第一大块源码。

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  ......
  else {
    const s1 = i // prev starting index
    const s2 = i // next starting index

    // 5.1 build key:index map for newChildren
    const keyToNewIndexMap: Map = new Map()
    // s2 到 e2 的 newVNode 是未处理的节点,for 循环
    for (i = s2; i <= e2; i++) {
      // 获取下标为 i 的 newVNode
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (nextChild.key != null) {
        // 填充 key 和 下标 到 keyToNewIndexMap 中
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
  }
}

这一块源码主要是为了构建一个 Map 数据 keyToNewIndexMap,这个数据的键是 newChildren 中未处理节点的 key,值是对应未处理节点的下标值,这个 Map 的作用是辅助填充 newIndexToOldIndexMap 数组。

接下来看第二块源码,如下所示:

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  ......
  else {
    ......

    // 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
    // 变量意义:newChildren 中还未处理的节点数量,
    const toBePatched = e2 - s2 + 1
    // 变量意义:用于判断是否需要进行移动节点操作的变量
    let moved = false
    // used to track whether any node has moved
    // 变量作用:接下来会进行 oldChildren 数组中未处理节点的遍历操作,在遍历的过程中,会获取对应 newVNode 在 newChildren 数组中的下标
    // 这个变量用于存储循环的过程中,最大的 newVNode 下标。在无需移动的情况下,当前循环 oldVNode 对应 newVNode 的下标应该始终大于等于 maxNewIndexSoFar,
    // 如果出现小于 maxNewIndexSoFar 情况的话,则说明未处理的子节点需要进行移动操作,所以说 maxNewIndexSoFar 变量是用于判断 moved 变量的真假值的。
    let maxNewIndexSoFar = 0
    // works as Map
    // 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
    // 创建 newIndexToOldIndexMap 数组,数组的长度是 newChildren 中未处理节点的数量
    const newIndexToOldIndexMap = new Array(toBePatched)
    // newIndexToOldIndexMap 数组默认填充数字 0,数字 0 表明对应的 newVNode 是新增节点,因为其没有对应的 oldVNode
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

    // 开始遍历 oldChildren 中未处理的节点
    for (i = s1; i <= e1; i++) {
      // 获取当前循环的 oldVNode
      const prevChild = c1[i]
      // 如果已经更新 patch 的节点数量大于 newChildren 中需要更新节点数量的话,则说明当前遍历的 oldVNode 是需要移除的节点
      if (patched >= toBePatched) {
        // all new children have been patched so this can only be a removal
        // 调用 unmount 函数进行节点的移除操作
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
      }
      // 接下来获取当前循环的 oldVNode 对应的 newVNode 在 newChildren 中的下标值
      let newIndex
      if (prevChild.key != null) {
        // 通过 keyToNewIndexMap 获取
        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
          }
        }
      }
      // 如果 newIndex 不存在的话,则说明当前循环处理的 oldVNode 是需要移除的节点,调用 unmount 进行卸载操作
      if (newIndex === undefined) {
        unmount(prevChild, parentComponent, parentSuspense, true)
      } else {
        // 如果 newIndex 不是 undefined 的话,这说明当前循环处理的 oldVNode 有其对应的 newVNode
        // 填充 newIndexToOldIndexMap 数据,设置的下标是 newVNode 在 newChildren 中的下标,设置的值是 i + 1,之所以加一,
        // 是因为数据 0 有其特殊的意义(表明当前的 newVNode 是新增节点)
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        // 如果当前循环 oldVNode 对应 newVNode 的下标大于等于 maxNewIndexSoFar 的话,说明此时的节点无需进行移动操作
        // 更新 maxNewIndexSoFar 数据
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          // 否则的话,说明当前需要进行节点的移动操作,将 moved 标志设为 true
          moved = true
        }
        // 因为当前循环处理的 oldVNode 找到了对应的 newVNode,所以可以进行节点的更新操作
        patch(
          prevChild,
          c2[newIndex] as VNode,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
        // 因为进行了节点的更新操作,所以 patched 加一
        patched++
      }
    }
  }
}

这部分源码的作用是遍历 oldChildren 中未处理的节点,然后填充 newIndexToOldIndexMap 数组,需要注意的是 newIndexToOldIndexMap 数组初始填充数据是 0,如果某一个 newVNode 对应的数组元素是 0 的话,说明这个 newVNode 没有对应的 oldVNode,因此它是一个新增节点。

在遍历 oldChildren 的过程中,如果发现当前遍历处理的 oldVNode 没有对应的 newVNode,则说明当前的 oldVNode 是一个需要移除的节点,这用于处理上面说的 f 节点。如果当前遍历的 oldVNode 有对应的 newVNode 的话,这说明需要进行节点的更新操作,甚至还有可能需要进行移动操作,移动操作在下面讲,这块代码使用 patch 函数进行节点的更新操作。

这一块代码还有一个任务是判断节点是否需要进行移动操作,主要看 newIndex、maxNewIndexSoFar 和 moved 变量,这些变量的作用如下所示:

  • moved:判断是否需要进行移动的最终标识。
  • newIndex:在遍历 oldChildren 的过程中,使用 newIndex 变量存储当前循环处理的 oldVNode 对应 newVNode 在 newChildren 中的下标。
  • maxNewIndexSoFar:用于存储当前遇到的最大的 newIndex 值。

接下来说说源码是如何判断出节点需要进行移动操作的,其实这一点我们可以使用反证法的思维,假设 oldChildren 和 newChildren 中的节点不需要进行节点的移动操作,oldChildren 和 newChildren 如下所示:

oldChildren:a b c d

newChildren:a b c d

那么在遍历 oldChildren 的过程中,获取当前的 oldVNode 对应的 newVNode 在 newChildren 中的下标 newIndex 应该是逐步增大的,那么如果某一步获取的 newIndex 比 maxNewIndexSoFar 小,就可以说明,子节点需要进行移动操作。

接下来,看第三块代码,这块代码主要负责节点的移动和新增节点。

// 快速 diff 算法
const patchKeyedChildren = (
  // oldChildren
  c1: VNode[],
  // newChildren
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  ......
  else {
    ......
    // 5.3 move and mount
    // generate longest stable subsequence only when nodes have moved
    // 进行节点的移动和新增挂载操作
    // 通过 getSequence 函数获取最长递增子序列
    // 最长递增子序列的意义:最长递增子序列数组中的数字元素对应的 newVNode 不需要进行移动操作
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : EMPTY_ARR
    // 接下里的 j 和 i 变量很重要,
    // 变量 j 一开始指向递增子序列的最后一位
    j = increasingNewIndexSequence.length - 1
    // looping backwards so that we can use last patched node as anchor
    // 变量 i + s2 用于指向 newChildren 中未处理节点的最后一位
    for (i = toBePatched - 1; i >= 0; i--) {
      // 指向 newChildren 中未处理节点最后一位的变量
      const nextIndex = s2 + i
      // 获取 newChildren 中未处理节点的最后一位
      const nextChild = c2[nextIndex] as VNode
      // 接下来会进行新增节点或者移动节点的操作,操作的锚点是当前 nextChild 的下一个节点
      const anchor =
        nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
      // 如果当前处理的 newVNode 对应的 newIndexToOldIndexMap 数组中的数据是 0 的话,说明当前的节点是新增节点,因为当前的 newVNode 没有对应的 oldVNode
      if (newIndexToOldIndexMap[i] === 0) {
        // 新增节点使用 patch 函数进行处理
        patch(
          null,
          nextChild,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (moved) {
        // 在这个 else if 分支中:说明当前的 newVNode 不是新增节点,并且有可能需要进行移动操作

        // j < 0 说明递增子序列中的数据已经用完了(递增子序列中的元素对应的 newVNode 不需要进行移动操作),因此当前的 newVNode 需要进行移动操作;
        // i !== increasingNewIndexSequence[j] 说明当前的 newVNode 不是递增子序列中包含指向的 VNode,所以也需要进行移动操作;
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 使用 move 函数完成移动操作
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          // 在这里,说明 i === increasingNewIndexSequence[j],所以当前的 newVNode 不需要进行移动操作
          // 需要消耗掉一个递增子序列中的数据,j--
          j--
        }
      }
    }
  }
}

首先获取 newIndexToOldIndexMap 数组的最长递增子序列,最长递增子序列的定义是:在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。

在 diff 算法中,最长递增子序列的意义是:最长递增子序列中元素对应的 newVNode 不需要进行节点的移动操作,只需要移动那些与最长递增子序列中元素不对应的 newVNode 即可,这可以保证性能,例如有如下的子节点:

oldChildren:  a b c

newChildren:b c a

在上面的例子中,性能最高的移动方式是将 a VNode 对应的真实 DOM 移动到最后,性能最差的移动方式是将 b 移动到 a 的前面,然后将 c 移动到 a 的前面。最长递增子序列的作用就是使用最佳的节点移动方式。

接下来继续看下面的源码,最长递增子序列生成后,开始进行新增节点和节点的移动操作,首先使用两个变量 j 和 i 分别指向 最长递增子序列的末尾 以及 newChildren 中未处理节点的末尾,然后进行 newChildren 中从后往前的遍历处理操作,在遍历的过程中,首先判断当前循环处理的 newVNode 对应的 newIndexToOldIndexMap 数组中的元素是不是 0,如果是 0 的话,说明当前的 newVNode 没有对应的 oldVNode,所以它是一个新增节点,新增节点使用 patch 函数进行处理。

如果不是 0 的话,则说明当前的 newVNode 不是新增节点,不是新增节点则有可能需要进行节点的移动操作,如果 moved 为 true,并且当前 newVNode 的下标和 increasingNewIndexSequence[j] 不相等的话,则说明需要进行节点的移动操作,移动操作使用 move 函数进行,如果 i === increasingNewIndexSequence[j] 的话,则说明当前的 newVNode 在递增子序列中,所以当前的 newVNode 不需要进行移动操作,使用 j-- 消耗掉一个最长递增子序列中的元素即可。

你可能感兴趣的:(vue3源码阅读系列,vue.js,前端,javascript)