《Vue.js 设计与实现》读书笔记 - 第10章、双端 Diff 算法

第10章、双端 Diff 算法

10.1 双端比较的原理

上一章的移动算法并不是最优的,比如我们把 ABC 移动为 CAB,如下

A     C
B --> A
C     B

按照上一章的算法,我们遍历新的数组,然后定下第一个元素 C 的位置后,后面的 AB 都需要被移动。但是显而易见的,我们其实可以只移动 C 移动一次即可。

而使用双端 Diff 就是记录新旧两个子数组的端点,然后 新头节点-旧头结点、新尾结点-旧尾结点、旧头结点-新尾结点、旧尾结点-新头节点,这样四种组合依次去比较,直到找到匹配的元素,然后根据新节点的位置把对应的旧节点移动。

function patchChildren(n1, n2, container) {
  // ... 其他逻辑省略
  if (Array.isArray(n2.children)) {
    // 如果新子元素是一组节点
    if (Array.isArray(n1.children)) {
      patchKeyedChildren(n1, n2, container)
    }
  }
}

function patchKeyedChildren(n1, n2, container) {
  const oldChildren = n1.children
  const newChildren = n2.children
  // 四个索引值
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1
  // 四个索引值对应的 vnode
  let oldStartVNode = oldChildren[oldStartIdx]
  let oldEndVNode = oldChildren[oldEndIdx]
  let newStartVNode = newChildren[newStartIdx]
  let newEndVNode = newChildren[newEndIdx]
  // 循环执行四个判断条件
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
      // 头部相同 不用移动节点 只需要patch打补丁
      patch(oldStartVNode, newStartVNode, container)
      oldStartVNode = oldChildren[++oldStartIdx]
      newStartVNode = newChildren[++newStartIdx]
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 尾部相同 不用移动节点 只需要patch打补丁
      patch(oldEndVNode, newEndVNode, container)
      oldEndVNode = oldChildren[--oldEndIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 如果旧头结点和新尾结点key相同 先patch 再把节点移到尾部
      patch(oldStartVNode, newEndVNode, container)
      insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
      oldStartVNode = oldChildren[++oldStartIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 如果新的头结点和旧尾结点key相同 先patch 再把节点移到头部
      patch(oldEndVNode, newStartVNode, container)
      insert(oldEndVNode.el, container, oldStartVNode.el)
      // 移动索引值
      oldEndVNode = oldChildren[--oldEndIdx]
      newStartVNode = newChildren[++newStartIdx]
    }
  }
}

10.2 双端比较的优势

使用了上述的双端 Diff,在大部分情况下可以少移动一些节点。

10.3 非理想状况下的处理方式

理想就是说每次四种条件都有一种能够命中,实际上可能全部没有命中,比如:

新    旧
2     1
4 <-- 2
1     3
3     4

这种情况时我们就处理新节点中的第一个节点。首先在旧数组中找到 key 相同的进行 patch 没有相同的就创建新节点,然后把该节点已移动到最前面。同时把旧节点置为 undefined 然后注意循环到旧节点位空时要继续前移/后移,来忽略处理过的旧节点。

// 插到循环开始位置
if (!oldStartVNode) {
  oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
  oldEndVNode = oldChildren[++oldEndIdx]
}
// 忽略中间那四个判断条件
const idxInOld = oldChildren.findIndex(
  (node) => node?.key === newStartVNode.key
)
if (idxInOld > 0) {
  const vnodeToMove = oldChildren[idxInOld]
  patch(vnodeToMove, newStartVNode, container)
  // 插到头部
  insert(vnodeToMove.el, container, oldStartVNode.el)
  // 注意处理完旧节点要在旧数组中置空
  oldChildren[idxInOld] = undefined
  newStartVNode = newChildren[newStartIdx++]
}

10.4 添加新元素

还是上面的情况,如果在旧节点中找不到匹配的 key,证明是新添加的元素,需要创建新节点,然后插入到头部。

const idxInOld = oldChildren.findIndex(
  (node) => node?.key === newStartVNode.key
)
if (idxInOld > 0) {
  const vnodeToMove = oldChildren[idxInOld]
  patch(vnodeToMove, newStartVNode, container)
  // 插到头部
  insert(vnodeToMove.el, container, oldStartVNode.el)
  // 注意处理完旧节点要在旧数组中置空
  oldChildren[idxInOld] = undefined
} else {
  // 将新节点挂载到头部,oldStartVNode.el 作为锚点
  patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = newChildren[++newStartIdx]

然后在循环结束后,如果旧数组处理完了但是新数组还有剩余,证明这些节点都是新增的,需要依次创建。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // ...
}
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    const anchor = newChildren[newEndIdx + 1]
      ? newChildren[newEndIdx + 1].el
      : null
    patch(null, newChildren[i], container, anchor)
  }
}

注意这里的 anchor 就是说我们双端 diff 的时候,如果有一些节点已经放在最后了,需要放在那些节点之前。

10.5 移除不存在的元素

如果循环完旧元素有剩余,则需要卸载。

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  // ...
} else if (oldEndIdx >= oldStartIdx && newStartIdx > newEndIdx) {
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    unmount(oldChildren[i])
  }
}

10.6 总结

这章整体还是比较好理解的,主要之前只知道双端比较,现在更清楚了匹配上之后要怎么移动。

同时这里只判断了 key 相同,在 Vue2 源码中还判断了标签等属性。

function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
posted @ 2023-02-06 12:56  我不吃饼干呀  阅读(71)  评论(0编辑  收藏  举报