Vue源碼解析(八) 之 diff 算法

上一篇 Vue 源碼解析(七) 之 響應式原理: 數組

之前章節介紹了VNode 如何生成真實Dom, 這只是patch 內首次渲染做的事, 完成了一小部分功能而已, 而它做的最重要的事情是當響應式觸發時,讓頁面的重現渲染這一過程能高效完成。 其實頁面的重新渲染完全可以使用新生成的Dom去整個替換掉舊的Dom, 然而這麼做比較低效, 所以藉助接下來將要介紹的diff比較算法來完成。

diff 算法做的事情是比較VNodeoldVnode , 再以VNode 爲標準的情況下在oldVNode 上做小的改動, 完成VNode 對應的Dom渲染。

回到之前==_update== 方法實現, 這個時候就會走到else 的邏輯了:

Vue.prototype._update = function(vnode) {
  const vm = this
  const prevVnode = vm._vnode
  
  vm._vnode = vnode  // 緩存爲之前vnode
  
  if(!prevVnode) {  // 首次渲染
    vm.$el = vm.__patch__(vm.$el, vnode)
  } else {  // 重新渲染
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}

既然是在現有的VNode 上修補,來達到重新渲染的目的, 所以無非是做三件事情:

創建新增節點

刪除廢棄節點

更新已有節點

接下來我們將介紹以上三種情況什麼情況下會遇到。

創建新增節點

新增節點兩種情況下會遇到:

VNode 中有的節點而oldVNode 沒有

  • VNode 中有的節點而oldVnode中沒有, 最明顯的場景就是首次渲染了, 這個時候是沒有oldNode的, 所以將整個VNode 渲染爲真實Dom 插入到根節點之內即可。

VNodeoldVnode 完全不同

  • VNodeoldVNode 不是同一個節點時, 直接會將VNode 創建爲真實Dom, 插入到舊節點的後面, 這個時候舊節點就變成了廢棄節點, 移除以完成替換過程。

判斷兩個節點是否爲同一個節點, 內部是這樣定義的:

function sameVnode (a, b) {  // 是否是相同的VNode節點
  return (
    a.key === b.key && (  // 如平時v-for內寫的key
      (
        a.tag === b.tag &&   // tag相同
        a.isComment === b.isComment &&  // 註釋節點
        isDef(a.data) === isDef(b.data) &&  // 都有data屬性
        sameInputType(a, b)  // 相同的input類型
      ) || (
        isTrue(a.isAsyncPlaceholder) &&  // 是異步佔位符節點
        a.asyncFactory === b.asyncFactory &&  // 異步工廠方法
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

刪除節點

上面創建新增節點的第二種情況以略有提及, 比較vnodeoldVnode , 如果根節點不同就將Vnode整顆渲染爲真實Dom, 插入到舊節點的後面, 最後刪除已經廢棄的舊節點即可:

在這裏插入圖片描述
patch 方法內將創建好的Dom插入到廢棄節點後面之後:

if (isDef(parentElm)) {  // 在它們的父節點內刪除舊節點
  removeVnodes(parentElm, [oldVnode], 0, 0)
}

-------------------------------------------------------------
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      removeNode(ch.elm)
    }
  }
}  // 移除從startIdx到endIdx之間的內容

------------------------------------------------------------

function removeNode(el) {  // 單個節點移除
  const parent = nodeOps.parentNode(el)
  if(isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

更新已有節點(重要)

這個纔是diff算法的重點, 當兩個節點是相同的節點時, 這個時候就需要找出它們的不同之處, 比較它們主要是使用patchVnode方法, 這個方法裏面主要也是處理幾種分支情況:

都是靜態節點

function patchVnode(oldVnode, vnode) {
  
  if (oldVnode === vnode) {  // 完全一樣
    return
  }

  const elm = vnode.elm = oldVnode.elm
  if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {  
    vnode.componentInstance = oldVnode.componentInstance
    return  // 都是靜態節點,跳過
  }
  ...
}

什麼是靜態節點? 這是編譯階段做的事情, 它會找出模板中的靜態節點並做上標記(isStatic 爲 true), 例如:

<template>
  <div>
    <h2>{{title}}</h2>
    <p>新鮮食材</p>
  </div>
</template>

這裏的h2 標籤就不是靜態節點 因爲是根據插值變化的, 而p標籤就是靜態節點, 因爲不會改變。 如果都是靜態節點就跳過這次比較, 這也是編譯階段爲diff比對做的優化。

vnode 節點沒有文本屬性

function patchVnode(oldVnode, vnode) {

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

  if (isUndef(vnode.text)) {  // vnode沒有text屬性
    
    if (isDef(oldCh) && isDef(ch)) {  // // 都有children
      if (oldCh !== ch) {  // 且children不同
        updateChildren(elm, oldCh, ch)  // 更新子節點
      }
    } 
    
    else if (isDef(ch)) {  // 只有vnode有children
      if (isDef(oldVnode.text)) {  // oldVnode有文本節點
        nodeOps.setTextContent(elm, '')  // 設置oldVnode文本爲空
      }
      addVnodes(elm, null, ch, 0, ch.length - 1)
      // 往oldVnode空的標籤內插入vnode的children的真實dom
    } 
    
    else if (isDef(oldCh)) {  // 只有oldVnode有children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)  // 全部移除
    } 
    
    else if (isDef(oldVnode.text)) {  // oldVnode有文本節點
      nodeOps.setTextContent(elm, '')  // 設置爲空
    }
  } 
  
  else {  vnode有text屬性
    ...
  }
  
  ...

如果vnode 沒有文本節點, 又會下面的四個分支:

1. 都有 children 且不相同

  • 使用updateChildren 方法更詳細的對比它們的children, 如果說更新已有節點是patch 的核心, 那這裏的更新children 就是核心中的核心, 這個之後使用流程圖的方式仔細說明。

2. 只有 vnode 有 children

  • 那這裏的 oldVnode 要麼是一個空標籤或者是文本節點, 如果是文本節點就清空節點, 然後將vnodechildren 創建爲真實Dom 後插入到空標籤內。

3. 只有 oldVnode 有 children

  • 因爲是以vnode 爲標準的, 所以vnode沒有的東西, oldVnode 內就是廢棄節點, 需要刪除掉。

4. 只有 oldVnode 有文本

  • 只要是oldVnode 有而vnode沒有的, 清空或移除即可。

vnode 節點有文本屬性

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {

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

  if (isUndef(vnode.text)) {  // vnode沒有text屬性
    ...
  } else if(oldVnode.text !== vnode.text) {  // vnode有text屬性且不同
    nodeOps.setTextContent(elm, vnode.text)  // 設置文本
  }
  
  ...

依舊以vnode爲標準, 所以vnode 有文本節點的話, 無論oldVnode 是什麼類型節點, 直接設置爲vnode內的文本即可。 至此, 整個diff比對的大致過程就算講解完畢了, 這裏借用網上的一張流程圖來理清思路:
在這裏插入圖片描述
更新已有節點值更新子節點(重點中的重點)

更新子節點示例:
<template>
  <ul>
    <li v-for='item in list' :key='item.id'>{{item.name}}</li>
  </ul>
</template>

export default {
  data() {
    return {
      list: [{
        id: 'a1',name: 'A'}, {
        id: 'b2',name: 'B'}, {
        id: 'c3',name: 'C'}, {
        id: 'd4',name: 'D'}
      ]
    }
  },
  mounted() {
    setTimeout(() => {
      this.list.sort(() => Math.random() - .5)
        .unshift({id: 'e5', name: 'E'})
    }, 1000)
  }
}

上面代碼中首先渲染一個列表, 然後將其隨機打亂順序後並添加一項到列表最前面, 這個時候就會觸發該組件更新子節點的邏輯, 之前也有一些其它的邏輯, 這裏只用關注更新子節點相關, 來看下它怎麼更新Dom的:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0  // 舊第一個下標
  let oldStartVnode = oldCh[0]  // 舊第一個節點
  let oldEndIdx = oldCh.length - 1  // 舊最後下標
  let oldEndVnode = oldCh[oldEndIdx]  // 舊最後節點
  
  let newStartIdx = 0  // 新第一個下標
  let newStartVnode = newCh[0]  // 新第一個節點
  let newEndIdx = newCh.length - 1  // 新最後下標
  let newEndVnode = newCh[newEndIdx]  // 新最後節點
  
  let oldKeyToIdx  // 舊節點key和下標的對象集合
  let idxInOld  // 新節點key在舊節點key集合裏的下標
  let vnodeToMove  // idxInOld對應的舊節點
  let refElm  // 參考節點
  
  checkDuplicateKeys(newCh) // 檢測newVnode的key是否有重複
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  // 開始遍歷children
  
    if (isUndef(oldStartVnode)) {  // 跳過因位移留下的undefined
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {  // 跳過因位移留下的undefine
      oldEndVnode = oldCh[--oldEndIdx]  
    } 
    
    else if(sameVnode(oldStartVnode, newStartVnode)) {  // 比對新第一和舊第一節點
      patchVnode(oldStartVnode, newStartVnode)  // 遞歸調用                        
      oldStartVnode = oldCh[++oldStartIdx]  // 舊第一節點和下表重新標記後移        
      newStartVnode = newCh[++newStartIdx]  // 新第一節點和下表重新標記後移        
    }
    
    else if (sameVnode(oldEndVnode, newEndVnode)) {  // 比對舊最後和新最後節點     
      patchVnode(oldEndVnode, newEndVnode)  // 遞歸調用                            
      oldEndVnode = oldCh[--oldEndIdx]  // 舊最後節點和下表重新標記前移            
      newEndVnode = newCh[--newEndIdx]  // 新最後節點和下表重新標記前移            
    }
    
    else if (sameVnode(oldStartVnode, newEndVnode)) { // 比對舊第一和新最後節點
      patchVnode(oldStartVnode, newEndVnode)  // 遞歸調用
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  
      // 將舊第一節點右移到最後,視圖立刻呈現
      oldStartVnode = oldCh[++oldStartIdx]  // 舊開始節點被處理,舊開始節點爲第二個
      newEndVnode = newCh[--newEndIdx]  // 新最後節點被處理,新最後節點爲倒數第二個
    }
    
    else if (sameVnode(oldEndVnode, newStartVnode)) { // 比對舊最後和新第一節點
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  // 遞歸調用
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // 將舊最後節點左移到最前面,視圖立刻呈現
      oldEndVnode = oldCh[--oldEndIdx]  // 舊最後節點被處理,舊最後節點爲倒數第二個
      newStartVnode = newCh[++newStartIdx]  // 新第一節點被處理,新第一節點爲第二個
    }
    
    else {  // 不包括以上四種快捷比對方式
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) 
        // 獲取舊開始到結束節點的key和下表集合
      }
      
      idxInOld = isDef(newStartVnode.key)  // 獲取新節點key在舊節點key集合裏的下標
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      
      if (isUndef(idxInOld)) { // 找不到對應的下標,表示新節點是新增的,需要創建新dom
        createElm(
          newStartVnode, 
          insertedVnodeQueue, 
          parentElm, 
          oldStartVnode.elm, 
          false, 
          newCh, 
          newStartIdx
        )
      }
      
      else {  // 能找到對應的下標,表示是已有的節點,移動位置即可
        vnodeToMove = oldCh[idxInOld]  // 獲取對應已有的舊節點
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
        oldCh[idxInOld] = undefined
        nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      }
      
      newStartVnode = newCh[++newStartIdx]  // 新開始下標和節點更新爲第二個節點
      
    }
  }
  
  ...
  
}

函數內首先會定義一堆let 定義的變量, 這些變量是隨着while循環體而改變當前值的, 循環的退出條件爲只要更新舊節點列表有一個處理完就退出, 看着循環體代碼挺複雜, 其實它只是做了三件事, 明白了哪三件事再看循環體,會發現其實並不複雜:

1. 跳過undefined

爲什麼會有undefined , 之後的流程圖會說明清楚。 這裏只要記住, 如果舊開始節點爲undefined , 就後移一位; 如果舊結束節點爲undefined, 就前移一位。

2. 快捷查找

首先會嘗試四種快速查找的方式, 如果不匹配, 再做進一步處理:

  • 2.1 新開始和舊開始節點比對
    如果匹配, 表示它們位置都是對的, Dom不用改, 就將新舊節點開始的下標往後移一位即可。

  • 2.2 舊結束和新結束節點對比
    如果匹配, 也表示它們位置是對的, Dom 不用改, 就將新舊節點結束的下標前移一位即可。

  • 2.3 舊開始和新結束節點比較
    如果匹配, 位置不對需要更新Dom視圖, 將舊開始節點對應的真實Dom插入到最後一位, 舊開始節點下標後移一位, 新結束節點下標前移一位。

  • 2.4 舊結束和新開始節點比對
    如果匹配, 位置不對需要更新Dom視圖, 將舊結束節點對應的真實Dom插入到就開始節點對應真實Dom的前面, 舊結束節點下標前移一位, 新開始節點下標後移一位。

3. key 值查找

  • 3.1 如果和已有key 值匹配
    那就說明是已有的節點, 只是位置不對, 那就移動節點位置即可。

  • 3.2 如果和已有key值不匹配

再已有的key值集合內找不到, 那就說明是新的節點, 那就創建一個對應的真實Dom節點, 插入到舊開始節點對應的真實Dom 前即可。

這麼說並不太好理解, 結合之前的示例, 根據以下的流程圖將會明白很多:

在這裏插入圖片描述
↑ 示例的初始狀態就是這樣了, 之前定義的下標以及對應的節點就是startend 標記。
在這裏插入圖片描述
↑ 首先進行之前說明兩兩四次的快捷比對, 找不到後通過舊節點的key值列表查找, 並沒有找到說明E是新增的節點, 創建對應的真實Dom, 插入到舊節點裏start 對應真實Dom的前面, 也就是A的前面, 已經處理完了一個, 新start位置後移一位。

在這裏插入圖片描述
↑ 接着開始處理第二個, 還是首先進行快捷查找, 沒有後進行key值列表查找。 發現是已有的節點, 只是位置不對, 那麼進行插入操作, 參考節點還是A節點, 將原來舊節點C設置爲undefined , 這裏之後會跳過它。 又處理完了一個節點, 新start後移一位。

在這裏插入圖片描述
↑ 再處理第三個節點, 通過快捷查找找到了, 是開始節點對應舊開始節點, Dom 位置是對的, 新start 和 舊start 都後移一位。
在這裏插入圖片描述
↑ 接着處理的第四個節點, 通過快捷查找, 這個時候先滿足了舊開始節點和新結束節點的匹配, Dom位置是不對的, 插入節點到最後位置, 最後將新end前移一位, 就start後移一位。

在這裏插入圖片描述
↑ 處理最後一個節點, 首先會執行跳過undefined 的邏輯, 然後再開始快捷比對, 匹配到的是最新開始節點和舊開始節點, 它們各自start 後移一位, 這個時候就會跳出循環了。 接着看下最後的收尾代碼:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  ...
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    ...
  }
  
  if (oldStartIdx > oldEndIdx) {  // 如果舊節點列表先處理完,處理剩餘新節點
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)  // 添加
  } 
  
  else if (newStartIdx > newEndIdx) {  // 如果新節點列表先處理完,處理剩餘舊節點
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)  // 刪除廢棄節點
  }
}

我們之前的示例剛好是新舊節點列表同時處理完退出的循環, 這裏是退出循環後爲還有沒處理完的幾點,做不同的處理:
在這裏插入圖片描述

以新節點列表爲標準, 如果是新節點列表處理完畢, 就列表還有沒被處理的廢棄節點,刪除即可; 如果是舊節點先處理完,新列表裏還有沒被使用的節點, 創建真實Dom 並插入到試圖即可。 這就是整個diff算法過程了, 可以比對之前的遞歸流程圖再看一遍, 有助於增強思路。

最後我們用一個問題來結束本章內容~

  • 爲什麼v-for裏建議爲每一項綁定key, 而且最好具有唯一性, 而不建議使用index?

解答:

  • diff比對內部做更新子節點時, 會根據oldVnode 內沒有處理的節點得到一個key值和下標對應的對象集合, 爲的就是處理vnode每一個節點是, 能快速查找該節點是否是已有的節點, 從而提高整個diff比對的性能。 如果是一個動態列表, key值最好能保持唯一性, 但像圖片輪播圖那種不會變更的列表, 使用index 也是沒問題的。

下一遍: Vue 原理解析(九)之 computed 和 watch 原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章