Vue源碼解析:虛擬dom比較原理

經過對 Vue2.0 源碼閱讀,想寫一寫本身的理解,能力有限故從尤大佬2016.4.11第一次提交開始讀,準備陸續寫:javascript

其中包含本身的理解和源碼的分析,儘可能通俗易懂!因爲是2.0的最先提交,因此和最新版本有不少差別、bug,後續將陸續補充,敬請諒解!包含中文註釋的Vue源碼已上傳...vue

開始

先說一下爲何會有虛擬dom比較這一階段,咱們知道了Vue是數據驅動視圖(數據的變化將引發視圖的變化),但你發現某個數據改變時,視圖是局部刷新而不是整個從新渲染,如何精準的找到數據對應的視圖並進行更新呢?那就須要拿到數據改變先後的dom結構,找到差別點並進行更新!java

虛擬dom實質上是針對真實dom提煉出的簡單對象。就像一個簡單的div包含200多個屬性,但真正須要的可能只有tagName,因此對真實dom直接操做將大大影響性能!
圖片描述node

簡化後的虛擬節點(vnode)大體包含如下屬性:git

{
  tag: 'div',       // 標籤名
  data: {},         // 屬性數據,包括class、style、event、props、attrs等
  children: [],     // 子節點數組,也是vnode結構
  text: undefined,  // 文本
  elm: undefined,   // 真實dom
  key: undefined    // 節點標識
}

虛擬dom的比較,就是找出新節點(vnode)和舊節點(oldVnode)之間的差別,而後對差別進行打補丁(patch)。大體流程以下github

clipboard.png

整個過程仍是比較簡單的,新舊節點若是不類似,直接根據新節點建立dom;若是類似,先是對data比較,包括class、style、event、props、attrs等,有不一樣就調用對應的update函數,而後是對子節點的比較,子節點的比較用到了diff算法,這應該是這篇文章的重點和難點吧。算法

值得注意的是,在Children Compare 過程當中,若是找到了類似的childVnode,那它們將遞歸進入新的打補丁過程。segmentfault

源碼解析

此次的源碼解析寫簡潔一點,寫太多發現本身都不肯意看 (┬_┬)api

開始

先來看patch()函數:數組

function patch (oldVnode, vnode) {
  var elm, parent;
  if (sameVnode(oldVnode, vnode)) {
    // 類似就去打補丁(增刪改)
    patchVnode(oldVnode, vnode);
  } else {
    // 不類似就整個覆蓋
    elm = oldVnode.elm;
    parent = api.parentNode(elm);
    createElm(vnode);
    if (parent !== null) {
      api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }
  return vnode.elm;
}

patch()函數接收新舊vnode兩個參數,傳入的這兩個參數有個很大的區別:oldVnode的elm指向真實dom,而vnode的elm爲undefined...但通過patch()方法後,vnode的elm也將指向這個(更新過的)真實dom。

判斷新舊vnode是否類似的sameVnode()方法很簡單,就是比較tagkey是否一致。

function sameVnode (a, b) {
  return a.key === b.key && a.tag === b.tag;
}

打補丁

對於新舊vnode不一致的處理方法很簡單,就是根據vnode建立真實dom,代替oldVnode中的elm插入DOM文檔。

對於新舊vnode一致的處理,就是咱們前面常常說到的打補丁了。具體什麼是打補丁?看看patchVnode()方法就知道了:

function patchVnode (oldVnode, vnode) {
  // 新節點引用舊節點的dom
  let elm = vnode.elm = oldVnode.elm;
  const oldCh = oldVnode.children;
  const ch = vnode.children;

  // 調用update鉤子
  if (vnode.data) {
    updateAttrs(oldVnode, vnode);
    updateClass(oldVnode, vnode);
    updateEventListeners(oldVnode, vnode);
    updateProps(oldVnode, vnode);
    updateStyle(oldVnode, vnode);
  }

  // 判斷是否爲文本節點
  if (vnode.text == undefined) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) api.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)) {
      api.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    api.setTextContent(elm, vnode.text)
  }
}

打補丁其實就是調用各類updateXXX()函數,更新真實dom的各個屬性。每一個的update函數都相似,就拿updateAttrs()舉例看看:

function updateAttrs (oldVnode, vnode) {
  let key, cur, old
  const elm = vnode.elm
  const oldAttrs = oldVnode.data.attrs || {}
  const attrs = vnode.data.attrs || {}

  // 更新/添加屬性
  for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
      if (booleanAttrsDict[key] && cur == null) {
        elm.removeAttribute(key)
      } else {
        elm.setAttribute(key, cur)
      }
    }
  }
  // 刪除新節點不存在的屬性
  for (key in oldAttrs) {
    if (!(key in attrs)) {
      elm.removeAttribute(key)
    }
  }
}

屬性(Attribute)的更新函數的大體思路就是:

  • 遍歷vnode屬性,若是和oldVnode不同就調用setAttribute()修改;
  • 遍歷oldVnode屬性,若是不在vnode屬性中就調用removeAttribute()刪除。

你會發現裏面有個booleanAttrsDict[key]的判斷,是用於判斷在不在布爾類型屬性字典中。

['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', ......]

eg: <video autoplay></video>,想關閉自動播放,須要移除該屬性。

全部數據比較完後,就到子節點的比較了。先判斷當前vnode是否爲文本節點,若是是文本節點就不用考慮子節點的比較;如果元素節點,就須要分三種狀況考慮:

  • 新舊節點都有children,那就進入子節點的比較(diff算法);
  • 新節點有children,舊節點沒有,那就循環建立dom節點;
  • 新節點沒有children,舊節點有,那就循環刪除dom節點。

後面兩種狀況都比較簡單,咱們直接對第一種狀況,子節點的比較進行分析。

diff算法

子節點比較這部分代碼比較多,先說說原理後面再貼代碼。先看一張子節點比較的圖:

clipboard.png

圖中的oldChnewCh分別表示新舊子節點數組,它們都有本身的頭尾指針oldStartIdxoldEndIdxnewStartIdxnewEndIdx,數組裏面存儲的是vnode,爲了容易理解就用a,b,c,d等代替,它們表示不一樣類型標籤(div,span,p)的vnode對象。

子節點的比較實質上就是循環進行頭尾節點比較。循環結束的標誌就是:舊子節點數組或新子節點數組遍歷完,(即 oldStartIdx > oldEndIdx || newStartIdx > newEndIdx)。大概看一下循環流程

  • 第一步 頭頭比較。若類似,舊頭新頭指針後移(即 oldStartIdx++ && newStartIdx++),真實dom不變,進入下一次循環;不類似,進入第二步。
  • 第二步 尾尾比較。若類似,舊尾新尾指針前移(即 oldEndIdx-- && newEndIdx--),真實dom不變,進入下一次循環;不類似,進入第三步。
  • 第三步 頭尾比較。若類似,舊頭指針後移,新尾指針前移(即 oldStartIdx++ && newEndIdx--),未確認dom序列中的頭移到尾,進入下一次循環;不類似,進入第四步。
  • 第四步 尾頭比較。若類似,舊尾指針前移,新頭指針後移(即 oldEndIdx-- && newStartIdx++),未確認dom序列中的尾移到頭,進入下一次循環;不類似,進入第五步。
  • 第五步 若節點有key且在舊子節點數組中找到sameVnode(tag和key都一致),則將其dom移動到當前真實dom序列的頭部,新頭指針後移(即 newStartIdx++);不然,vnode對應的dom(vnode[newStartIdx].elm)插入當前真實dom序列的頭部,新頭指針後移(即 newStartIdx++)。

先看看沒有key的狀況,放個動圖看得更清楚些!
圖片描述
相信看完圖片有更好的理解到diff算法的精髓,整個過程仍是比較簡單的。上圖中一共進入了6次循環,涉及了每一種狀況,逐個敘述一下:

  • 第一次是頭頭類似(都是a),dom不改變,新舊頭指針均後移。a節點確認後,真實dom序列爲:a,b,c,d,e,f,未確認dom序列爲:b,c,d,e,f
  • 第二次是尾尾類似(都是f),dom不改變,新舊尾指針均前移。f節點確認後,真實dom序列爲:a,b,c,d,e,f,未確認dom序列爲:b,c,d,e
  • 第三次是頭尾類似(都是b),當前剩餘真實dom序列中的頭移到尾,舊頭指針後移,新尾指針前移。b節點確認後,真實dom序列爲:a,c,d,e,b,f,未確認dom序列爲:c,d,e
  • 第四次是尾頭類似(都是e),當前剩餘真實dom序列中的尾移到頭,舊尾指針前移,新頭指針後移。e節點確認後,真實dom序列爲:a,e,c,d,b,f,未確認dom序列爲:c,d
  • 第五次是均不類似,直接插入到未確認dom序列頭部。g節點插入後,真實dom序列爲:a,e,g,c,d,b,f,未確認dom序列爲:c,d
  • 第六次是均不類似,直接插入到未確認dom序列頭部。h節點插入後,真實dom序列爲:a,e,g,h,c,d,b,f,未確認dom序列爲:c,d

但結束循環後,有兩種狀況須要考慮:

  • 新的字節點數組(newCh)被遍歷完(newStartIdx > newEndIdx)。那就須要把多餘的舊dom(oldStartIdx -> oldEndIdx)都刪除,上述例子中就是c,d
  • 新的字節點數組(oldCh)被遍歷完(oldStartIdx > oldEndIdx)。那就須要把多餘的新dom(newStartIdx -> newEndIdx)都添加。

上面說了這麼多都是沒有key的狀況,說添加了:key能夠優化v-for的性能,究竟是怎麼回事呢?由於v-for大部分狀況下生成的都是相同tag的標籤,若是沒有key標識,那麼至關於每次頭頭比較都能成功。你想一想若是你往v-for綁定的數組頭部push數據,那麼整個dom將所有刷新一遍(若是數組每項內容都不同),那加了key會有什麼幫助呢?這邊引用一張圖:

clipboard.png

key的狀況,其實就是多了一步匹配查找的過程。也就是上面循環流程中的第五步,會嘗試去舊子節點數組中找到與當前新子節點類似的節點,減小dom的操做!

有興趣的能夠看看代碼:

function updateChildren (parentElm, 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, before

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // 未定義表示被移動過
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) { // 頭頭類似
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) { // 尾尾類似
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // 頭尾類似
      patchVnode(oldStartVnode, newEndVnode)
      api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // 尾頭類似
      patchVnode(oldEndVnode, newStartVnode)
      api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 根據舊子節點的key,生成map映射
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 在舊子節點數組中,找到和newStartVnode類似節點的下標
      idxInOld = oldKeyToIdx[newStartVnode.key]
      if (isUndef(idxInOld)) { 
        // 沒有key,建立並插入dom
        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 有key,找到對應dom ,移動該dom並在oldCh中置爲undefined
        elmToMove = oldCh[idxInOld]
        patchVnode(elmToMove, newStartVnode)
        oldCh[idxInOld] = undefined
        api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  // 循環結束時,刪除/添加多餘dom
  if (oldStartIdx > oldEndIdx) {
    before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

最後

但願看完這篇對虛擬dom的比較會有必定的瞭解!若是有什麼錯誤記得悄悄告訴我啊哈哈。

文筆仍是很差,但願你們能理解o(︶︿︶)o

4篇文章寫了兩個月......真是佩服本身的執行力!但發現寫博客好像確實挺費時的(┬_┬),不過之後必定會常常寫,先兩週一篇?😄

相關文章
相關標籤/搜索