Vue 2.0 的 virtual-dom 實現簡析

Vue版本: 2.3.2vue

virtual-dom(後文簡稱vdom)的概念大規模的推廣仍是得益於react出現,virtual-dom也是react這個框架的很是重要的特性之一。相比於頻繁的手動去操做dom而帶來性能問題,vdom很好的將dom作了一層映射關係,進而將在咱們本須要直接進行dom的一系列操做,映射到了操做vdom,而vdom上定義了關於真實dom的一些關鍵的信息,vdom徹底是用js去實現,和宿主瀏覽器沒有任何聯繫,此外得益於js的執行速度,將本來須要在真實dom進行的建立節點,刪除節點,添加節點等一系列複雜的dom操做所有放到vdom中進行,這樣就經過操做vdom來提升直接操做的dom的效率和性能。node

Vue在2.0版本也引入了vdom。其vdom算法是基於snabbdom算法所作的修改。react

在Vue的整個應用生命週期當中,每次須要更新視圖的時候便會使用vdom。那麼在Vue當中,vdom是如何和Vue這個框架融合在一塊兒工做的呢?以及你們經常提到的vdom的diff算法又是怎樣的呢?接下來就經過這篇文章簡單的向你們介紹下Vue當中的vdom是如何去工做的。git

首先,咱們仍是來看下Vue生命週期當中初始化的最後階段:將vm實例掛載到dom上,源碼在src/core/instance/init.jsgithub

Vue.prototype._init = function () {
        ...
        vm.$mount(vm.$options.el)  
        ...
    }   
複製代碼

其實是調用了src/core/instance/lifecycle.js中的mountComponent方法, mountComponent函數的定義是:算法

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // vm.$el爲真實的node
  vm.$el = el
  // 若是vm上沒有掛載render函數
  if (!vm.$options.render) {
    // 空節點
    vm.$options.render = createEmptyVNode
  }
  // 鉤子函數
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    ...
  } else {
    // updateComponent爲監聽函數, new Watcher(vm, updateComponent, noop)
    updateComponent = () => {
      // Vue.prototype._render 渲染函數
      // vm._render() 返回一個VNode
      // 更新dom
      // vm._render()調用render函數,會返回一個VNode,在生成VNode的過程當中,會動態計算getter,同時推入到dep裏面
      vm._update(vm._render(), hydrating)
    }
  }

  // 新建一個_watcher對象
  // vm實例上掛載的_watcher主要是爲了更新DOM
  // vm/expression/cb
  vm._watcher = new Watcher(vm, updateComponent, noop)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}
複製代碼

注意上面的代碼中定義了一個updateComponent函數,這個函數執行的時候內部會調用vm._update(vm._render(), hyddrating)方法,其中vm._render方法會返回一個新的vnode,(關於vm_render是如何生成vnode的建議你們看看vue的關於compile階段的代碼),而後傳入vm._update方法後,就用這個新的vnode和老的vnode進行diff,最後完成dom的更新工做。那麼updateComponent都是在何時去進行調用呢?express

vm._watcher = new Watcher(vm, updateComponent, noop)
複製代碼

實例化一個watcher,在求值的過程當中this.value = this.lazy ? undefined : this.get(),會調用this.get()方法,所以在實例化的過程中Dep.target會被設爲這個watcher,經過調用vm._render()方法生成新的Vnode並進行diff的過程當中完成了模板當中變量依賴收集工做。即這個watcher被添加到了在模板當中所綁定變量的依賴當中。一旦model中的響應式的數據發生了變化,這些響應式的數據所維護的dep數組便會調用dep.notify()方法完成全部依賴遍歷執行的工做,這裏面就包括了視圖的更新即updateComponent方法,它是在mountComponent中的定義的。數組

updateComponent方法的定義是:瀏覽器

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

完成視圖的更新工做事實上就是調用了vm._update方法,這個方法接收的第一個參數是剛生成的Vnode,調用的vm._update方法(src/core/instance/lifecycle.js)的定義是bash

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const prevActiveInstance = activeInstance
    activeInstance = vm
    // 新的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)
    }
    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
    }
}
複製代碼

在這個方法當中最爲關鍵的就是vm.__patch__方法,這也是整個virtaul-dom當中最爲核心的方法,主要完成了prevVnode和vnode的diff過程並根據須要操做的vdom節點打patch,最後生成新的真實dom節點並完成視圖的更新工做。

接下來就讓咱們看下vm.__patch__裏面到底發生了什麼:

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
            // 對oldVnode和vnode進行diff,並對oldVnode打patch
            patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } 
        }
    }
在對oldVnode和vnode類型判斷中有個sameVnode方法,這個方法決定了是否須要對oldVnode和vnode進行diff及patch的過程。

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)
  )
}
複製代碼

sameVnode會對傳入的2個vnode進行基本屬性的比較,只有當基本屬性相同的狀況下才認爲這個2個vnode只是局部發生了更新,而後纔會對這2個vnode進行diff,若是2個vnode的基本屬性存在不一致的狀況,那麼就會直接跳過diff的過程,進而依據vnode新建一個真實的dom,同時刪除老的dom節點。

vnode基本屬性的定義能夠參見源碼:src/vdom/vnode.js裏面對於vnode的定義。

constructor (
    tag?: string,
    data?: VNodeData,         // 關於這個節點的data值,包括attrs,style,hook等
    children?: ?Array<VNode>, // 子vdom節點
    text?: string,        // 文本內容
    elm?: Node,           // 真實的dom節點
    context?: Component,  // 建立這個vdom的上下文
    componentOptions?: VNodeComponentOptions
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.functionalContext = 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
  }

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

每個vnode都映射到一個真實的dom節點上。其中幾個比較重要的屬性:

  • tag 屬性即這個vnode的標籤屬性
  • data 屬性包含了最後渲染成真實dom節點後,節點上的class,attribute,style以及綁定的事件
  • children 屬性是vnode的子節點
  • text 屬性是文本屬性
  • elm 屬性爲這個vnode對應的真實dom節點
  • key 屬性是vnode的標記,在diff過程當中能夠提升diff的效率,後文有講解 好比,我定義了一個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>
複製代碼

讓咱們再回到patch函數當中,在當oldVnode不存在的時候,這個時候是root節點初始化的過程,所以調用了createElm(vnode, insertedVnodeQueue, parentElm, refElm)方法去建立一個新的節點。而當oldVnode是vnode且sameVnode(oldVnode, vnode)2個節點的基本屬性相同,那麼就進入了2個節點的diff過程。

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

function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    ...
}
複製代碼
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)
    }
複製代碼

這其中的diff過程當中又分了好幾種狀況,oldCh爲oldVnode的子節點,ch爲Vnode的子節點:

1.首先進行文本節點的判斷,若oldVnode.text !== vnode.text,那麼就會直接進行文本節點的替換;

2.在vnode沒有文本節點的狀況下,進入子節點的diff;

3.當oldCh和ch都存在且不相同的狀況下,調用updateChildren對子節點進行diff;

4.若oldCh不存在,ch存在,首先清空oldVnode的文本節點,同時調用addVnodes方法將ch添加到elm真實dom節點當中;

5.若oldCh存在,ch不存在,則刪除elm真實節點下的oldCh子節點;

6.若oldVnode有文本節點,而vnode沒有,那麼就清空這個文本節點。

這裏着重分析下updateChildren(src/core/vdom/patch.js)方法,它也是整個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)
    }
}
複製代碼

在開始遍歷diff前,首先給oldCh和newCh分別分配一個startIndex和endIndex來做爲遍歷的索引,當oldCh或者newCh遍歷完後(遍歷完的條件就是oldCh或者newCh的startIndex >= endIndex),就中止oldCh和newCh的diff過程。接下來經過實例來看下整個diff的過程(節點屬性中不帶key的狀況):

1.首先從第一個節點開始比較,不論是oldCh仍是newCh的起始或者終止節點都不存在sameVnode,同時節點屬性中是不帶key標記的,所以第一輪的diff完後,newCh的startVnode被添加到oldStartVnode的前面,同時newStartIndex前移一位;

2.第二輪的diff中,知足sameVnode(oldStartVnode, newStartVnode),所以對這2個vnode進行diff,最後將patch打到oldStartVnode上,同時oldStartVnode和newStartIndex都向前移動一位

3.第三輪的diff中,知足sameVnode(oldEndVnode, newStartVnode),那麼首先對oldEndVnode和newStartVnode進行diff,並對oldEndVnode進行patch,並完成oldEndVnode移位的操做,最後newStartIndex前移一位,oldStartVnode後移一位

4.第四輪的diff中,過程同步驟3;

5.第五輪的diff中,同過程1;

6.遍歷的過程結束後,newStartIdx > newEndIdx,說明此時oldCh存在多餘的節點,那麼最後就須要將這些多餘的節點刪除

在vnode不帶key的狀況下,每一輪的diff過程中都是起始和結束節點進行比較,直到oldCh或者newCh被遍歷完。而當爲vnode引入key屬性後,在每一輪的diff過程當中,當起始和結束節點都沒有找到sameVnode時,首先對oldCh中進行key值與索引的映射:

if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
複製代碼

createKeyToOldIdx(src/core/vdom/patch.js)方法,用以將oldCh中的key屬性做爲鍵,而對應的節點的索引做爲值。而後再判斷在newStartVnode的屬性中是否有key,且是否在oldKeyToIndx中找到對應的節點。

1.若是不存在這個key,那麼就將這個newStartVnode做爲新的節點建立且插入到原有的root的子節點中:

if (isUndef(idxInOld)) { // New element
    // 建立新的dom節點
    // 插入到oldStartVnode.elm前面
    // 參見createElm方法
    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
         newStartVnode = newCh[++newStartIdx]
    } 
複製代碼

2.若是存在這個key,那麼就取出oldCh中的存在這個key的vnode,而後再進行diff的過程:

elmToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !elmToMove) {
          
          // 將找到的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]
          }
複製代碼

經過以上分析,給vdom上添加key屬性後,遍歷diff的過程當中,當起始點, 結束點的搜尋及diff出現仍是沒法匹配的狀況下時,就會用key來做爲惟一標識,來進行diff,這樣就能夠提升diff效率。

帶有Key屬性的vnode的diff過程可見下圖:

注意在第一輪的diff事後oldCh上的B節點被刪除了,可是newCh上的B節點上elm屬性保持對oldCh上B節點的elm引用。

原文連接: github.com/DDFE/DDFE-b…
相關文章
相關標籤/搜索