vue之virtual-dom篇

首先理解VNode對象

一個VNode的實例對象包含了以下屬性,參見源碼src/vdom/vnode.js

constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (): Component | void {
    return this.componentInstance
  }

其中幾個比較重要的屬性:

  • tag: 當前節點的標籤名
  • data: 當前節點的數據對象,具體包含哪些字段可以參考vue源碼types/vnode.d.ts中對VNodeData的定義
  • children: 數組類型,包含了當前節點的子節點
  • text: 當前節點的文本,一般文本節點或註釋節點會有該屬性
  • elm: 當前虛擬節點對應的真實的dom節點
  • key: 節點的key屬性,用於作爲節點的標識,有利於patch的優化

比如,定義一個vnode,它的數據結構是:

    {
        tag: 'div'
        data: {
            id: 'app',
            class: 'page-box'
        },
        children: [
            {
                tag: 'p',
                text: 'this is demo'
            }
        ]
    }

通過一定的渲染函數,最後渲染出的實際的dom結構就是:

   <div id="app" class="page-box">
       <p>this is demo</p>
   </div>

VNode對象是JS用對象模擬的DOM節點,通過渲染這些對象即可渲染成一棵dom樹。

patch

我對patch的理解就是對內容已經變更的節點進行修改的過程

當model中的響應式的數據發生了變化,這些響應式的數據所維護的dep數組便會調用dep.notify()方法完成所有依賴遍歷執行的工作,這裏面就包括了視圖的更新即updateComponent方法。

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

完成視圖的更新工作事實上就是調用了vm._update方法,這個方法接收的第一個參數是剛生成的Vnode(vm._render()會生成一個新的Vnode) vm._update方法主要調用了vm.__patch__() 方法,這也是整個virtaul-dom當中最爲核心的方法,主要完成了prevVnode和vnode的diff過程並根據需要操作的vdom節點打patch,最後生成新的真實dom節點並完成視圖的更新工作。

   function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
        // 當oldVnode不存在時
        if (isUndef(oldVnode)) {
            // 創建新的節點
            createElm(vnode, insertedVnodeQueue, parentElm, refElm)
        } else {
            const isRealElement = isDef(oldVnode.nodeType)
            if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // patch existing root node
            patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } 
        }
    }

在當oldVnode不存在的時候,這個時候是root節點初始化的過程,因此調用了createElm(vnode, insertedVnodeQueue, parentElm, refElm)方法去創建一個新的節點。而當oldVnode是vnode且sameVnode(oldVnode, vnode)2個節點的基本屬性相同,那麼就進入了2個節點的patch以及diff過程。 (在對oldVnode和vnode類型判斷中有個sameVnode方法,這個方法決定了是否需要對oldVnode和vnode進行diff及patch的過程。如果2個vnode的基本屬性存在不一致的情況,那麼就會直接跳過diff的過程,進而依據vnode新建一個真實的dom,同時刪除老的dom節點)

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)
  )
}

patch過程主要調用了patchVnode(src/core/vdom/patch.js)方法進行的:

if (isDef(data) && isPatchable(vnode)) {
      // cbs保存了hooks鉤子函數: 'create', 'activate', 'update', 'remove', 'destroy'
      // 取出cbs保存的update鉤子函數,依次調用,更新attrs/style/class/events/directives/refs等屬性
      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)
    }

更新真實dom節點的data屬性,相當於對dom節點進行了預處理的操作 接下來:

    ...
    const elm = vnode.elm = oldVnode.elm
    const oldCh = oldVnode.children
    const ch = vnode.children
    // 如果vnode沒有文本節點
    if (isUndef(vnode.text)) {
      // 如果oldVnode的children屬性存在且vnode的屬性也存在
      if (isDef(oldCh) && isDef(ch)) {
        // updateChildren,對子節點進行diff
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 如果oldVnode的text存在,那麼首先清空text的內容
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 然後將vnode的children添加進去
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 刪除elm下的oldchildren
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // oldVnode有子節點,而vnode沒有,那麼就清空這個節點
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 如果oldVnode和vnode文本屬性不同,那麼直接更新真是dom節點的文本元素
      nodeOps.setTextContent(elm, vnode.text)
    }

這個patch的過程又分爲幾種情況: 1.當vnode的text爲空,即不是文本節點時。

  • 如果oldVnode和新節點vnode都有子節點。 則調用updateChildren( ),對子節點進行diff
  • 如果只有新節點vnode有子節點 則判斷oldVnode是否是文本節點,如果是文本節點,則首先清空真實節點的text的內容。然後把新節點的children添加到elm中。
  • 如果只有oldVnode有子節點時 則調用removeVnodes()刪除elm下的oldVnode的children。
  • 如果oldVnode和新節點vnode都沒有子節點,且oldVnode是文本節點 則清空真實節點的text的內容。

2.當vnode的text存在,即是文本節點時 則設置真實節點的text內容爲vnode的text內容。

diff過程

我對diff的理解就是遍歷兩棵不同的虛擬樹,如果其中有的節點不同,則進行patch。

上個函數的updateChildren(src/core/vdom/patch.js)方法就是diff過程,它也是整個diff過程中最重要的環節:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // 爲oldCh和newCh分別建立索引,爲之後遍歷的依據
    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, elmToMove, refElm
    
    // 直到oldCh或者newCh被遍歷完後跳出循環
    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 {
        // 如果以上條件都不滿足,那麼這個時候開始比較key值,首先建立key和index索引的對應關係
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
        // 如果idxInOld不存在
        // 1. newStartVnode上存在這個key,但是oldKeyToIdx中不存在
        // 2. newStartVnode上並沒有設置key屬性
        if (isUndef(idxInOld)) { // New element
          // 創建新的dom節點
          // 插入到oldStartVnode.elm前面
          // 參見createElm方法
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          elmToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !elmToMove) {
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          
          // 將找到的key一致的oldVnode再和newStartVnode進行diff
          if (sameVnode(elmToMove, newStartVnode)) {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            // 移動node節點
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          } else {
            // same key but different element. treat as new element
            // 創建新的dom節點
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          }
        }
      }
    }
    // 如果最後遍歷的oldStartIdx大於oldEndIdx的話
    if (oldStartIdx > oldEndIdx) {        // 如果是老的vdom先被遍歷完
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      // 添加newVnode中剩餘的節點到parentElm中
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) { // 如果是新的vdom先被遍歷完,則刪除oldVnode裏面所有的節點
      // 刪除剩餘的節點
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

代碼中,oldStartIdx,oldEndIdx是遍歷oldCh(oldVnode的子節點)的索引 newStartIdx,newEndIdx是遍歷newCh(vnode的子節點)的索引

diff遍歷的過程如下: (節點屬性中不帶key的情況)

遍歷完的條件就是oldCh或者newCh的startIndex >= endIndex 首先先判斷oldCh的起始節點oldStartVnode和末尾節點oldEndVnode是否存在,如果不存在,則oldCh的起始節點向後移動一位,末尾節點向前移動一位。

如果存在,則每一輪diff都進行比較如下比較:

  1. sameVnode(oldStartVnode, newStartVnode) 判斷老節點的初節點和新節點的初節點是否是同一類型,如果是,則對它們兩個進行patchVnode(patch過程).兩個節點初節點分別向後移動一位。
  2. 如果1不滿足,sameVnode(oldEndVnode, newEndVnode) 判斷老節點的尾節點和新節點的尾節點是否是同一類型,如果是,則對它們兩個進行patchVnode(patch過程).兩個節點尾節點分別向前移動一位。
  3. 如果2也不滿足,則sameVnode(oldStartVnode, newEndVnode) 判斷老節點的初節點和新節點的尾節點是否是同一類型,如果是,則對它們兩個進行patchVnode(patch過程).老節點的初節點向後移動一位,新節點尾節點向前移動一位。
  4. 如果3也不滿足,則sameVnode(oldEndVnode, newStartVnode) 判斷老節點的尾節點和新節點的初節點是否是同一類型,如果是,則對它們兩個進行patchVnode(patch過程).老節點的尾節點向前移動一位,新節點初節點向後移動一位。 5.如果以上都不滿足,則創建新的dom節點,newCh的startVnode被添加到oldStartVnode的前面,同時newStartIndex後移一位;

用圖來描述就是 diffdiffdiffdiffdiff

遍歷的過程結束後,newStartIdx > newEndIdx,說明此時oldCh存在多餘的節點,那麼最後就需要將oldCh的多餘節點從parentElm中刪除。 如果oldStartIdx > oldEndIdx,說明此時newCh存在多餘的節點,那麼最後就需要將newCh的多餘節點添加到parentElm中。

diff遍歷的過程如下: (節點屬性中帶key的情況)

前四步還和上面的一樣 第五步:如果前四步都不滿足,則首先建立oldCh key和index索引的對應關係。

  • 如果newStartVnode上存在這個key,但是oldKeyToIdx中不存在 則創建新的dom節點,newCh的startVnode被添加到oldStartVnode的前面,同時newStartIndex後移一位;
  • 如果找到與newStartVnode key一致的oldVnode 則先將這兩個節點進行patchVnode(patch過程),然後將newStartVnode移到oldStartVnode的前面,並在oldCh中刪除與newStartVnode key一致的oldVnode,然後新節點初節點向後移動一位。再進行遍歷。

用圖來描述就是 diffdiffdiffdiff

 

最後,由於newStartIndex>newEndIndex,所以newCh剩餘的節點會被添加到parentElm中

總結

Virtual DOM 算法主要是實現上面三個概念:VNode,diff,patch 總結下來就是

1. 通過構造VNode構建虛擬DOM

2. 通過虛擬DOM構建真正的DOM

3. 生成新的虛擬DOM

4. 比較兩棵虛擬DOM樹的不同.從根節點開始比較,diff過程

5. 在真正的DOM元素上應用變更,patch

其中patch的過程中遇到兩個節點有子節點,則對其子節點進行diff。 而diff的過程又會調用patch。

參考鏈接: 知乎:如何理解虛擬DOM? Vue原理解析之Virtual Dom Vue 2.0 的 virtual-dom 實現簡析

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