Vue2 的 diff 算法規則原理詳解

前言

所謂 diff 算法,就是通過比對新舊兩個虛擬節點不一樣的地方,針對那些不一樣的地方進行新增或更新或刪除操作。接下來我們詳細介紹節點更新的過程。

首先進行靜態節點處理,判斷新舊兩個虛擬節點是否是靜態節點,如果是,就不需要進行更新操作,可以直接跳過更新比對的過程 。

再更新處理新老節點的屬性,獲取新老節點的子節點,然後進行一定規則的判斷。

這裡值得多說一下的是,Vue2 在更新元素屬性的時候,是暴力全量 diff 更新的,Vue3 則做瞭很多優化。

算法規則

具體規則如下:

  • 如果新節點有子節點而老節點沒有子節點,則判斷老節點是否有文本內容,如果有就清空老節點的文本內容,然後為其新增子節點。
  • 如果新節點沒有子節點而老節點有子節點,則先刪除老節點的子節點,然後設置文本內容。
  • 如果新節點沒有子節點,老節點也沒有子節點,則進行文本的比對,然後設置文本內容。
  • 如果新節點有子節點,老節點也有子節點,則進行新老子節點的比對,然後進行新增、移動、刪除的操作,這也就是傳說中的 diff 算法發生的地方

patchVnode 源碼解析:

  // diff 的過程
  // 分析當前兩個節點的類型
  // 如果是元素,更新雙方屬性、特性等,同時比較雙方子元素,這個遞歸過程,叫深度優先
  // 如果雙方是文本,更新文本
  function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    if (oldVnode === vnode) {
      return
    }
    // 靜態節點處理
    // 判斷新舊兩個虛擬節點是否是靜態節點,如果是,就不需要進行更新操作,可以直接跳過更新比對的過程
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }
    
    // 獲取雙方孩子
    const oldCh = oldVnode.children
    const ch = vnode.children
    // 比較雙方屬性
    // Vue2在更新元素屬性的時候,是暴力全量 diff 更新的。Vue3 則做瞭很多優化。
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // 根據雙方類型的幾種情況分別處理
    if (isUndef(vnode.text)) {// 新節點沒有文本
      if (isDef(oldCh) && isDef(ch)) {
        // 雙方都有子元素,就進行重排,傳說中的 diff 就發生在這裡
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 新節點有孩子, 老的沒有,新增創建
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        // 判斷老節點是否有文本內容,如果有則先清空
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 批量添加子節點
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 新節點沒有孩子,老的有的,則刪除老節點的孩子節點
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 新節點沒有文本節點,老的有文本節點,則清空老的文本節點
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新老節點都是文本節點,則判斷新老文本內容是否相同進行文本更新
      nodeOps.setTextContent(elm, vnode.text)
    }
    // 鉤子處理
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

接下來,我們看看兩組子元素都是多節點比對的情況,也就是傳說 diff 發生的地方。

在新老兩組VNode節點的左右頭尾兩側都有一個變量標記,在遍歷過程中這幾個變量都會向中間靠攏,當oldStartIdx > oldEndIdx或者newStartIdx > newEndIdx時結束循環。

diff 優化策略

先進行以下4種情況的優化策略:

  • 老數組的開始與新數組的開始:oldStartVnode, newStartVnode
  • 老數組的結尾與新數組的結尾:oldEndVnode, newEndVnode
  • 老數組的開始與新數組的結尾:oldStartVnode, newEndVnode
  • 老數組的結尾與新數組的開始:oldEndVnode, newStartVnode

如果以上4種情況都沒找到,則從新數組的第一個節點去老數組中去查找,找到瞭就進行遞歸更新,沒找到則創建新節點。

老數組的開始與新數組的開始

新數組的結尾節點有剩餘則添加

從左往右比對完,老數組的遊標先相交瞭,發現新數組結尾還有節點沒有比對,則在新數組結尾創建剩下沒有比對的節點。

老數組的結尾節點有剩餘則刪除

從左往右比對完,新數組的遊標先相交瞭,發現老數組結尾還有節點沒有比對,則刪除老數組剩下沒有比對的節點。

老數組的結尾與新數組的結尾

新數組的開頭節點有剩餘則添加

從右往左比對完,老數組的遊標先相交瞭,發現新數組開頭還有節點沒有比對,則在新數組開頭創建沒有比對的節點。

老數組的開頭節點有剩餘則刪除

從右往左比對完,新數組的遊標先相交瞭,發現老數組的開頭還有節點沒有比對,則刪除老數組開頭沒有比對的節點。

老數組的開始與新數組的結尾

如果老數組的開頭節點與新數組的結尾節點比對成功瞭,除瞭會繼續遞歸比對它們,還將真實節點 A 移動到結尾。

老數組的結尾與新數組的開始

如果老數組的結尾節點與新數組的開始節點比對成功瞭,除瞭會繼續遞歸比對它們,還將真實節點D移動到開頭。

以上四種情況都沒對比成功

如果以上4種情況都沒找到,則拿新數組的第一個節點去老數組中去查找。

如果拿新數組的第一個節點去老數組中查找成功瞭,則會繼續遞歸比對它們,同時將比對到的節點移動到對應的節點前面,並且將老數組原來的位置內容設置為 undefind。

如果拿新數組的第一個節點去老數組中查找,沒找到,則創建一個新的節點插入到未處理的節點前面

推薦在渲染列表時為節點設置 key

如果我們在模版渲染列表時,為節點設置瞭屬性 key,那麼在上面建立的 key 與 index 索引的對應關系時,就生成瞭一個 key 對應著一個節點下標這樣一個對象。 也就是說,如果在節點上設置瞭屬性 key,那麼在老的虛擬DOM中找相同節點時,可以直接通過 key 拿到下標,從而獲取節點,否則我們就需要每一次都要進行遍歷查找。 所以非常推薦在渲染列表時為節點設置 key,最好是後端返回的唯一 ID。

循環比對結束的後續處理工作

如果老數組的遊標先相交瞭,則判斷新數組中是否還有剩下的節點,沒有進行比對的,創建它們。

如果新數組的遊標先相交瞭,則判斷老數組中是否還有剩下的節點,沒有進行比對的,把它們都刪除掉。

源碼解析

// 傳說中的 diff 發生的地方
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 4個遊標和對應節點
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    // 後續查找需要的變量
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !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)) {
        // 先查找兩個開頭節點
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 兩個結尾節點
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 老的開始節點,新的結尾節點
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 進行節點移動
        // node.insertBefore(newnode,existingnode) 1.newnode 必需。需要插入的節點對象  2.existingnode 可選。在其之前插入新節點的子節點。如果未規定,則 insertBefore 方法會在結尾插入 newnode。
        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, newCh, newStartIdx)
        // 進行節點移動
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 首尾沒找到
        // 第一次創建一個老的節點的索引 Map,方便後續不需要遍歷查找,這是一個空間換時間的方法
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 拿新虛擬DOM開頭的第一個節點,去老的虛擬DOM中進行查找
        // 如果我們在模版渲染列表時,為節點設置瞭屬性 key,那麼在上面建立的 key 與 index 索引的對應關系時,就生成瞭一個 key 對應著一個節點下標這樣一個對象。
        // 也就是說,如果在節點上設置瞭屬性 key,那麼在老的虛擬DOM中找相同節點時,可以直接通過 key 拿到下標,從而獲取節點,否則我們就需要每一次都要進行遍歷查找。
        // 所以非常推薦在渲染列表時為節點設置 key,最好是後端返回的唯一 ID。
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          // 沒找到就進行創建,並且插入到未處理的節點(oldStartVnode.elm)的前面
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          // 找到之後,也要進行判斷是否相同節點
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 遞歸更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            // 創建新的節點進行替換
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // 循環結束
    // 後續處理工作
    if (oldStartIdx > oldEndIdx) {
      // 老的先結束,判斷新的虛擬DOM中是否還有剩下的節點,批量創建
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 新的先結束,判斷老的虛擬DOM中是否還剩下,批量刪除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

總結

總的來說 Vue2 的 diff 算法就是以新的虛擬DOM為準進行與老虛擬DOM的比對,繼而進行各種情況的處理。大概可以分為 4 種情況:更新節點、新增節點、刪除節點、移動節點位置。比對新老兩個虛擬DOM,就是通過循環,每循環到一個新節點,就去老節點列表裡面找到和當前新節點相同的舊節點。如果在舊節點列表中找不到,說明當前節點是需要新增的節點,我們就需要進行創建節點並插入視圖的操作;如果找到瞭,就做更新操作;如果找到的舊節點與新節點位置不同,則需要移動節點等。

其中為瞭快速查找到節點,Vue2 的 diff 算法設置瞭 4 種優化策略,分別是:

  • 老數組的開始與新數組的開始
  • 老數組的結尾與新數組的結尾
  • 老數組的開始與新數組的結尾
  • 老數組的結尾與新數組的開始

通過這 4 種快捷的查找方式,我們就不需要循環來查找瞭,隻有當以上 4 種方式都查找不到的時候,再進行循環查找。

最後循環結束後需要對未處理的節點進行處理。

如果是老節點列表先循環完畢,這個時候如果新節點列表還有剩餘的節點,則說明這些節點都是需要新增的節點,直接把這些節點創建並插入到 DOM 中就行瞭。

如果是新節點列表先循環完畢,這個時候如果老節點列表還有剩餘節點,則說明這些節點都是要被廢棄的節點,是應該被刪除的節點,直接批量刪除就可以瞭。

到此這篇關於Vue2 的 diff 算法規則原理詳解的文章就介紹到這瞭,更多相關Vue2 diff 算法內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: