Vue原理解析(八):一塊兒搞明白使人頭疼的diff算法

上一篇:Vue原理解析(七):全面深刻理解響應式原理(下)-數組進階篇html

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

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

回到以前_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上修修補補來達到從新渲染的目的,因此無非是作三件事情:算法

建立新增節點數組

刪除廢棄節點緩存

更新已有節點bash

接下來咱們將介紹以上三種狀況分別什麼狀況下會遇到。dom

建立新增節點

新增節點兩種狀況下會遇到:異步

VNode中有的節點而oldVNode沒有

  • VNode中有的節點而oldVNode中沒有,最明顯的場景就是首次渲染了,這個時候是沒有oldVNode的,因此將整個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  // 都是靜態節點,跳過
  }
  ...
}
複製代碼

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

<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. 只有vnodechildren

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

3. 只有oldVnodechildren

  • 由於是以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算法過程了,你們能夠對比以前的遞歸流程圖再看一遍,相信思路會清晰不少。

最後按照慣例咱們仍是以一道vue可能會被問到的面試題做爲本章的結束~

面試官微笑而又不失禮貌的問道:

  • 爲何v-for裏建議爲每一項綁定key,並且最好具備惟一性,而不建議使用index

懟回去:

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

下一篇: Vue原理解析(九):搞懂computed和watch原理,減小使用場景思考時間

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索