vue原始碼解析-圖解diff詳細過程

yuwanli發表於2019-06-08

也看過其他講vue diff過程的文章,但是感覺都只是講了其中的一部分(對比方式),沒有對其中細節的部分做詳細的講解,如

  • 匹配成功後進行的patchVnode是做了什麼?為什麼的有的緊接著要進行dom操作,有的沒有?
  • 在diff的過程中,指標的具體如何移動?及哪些部分發生了變化?
  • insertedVnodeQueue 又是何用?為何一直帶著?
  • 然後也是困惑很久的,很多文章在移動這部分直接操作的oldChildren,然而oldChildren會發生移動麼?那麼到底是誰發生了移動呢?

這裡並不會直接就開始講diff,為了讓大家能瞭解到diff的詳細過程,所在開始核心部分之前,有些簡單的概念和流程需要提前說明一下,當然最好是希望你已經對vue原始碼patch這部分有些瞭解。

幾個概念

由於核心是說明diff的過程,所以會先把diff涉及到的核心概念簡單說明一下,對於這些若仍有疑問可以在評論區留言:

1. vnode

簡單的說就是真實 dom 的描述物件,這也是vue的特點之一 - virtual dom。由於原生的dom結構過於複雜,當需要獲取並瞭解節點資訊的時候,並不需要操作複雜的 dom,相應的vue 是先用其描述物件進行分析(diff 對比也就是vnode的對比),然後再反應到真實的 dom。

export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  functionalContext: Component | void; // real context vm for functional nodes
  functionalOptions: ?ComponentOptions; // for SSR caching
  functionalScopeId: ?string; // functioanl scope id support

  constructor () {
    ...
  }

}
複製程式碼

需要注意的是後面會涉及到的幾個屬性:

  • childrenparent 通過這個建立其vnode之間的層級關係,對應的也就是真實dom的層級關係
  • text 如果存在值,證明該vnode對應的就是一個檔案節點,跟children是一個互斥的關係,不可能同時有值
  • tag 表明當前vnode,對應真實 dom 的標籤名,如‘div’、‘p’
  • elm 就是當前vnode對應的真實的dom

2. patch

閱讀原始碼中複雜函式的小技巧:看‘一頭’‘一尾’。‘頭’指的的入參,提煉出能看懂和能理解的引數(oldVnodevnodeparentElm),‘尾’指的是函式的處理結果,這個返回的elm。所以可以根據‘頭尾’總結下,patch完成之後,新的vnode上會對應生成elm,也就是真實的 dom,且是已經掛載到parentElm下的dom。簡單的來說,如vue 例項初始化、資料更改導致的頁面更新等,都需要經過patch方法來生成elm。

  function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    // ...
    const insertedVnodeQueue = []
    // ...
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue, parentElm, refElm)
    } 
    // ...
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
    } 
    // ...
    return vnode.elm
  }
複製程式碼

patch 的過程(除去邊界條件)主要會有三種 case:

  • 不存在 oldVnode,則進行createElm

  • 存在 oldVnode 和 vnode,但是 sameVnode 返回 false, 則進行createElm

  • 存在 oldVnode 和 vnode,但是 sameVnode 返回 true, 則進行patchVnode

3. sameVnode

上面提到了sameVnode,程式碼如下:

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
複製程式碼

簡單的舉個的case,比如之前是一個<div>標籤,由於邏輯的變動,變為<p>標籤了,則sameVnode會返回false(a.tag === b.tag 返回 false)。所以sameVnode表明的是,滿足以上條件就是同一個元素,才可進行patchVnode。反過來理解就是,只要以上任意一個發生改變,則無需進行pathchVnode,直接根據vnode進行createElm即可。

注意,sameVnode 返回true,不能說明是同一個vnode,這裡的相同是指當前的以上指標一致,他們的children可能發生了變化,仍需進行patchVnode進行更新。

patchVnode

patch方法,我們知道patchVnode方法和createElm的方法最終的處理結果一樣,就是生成或更新了當前vnode對應的dom。

經過上面的分析,總結下,就是當需要生成 dom,且前後vnode進行sameVnodetrue的情況下,則進行patchVnode

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // ...
    const elm = vnode.elm = oldVnode.elm
    // ...
    const oldCh = oldVnode.children
    const ch = vnode.children
    // ...
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 具體是何種情況下會走到這個邏輯???
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
    // ...
  }
複製程式碼

以上是patchVnode的部分程式碼,展示出來的這部分邏輯,也是patchVnode的核心處理邏輯。

以上程式碼,充斥大量的if else,大家可以思考幾個問題?

  1. 根據以上程式碼分析,對於一個vnode,可分成三種vnode: 文字vnode、存在chilren的vnode、不存在children的vnode。對於oldVnode和vnode交叉組合的話,應該會有9種 case,那麼以上的程式碼有全部覆蓋所有 case 麼?
  2. 那比如,具體哪些case會進入到removeVnodes的邏輯?

這其實也是我在閱讀的時候思考的問題,最終我採用了以下的方式(對著程式碼繪製表格)來解決這種複雜的if else邏輯的解讀:

oldVnode.text oldCh !oldCh
vnode.text setTextContent setTextContent setTextContent
ch addVnodes updateChildren addVnodes
!ch setTextContent removeVnodes setTextContent

對應著表格,然後對應著程式碼,相信你能找到答案。

updateChildren

經過上面的分析,只有在oldChch都存在的情況下才會執行updateChildren,此時入參是oldChch,所以可以知道的是,updateChildren進行的是同層級下的children的更新比較,也就是‘傳說中的’diff了。

開始分析之前,可以思考下:若現在js來操作原生dom的一個<ul>列表,當然這個列表也是用原生的js來實現的,現在如果其中的資料順序發生了變化,第一條要排到末尾或具體的某個位置,或者有新增資料、刪除資料等,該如何操作。

let listData = [
  '測試資料1',
  '測試資料2',
  '測試資料3',
  '測試資料4',
  '測試資料5',
]
let ulElm = document.createElement('ul');
let liStr = '';
for(let i = 0; i < listData.length; i++){
  liStr += `<li>${listData[i]}</li>`
}
ulElm.append(liStr)
document.body.innerHTML = ''
document.body.append(ulElm)
複製程式碼

這個時候由於變化的不確定性,不希望在業務程式碼邏輯中維護繁瑣的insertBeforeappendChildremoveChildreplaceChild,立馬能想到的粗暴的解決方式是,我們拿到最新的listData,把上面面建立的流程再走一遍。

然而vue採取的是diff演算法,簡單的說就是:

  1. 還是和上面一樣,依然先獲取到最新的listData
  2. 然後新的 data 進行_render操作,得到新的vnode
  3. 對比前後vnode,也就是patch過程
  4. 對於同一層級的節點,會進行updateChildren操作(diff),進行最小的變動

diff

updateChildren程式碼如下:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    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

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    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)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        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)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        } else {
          vnodeToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          }
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            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)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    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)
    }
  }
複製程式碼

之前分析了,oldChch表示的是同層級的vnode的列表,也就是兩個陣列

開始之前定義了一系列的變數,分別如下:

  • oldStartIdx 開始指標,指向oldCh中待處理部分的頭部,對應的vnode也就是oldStartVnode
  • oldEndIdx 結束指標,指向oldCh中待處理部分的尾部,對應的vnode也就是oldEndVnode
  • newStartIdx 開始指標,指向ch中待處理部分的頭部,對應的vnode也就是newStartVnode
  • newEndIdx 結束指標,指向ch中待處理部分的尾部,對應的vnode也就是newEndVnode
  • oldKeyToIdx 是一個map,其中key就是常在for迴圈中寫的v-bind:key的值,value 對應的就是當前vnode,也就是可以通過唯一的key,在map中找到對應的vnode

updateChildren使用的是while迴圈來更新dom的,其中的退出條件就是!(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx),換種理解方式:oldStartIdx > oldEndIdx || newStartIdx > newEndIdx,什麼意思呢,就是隻要有一個發生了‘交叉’(下面的例子會出現交叉)就退出迴圈。

舉個例子

原有的oldCh的順序是 A 、B、C、D、E、F、G,更新後成ch的順序 F、D、A、H、E、C、B、G。

base

圖解說明

為了更好理解後續的round,開始之前先看下相關符合標記的說明

rule

diff的過程

round1: 對比順序:A-F -> G-G,匹配成功,然後:

  1. 對G進行patchVnode的操作,更新oldEndVnodeG和newEndVnodeG的elm
  2. 指標移動,兩個尾部指標向左移動,即oldEndIdx-- newEndIdx--

round1

round2: 對比順序:A-F -> F-B -> A-B -> F-F,匹配成功,然後:

  1. 對F進行patchVnode的操作,更新oldEndVnodeF和newEndVnodeF的elm
  2. 指標移動,移動指標,即oldEndIdx-- newStartIdx++
  3. 找到oldStartVnode在dom中所在的位置A,然後在其前面插入更新過的F的elm

round2

round3: 對比順序:A-D -> E-B -> A-B -> E-D,仍未成功,取D的key,在oldKeyToIdx中查詢,找到對應的D,查詢成功,然後:

  1. 將D取出賦值到 vnodeToMove
  2. 對D進行patchVnode的操作,更新vnodeToMoveD和newStartVnodeD的elm
  3. 指標移動,移動指標,即newStartIdx++
  4. 將oldCh中對應D的vnode置undefined
  5. 在dom中找到oldStartVnodeA的elm對應的節點,然後在其前面插入更新過的D的elm

round3

round4: 對比順序:A-A,對比成功,然後:

  1. 對A進行patchVnode的操作,更新oldStartVnodeA和newStartVnodeA的elm
  2. 指標移動,兩個尾部指標向左移動,即oldStartIdx++ newStartIdx++

round4

round5: 對比順序:B-H -> E-B -> B-B ,對比成功,然後:

  1. 對B進行patchVnode的操作,更新oldStartVnodeB和newStartVnodeB的elm
  2. 指標移動,即oldStartIdx++ newEndIdx--
  3. 在dom中找到oldEndVnodeE的elm的nextSibling節點(即G的elm),然後在其前面插入更新過的B的elm

round5

round6: 對比順序:C-H -> E-C -> C-C ,對比成功,然後(同round5):

  1. 對C進行patchVnode的操作,更新oldStartVnodeC和newStartVnodeC的elm
  2. 指標移動,即oldStartIdx++ newEndIdx--
  3. 在dom中找到oldEndVnodeE的elm的nextSibling節點(即剛剛插入的B的elm),然後在其前面插入更新過的C的elm

round6

round7: 獲取oldStartVnode失敗(因為round3的步驟4),然後:

  1. 指標移動,即oldStartIdx++

round7

round8: 對比順序:E-H、E-E,匹配成功,然後(同round1):

  1. 對E進行patchVnode的操作,更新oldEndVnodeE和newEndVnodeE的elm
  2. 指標移動,兩個尾部指標向左移動,即oldEndIdx-- newEndIdx--

round8

last round8之後oldCh提前發生了‘交叉’,退出迴圈。

last
last:

  1. 找到newEndIdx+1對應的元素A
  2. 待處理的部分(即newStartIdx-newEndIdx中的vnode)則為新增的部分,無需patch,直接進行createElm
  3. 所有的這些待處理的部分,都會插到步驟1中dom中A的elm所在位置的後面

需要注意的點:

  • oldCh和ch在過程中他們的位置並不會發生變化
  • 真正進行操作的是進入updateChildren傳入的parentElm,即父vnode的elm
  • while每一次的迴圈體,我稱之為回和,也就是round
  • 多次提到patchVnode,往前看patchVnode的部分,其處理的結果就是oldVnode.elm和vnode.elm得到了更新
  • 有多次的原生的dom的操作,insertBefore,重點是要先找到插入的地方

總結

每一個round(以上例子中涉及到的)做的事情如下(優先順序從上至下):

  • oldStartVnode則移動(參照round6)
  • 對比頭部,成功則更新並移動(參照round4)
  • 對比尾部,成功則更新並移動(參照round1)
  • 頭尾對比,成功則更新並移動(參照round5)
  • 尾頭對比,成功則更新並移動(參照round2)
  • oldKeyToIdx中根據newStartVnode的可以進行查詢,成功則更新並移動(參照round3) (更新並移動:patchVnode更新對應vnode的elm,並移動指標)

關於插入的問題,為何有的緊接著進行的dom操作,有的沒有?何時在oldStartVnode的elm前插,何時在oldEndVnode的elm的nextSibling前插?

這裡只要記住,oldChch都是參照物,其中,ch是我們的目標順序,而oldCh是我們用來了解當前dom順序的參照,也就是開篇提到的vnode的介紹。所以整個diff過程,就是對比oldChch,確認當前round,oldCh如何移動更靠近ch,由於oldCh中待處理的部分仍在dom中,所以可以根據oldCh中的oldStartVnode的elm和 oldEndVnode的elm的位置,來確定匹配成功的元素該如何插入。

  • ‘頭頭’匹配成功的時候,證明當前oldStartVnode位置正是現在的位置,無需移動,進行patchVnode更新即可
  • ‘尾尾’匹配成功同‘頭頭’匹配成功,也無需移動
  • 若‘尾頭匹配成功’,即oldEndVnodenewSatrtVnode匹配成功,這裡注意成功的是newSatrtVnode,所以是在待處理dom的頭部前插。如round2,當前待處理的部分,也就是oldCh中黑塊的部分,頭部也就是oldStartVnode。也就是在oldStartVnode的elm前面插入newSatrtVnode的elm。
  • 同理,若‘頭尾匹配成功’,即oldStartVnodenewEndVnode匹配成功,這裡注意成功的是newEndVnode,所以是在待處理dom的尾部插入(就是尾部元素的下一個元素前插)。如round5,當前待處理的部分,也就是oldCh中黑塊的部分,尾部也就是oldEndVnode。也就是先找到oldEndVnode的elm的nextSibling前面插入newEndVnode的elm。

(這裡有提到‘待處理塊’,具體大家可以看示意圖,注意oldCh中的待處理塊部分和dom中待處理的部分)

以上已經包含updateChildren中大部分的內容了,當然還有部分沒有涉及到的就不一一說明的,具體的大家可以對著原始碼,找個例項走整個的流程即可。


最後還有一個問題沒回答,insertedVnodeQueue有何用?為啥一直帶著?

這部分涉及到元件的patch的過程,這裡可以簡單說下:元件的$mount函式之後之後並不會立即觸發元件例項的mounted鉤子,而是把當前例項pushinsertedVnodeQueue中,然後在patch的倒數第二行,會執行invokeInsertHook,也就是觸發所有元件例項的insert的鉤子,而元件的insert鉤子函式中才會觸發元件例項的mounted鉤子。比方說,在patch的過程中,patch了多個元件vnode,他們都進行了$mount即生成dom,但沒有立即觸發$mounted,而是等整個patch完成,再逐一觸發。

相關文章