《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)
      )
    )
  )
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章