vue源碼解析-圖解diff詳細過程

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

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

這裏並不會直接就開始講diff,爲了讓你們能瞭解到diff的詳細過程,所在開始核心部分以前,有些簡單的概念和流程須要提早說明一下,固然最好是但願你已經對vue源碼patch這部分有些瞭解。node

幾個概念

因爲核心是說明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 () {
    ...
  }

}
複製代碼

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

  • 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。dom

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:async

  • 不存在 oldVnode,則進行createElm函數

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

  • 存在 oldVnode 和 vnode,可是 sameVnode 返回 true, 則進行patchVnodeui

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完成,再逐一觸發。

相關文章
相關標籤/搜索