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<string | number | symbol, number> = 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<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
    // 创建 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-- 消耗掉一个最长递增子序列中的元素即可。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值