Vue.js從Virtual DOM對映到真實DOM的過程

慕晨同學發表於2018-08-29

寫在前面

Virtual DOM的概念相信大家都不會陌生,Vritual DOM是相對與DOM(文件物件模型)來說的,MDN上關於DOM的定義:“DOM模型用一個邏輯樹來表示一個文件,樹的每個分支的終點都是一個節點(node),每個節點都包含著物件(objects)。DOM的方法(methods)讓你可以用特定方式操作這個樹,用這些方法你可以改變文件的結構、樣式或者內容”。相對於頻繁地去操作DOM引起的效能問題,Vritual DOM很好地將DOM做了一層對映關係,將原來需要在DOM上的一系列操作,對映到來操作Virtual DOM。

“昂貴”的DOM

為了有更直觀地感受“昂貴”的DOM,現在將一個簡單的div元素的所有屬性值列印出來:

let div = document.createElement('div')
let str = ''
for (let key in div) {
	str += key + ' '
}
複製程式碼

列印出來的str值為:

Vue.js從Virtual DOM對映到真實DOM的過程

可見,真正的DOM元素是非常龐大的,因為瀏覽器把DOM設計地非常複雜,所以當我們頻繁地去更新DOM時,會產生一定的效能問題。可以想象,用簡單粗暴的方法將整個DOM結構用innerHTML修改到頁面上,這樣進行重繪整個檢視層是相當消耗效能的。那我們更新DOM時,能不能只更新修改的地方呢?

VNode

Vue.js從Virtual DOM對映到真實DOM的過程
我們知道,經歷過render function之後會得到VNode節點,對這張圖不太明白的話可以看下我寫的這兩篇文章Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程Vue.js的響應式系統原理Vritual DOM其實就是以VNode節點(JavaScript物件)作為基礎,用物件屬性來描述節點,實際上它是一層對真實DOM的封裝。Vritual DOM上定義了關於真實DOM的一些關鍵的資訊,Vritual DOM完全是用JS去實現,和宿主瀏覽器沒有任何聯絡,此外得益於js的執行速度,將原本需要在真實DOM進行的建立節點,刪除節點,新增節點等一系列複雜的DOM操作全部放到Vritual DOM中進行。這樣相對與用innerHTML粗暴地重繪整個檢視效能將大大提高。將Virtual DOM修改的地方用diff演算法來更新只修改地方,這樣就能避免很多無謂的DOM修改,從而提高了效能。

來看一下Vue.js原始碼中關於VNode的定義,定義在src/core/vdom/vnode.js中:

 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;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // for SSR caching
  fnScopeId: ?string; // functional scope id support

  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: 當前節點對應的物件,包含了具體的一些資料資訊,是一個VNodeData型別,可以參考VNodeData型別中的資料資訊

children: 當前節點的子節點,是一個陣列

text: 當前節點的文字

elm: 當前虛擬節點對應的真實dom節點

ns: 當前節點的名字空間

context: 當前節點的編譯作用域

functionalContext: 函式化元件作用域

key: 節點的key屬性,被當作節點的標誌,用以優化

componentOptions: 元件的option選項

componentInstance: 當前節點對應的元件的例項

parent: 當前節點的父節點

raw: 簡而言之就是是否為原生HTML或只是普通文字,innerHTML的時候為true,textContent的時候為false

isStatic: 是否為靜態節點

isRootInsert: 是否作為跟節點插入

isComment: 是否為註釋節點

isCloned: 是否為克隆節點

isOnce: 是否有v-once指令

舉個例子,我們現在有這樣一個Vritual DOM:

 {
    tag: 'div'
    data: {
        class: 'outer'
    },
    children: [
        {
            tag: 'div',
            data: {
                class: 'inner'
            }
            text: 'Virtual DOM'
        }
    ]
}
複製程式碼

渲染之後的真實DOM為:

<div class="outer">
    <span class="inner">Virtual DOM</span>
</div>
複製程式碼

建立一個空VNode節點

export const createEmptyVNode = (text: string = '') => {
  const node = new VNode()
  node.text = text
  node.isComment = true
  return node
}
複製程式碼

建立一個文字節點

export function createTextVNode (val: string | number) {
  return new VNode(undefined, undefined, undefined, String(val))
}
複製程式碼

克隆一個VNode節點

export function cloneVNode (vnode: VNode): VNode {
  const cloned = new VNode(
    vnode.tag,
    vnode.data,
    vnode.children,
    vnode.text,
    vnode.elm,
    vnode.context,
    vnode.componentOptions,
    vnode.asyncFactory
  )
  cloned.ns = vnode.ns
  cloned.isStatic = vnode.isStatic
  cloned.key = vnode.key
  cloned.isComment = vnode.isComment
  cloned.fnContext = vnode.fnContext
  cloned.fnOptions = vnode.fnOptions
  cloned.fnScopeId = vnode.fnScopeId
  cloned.asyncMeta = vnode.asyncMeta
  cloned.isCloned = true
  return cloned
}
複製程式碼

總的來說,VNode 就是一個 JavaScript 物件,用 JavaScript 物件的屬性來描述當前節點的一些狀態,用 VNode 節點的形式來模擬一棵 Virtual DOM 樹。

更新檢視

我們知道,Vue.js通過資料繫結來更新檢視,其中會呼叫updateComponent方法,對這一流程不太明白的話可以看一下上邊提到的兩篇文章。updateComponent方法定義如下:

updateComponent = () => {
 vm._update(vm._render(), hydrating)
}
複製程式碼

該方法會呼叫vm._update方法,該方法接受的第一個引數是剛生成的VNode,定義在src/core/instance/lifecycle.js中:

  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const prevActiveInstance = activeInstance
    activeInstance = vm
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    activeInstance = prevActiveInstance
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }
複製程式碼

其中在關鍵的地方加上註釋:

  // 新的vnode
  vm._vnode = vnode
  // Vue.prototype.__patch__ is injected in entry points
  // based on the rendering backend used.
  // 如果需要diff的prevVnode不存在,那麼就用新的vnode建立一個真實dom節點
  if (!prevVnode) {
   // initial render
   // 第一個引數為真實的node節點
   vm.$el = vm.__patch__(
    vm.$el, vnode, hydrating, false /* removeOnly */,
    vm.$options._parentElm,
    vm.$options._refElm
   )
  } else {
   // updates
   // 如果需要diff的prevVnode存在,那麼首先對prevVnode和vnode進行diff,並將需要的更新的dom操作已patch的形式打到prevVnode上,並完成真實dom的更新工作
   vm.$el = vm.__patch__(prevVnode, vnode)
  }
複製程式碼

可以看到,該方法呼叫了一個核心方法__patch__,這可以說是整個Virtual DOM最核心的方法,主要完成了新的虛擬DOM節點和舊的虛擬DOM節點的diff過程,經過patch過程之後生成真實的DOM節點並完成檢視的更新工作。

patch

接下來我們看一下vm.__patch__方法到底發生了什麼,定義在src/core/vdom/patch.js中:

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        if (isRealElement) {
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
              )
            }
          }
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
複製程式碼

通過原始碼我們可以發現,當oldVnode(舊的節點)與vnode(新的節點)在sameVnode的時候才會進行patchVnode,sameVnode這個方法決定是否要對oldvnode和vnode進行diff和patch的過程。也就是新舊VNode節點判定為同一節點的時候才會進行patchVnode這個過程,否則就是建立新的DOM,移除舊的DOM。下面介紹一下sameVnode方法:

sameVnode

sameVnode定義在src/core/vdom/patch.js中:

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

function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
複製程式碼

通過程式碼可以看出,只有當新舊兩個VNode的tag、key、isComment都相同,與此同時定義或未定義data的時候,且如果標籤為input則type必須相同。這時候這新舊兩個VNode則算sameVnode,接著進行進行patchVnode操作。

diff演算法

Vue在2.x版本的vdom演算法是基於snabbdom演算法所做的修改實現的。

Vue.js從Virtual DOM對映到真實DOM的過程

Vue.js從Virtual DOM對映到真實DOM的過程
如圖所示,diff演算法是通過同層的樹節點進行比較而非對樹進行逐層搜尋遍歷的方式,所以時間複雜度只有O(n),是一種非常高效的演算法。接下來看一下diff演算法最重要的環節updateChildren原始碼的實現。

updateChildren

updateChildren原始碼的定義在src/core/vdom/patch.js中:

  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

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(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 {
        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, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          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, false, newCh, newStartIdx)
          }
        }
        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)
    }
  }
複製程式碼

這一塊的原始碼解析可以參考snabbdom原始碼學習

相關文章