面試官: 你對虛擬DOM原理的理解?

本文首發於微信公衆號「程序員面試官」前端

什麼是Virtual DOM

Virtual DOM是對DOM的抽象,本質上是JavaScript對象,這個對象就是更加輕量級的對DOM的描述.vue

2019-07-27-17-02-23

爲何須要Virtual DOM

既然咱們已經有了DOM,爲何還須要額外加一層抽象?node

首先,咱們都知道在前端性能優化的一個祕訣就是儘量少地操做DOM,不只僅是DOM相對較慢,更由於頻繁變更DOM會形成瀏覽器的迴流或者重回,這些都是性能的殺手,所以咱們須要這一層抽象,在patch過程當中儘量地一次性將差別更新到DOM中,這樣保證了DOM不會出現性能不好的狀況.react

其次,現代前端框架的一個基本要求就是無須手動操做DOM,一方面是由於手動操做DOM沒法保證程序性能,多人協做的項目中若是review不嚴格,可能會有開發者寫出性能較低的代碼,另外一方面更重要的是省略手動DOM操做能夠大大提升開發效率.git

最後,也是Virtual DOM最初的目的,就是更好的跨平臺,好比Node.js就沒有DOM,若是想實現SSR(服務端渲染),那麼一個方式就是藉助Virtual DOM,由於Virtual DOM自己是JavaScript對象.程序員

Virtual DOM的關鍵要素

Virtual DOM的建立

咱們已經知道Virtual DOM是對真實DOM的抽象,根據不一樣的需求咱們能夠作出不一樣的抽象,好比snabbdom.js的抽象方式是這樣的.github

2019-07-28-00-19-08

固然,snabbdom.js因爲是面向生產環境的庫,因此作了大量的抽象各類,咱們因爲僅僅做爲教程理解,所以採用最簡單的抽象方法:面試

{
  type, // String,DOM 節點的類型,如 'div'
  data, // Object,包括 props,style等等 DOM 節點的各類屬性
  children // Array,子節點
}
複製代碼

在明確了咱們抽象的Virtual DOM構造以後,咱們就須要一個函數來建立Virtual DOM.算法

/** * 生成 vnode * @param {String} type 類型,如 'div' * @param {String} key key vnode的惟一id * @param {Object} data data,包括屬性,事件等等 * @param {Array} children 子 vnode * @param {String} text 文本 * @param {Element} elm 對應的 dom * @return {Object} vnode */
function vnode(type, key, data, children, text, elm) {
  const element = {
    __type: VNODE_TYPE,
    type, key, data, children, text, elm
  }

  return element
}
複製代碼

這個函數很簡單,接受必定的參數,再根據這些參數返回一個對象,這個對象就是DOM的抽象.api

Virtual DOM Tree的建立

上面咱們已經聲明瞭一個vnode函數用於單個Virtual DOM的建立工做,可是咱們都知道DOM實際上是一個Tree,咱們接下來要作的就是聲明一個函數用於建立DOM Tree的抽象 -- Virtual DOM Tree.

function h(type, config, ...children) {
  const props = {}

  let key = null

  // 獲取 key,填充 props 對象
  if (config != null) {
    if (hasValidKey(config)) {
      key = '' + config.key
    }

    for (let propName in config) {
      if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS[propName]) {
        props[propName] = config[propName]
      }
    }
  }

  return vnode(
    type,
    key,
    props,
    flattenArray(children).map(c => {
      return isPrimitive(c) ? vnode(undefined, undefined, undefined, undefined, c) : c
    })
  )
}
複製代碼

Virtual DOM 的更新

Virtual DOM 歸根究竟是JavaScript對象,咱們得想辦法將Virtual DOM與真實的DOM對應起來,也就是說,須要咱們聲明一個函數,此函數能夠將vnode轉化爲真實DOM.

function createElm(vnode, insertedVnodeQueue) {
  let data = vnode.data
  let i
  // 省略 hook 調用
  let children = vnode.children
  let type = vnode.type

  /// 根據 type 來分別生成 DOM
  // 處理 comment
  if (type === 'comment') {
    if (vnode.text == null) {
      vnode.text = ''
    }
    vnode.elm = api.createComment(vnode.text)
  }
  // 處理其它 type
  else if (type) {
    const elm = vnode.elm = data.ns
      ? api.createElementNS(data.ns, type)
      : api.createElement(type)

    // 調用 create hook
    for (let i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)

    // 分別處理 children 和 text。
    // 這裏隱含一個邏輯:vnode 的 children 和 text 不會/應該同時存在。
    if (isArray(children)) {
      // 遞歸 children,保證 vnode tree 中每一個 vnode 都有本身對應的 dom;
      // 即構建 vnode tree 對應的 dom tree。
      children.forEach(ch => {
        ch && api.appendChild(elm, createElm(ch, insertedVnodeQueue))
      })
    }
    else if (isPrimitive(vnode.text)) {
      api.appendChild(elm, api.createTextNode(vnode.text))
    }
    // 調用 create hook;爲 insert hook 填充 insertedVnodeQueue。
    i = vnode.data.hook
    if (i) {
      i.create && i.create(emptyNode, vnode)
      i.insert && insertedVnodeQueue.push(vnode)
    }
  }
  // 處理 text(text的 type 是空)
  else {
    vnode.elm = api.createTextNode(vnode.text)
  }

  return vnode.elm
}
複製代碼

上述函數其實工做很簡單,就是根據 type 生成對應的 DOM,把 data 裏定義的 各類屬性設置到 DOM 上.

Virtual DOM 的diff

Virtual DOM 的 diff纔是整個Virtual DOM 中最難理解也最核心的部分,diff的目的就是比較新舊Virtual DOM Tree找出差別並更新.

可見diff是直接影響Virtual DOM 性能的關鍵部分.

要比較Virtual DOM Tree的差別,理論上的時間複雜度高達O(n^3),這是一個奇高無比的時間複雜度,很顯然選擇這種低效的算法是沒法知足咱們對程序性能的基本要求的.

好在咱們實際開發中,不多會出現跨層級的DOM變動,一般狀況下的DOM變動是同級的,所以在現代的各類Virtual DOM庫都是隻比較同級差別,在這種狀況下咱們的時間複雜度是O(n).

2019-07-29-15-12-28

那麼咱們接下來須要實現一個函數,進行具體的diff運算,函數updateChildren的核心算法以下:

// 遍歷 oldCh 和 newCh 來比較和更新
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 1⃣️ 首先檢查 4 種狀況,保證 oldStart/oldEnd/newStart/newEnd
      // 這 4 個 vnode 非空,左側的 vnode 爲空就右移下標,右側的 vnode 爲空就左移 下標。
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx]
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx]
      }
      /** * 2⃣️ 而後 oldStartVnode/oldEndVnode/newStartVnode/newEndVnode 兩兩比較, * 對有相同 vnode 的 4 種狀況執行對應的 patch 邏輯。 * - 若是同 start 或同 end 的兩個 vnode 是相同的(狀況 1 和 2), * 說明不用移動實際 dom,直接更新 dom 屬性/children 便可; * - 若是 start 和 end 兩個 vnode 相同(狀況 3 和 4), * 那說明發生了 vnode 的移動,同理咱們也要移動 dom。 */
      // 1. 若是 oldStartVnode 和 newStartVnode 相同(key相同),執行 patch
      else if (isSameVnode(oldStartVnode, newStartVnode)) {
        // 不須要移動 dom
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      }
      // 2. 若是 oldEndVnode 和 newEndVnode 相同,執行 patch
      else if (isSameVnode(oldEndVnode, newEndVnode)) {
        // 不須要移動 dom
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      // 3. 若是 oldStartVnode 和 newEndVnode 相同,執行 patch
      else if (isSameVnode(oldStartVnode, newEndVnode)) {
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        // 把得到更新後的 (oldStartVnode/newEndVnode) 的 dom 右移,移動到
        // oldEndVnode 對應的 dom 的右邊。爲何這麼右移?
        // (1)oldStartVnode 和 newEndVnode 相同,顯然是 vnode 右移了。
        // (2)若 while 循環剛開始,那移到 oldEndVnode.elm 右邊就是最右邊,是合理的;
        // (3)若循環不是剛開始,由於比較過程是兩頭向中間,那麼兩頭的 dom 的位置已是
        // 合理的了,移動到 oldEndVnode.elm 右邊是正確的位置;
        // (4)記住,oldVnode 和 vnode 是相同的才 patch,且 oldVnode 本身對應的 dom
        // 老是已經存在的,vnode 的 dom 是不存在的,直接複用 oldVnode 對應的 dom。
        api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      // 4. 若是 oldEndVnode 和 newStartVnode 相同,執行 patch
      else if (isSameVnode(oldEndVnode, newStartVnode)) {
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        // 這裏是左移更新後的 dom,緣由參考上面的右移。
        api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }

      // 3⃣️ 最後一種狀況:4 個 vnode 都不相同,那麼咱們就要
      // 1. 從 oldCh 數組創建 key --> index 的 map。
      // 2. 只處理 newStartVnode (簡化邏輯,有循環咱們最終仍是會處理到全部 vnode),
      // 以它的 key 從上面的 map 裏拿到 index;
      // 3. 若是 index 存在,那麼說明有對應的 old vnode,patch 就行了;
      // 4. 若是 index 不存在,那麼說明 newStartVnode 是全新的 vnode,直接
      // 建立對應的 dom 並插入。
      else {
        // 若是 oldKeyToIdx 不存在,建立 old children 中 vnode 的 key 到 index 的
        // 映射,方便咱們以後經過 key 去拿下標。
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        }
        // 嘗試經過 newStartVnode 的 key 去拿下標
        idxInOld = oldKeyToIdx[newStartVnode.key]
        // 下標不存在,說明 newStartVnode 是全新的 vnode。
        if (idxInOld == null) {
          // 那麼爲 newStartVnode 建立 dom 並插入到 oldStartVnode.elm 的前面。
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        }
        // 下標存在,說明 old children 中有相同 key 的 vnode,
        else {
          elmToMove = oldCh[idxInOld]
          // 若是 type 不一樣,沒辦法,只能建立新 dom;
          if (elmToMove.type !== newStartVnode.type) {
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm)
          }
          // type 相同(且key相同),那麼說明是相同的 vnode,執行 patch。
          else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
          }
          newStartVnode = newCh[++newStartIdx]
        }
      }
    }

    // 上面的循環結束後(循環條件有兩個),處理可能的未處理到的 vnode。
    // 若是是 new vnodes 裏有未處理的(oldStartIdx > oldEndIdx
    // 說明 old vnodes 先處理完畢)
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    }
    // 相反,若是 old vnodes 有未處理的,刪除 (爲處理 vnodes 對應的) 多餘的 dom。
    else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
複製代碼

咱們能夠假設有舊的Vnode數組和新的Vnode數組這兩個數組,並且有四個變量充當指針分別指到兩個數組的頭尾.

重複下面的對比過程,直到兩個數組中任一數組的頭指針超過尾指針,循環結束 :

  • 頭頭對比: 對比兩個數組的頭部,若是找到,把新節點patch到舊節點,頭指針後移
  • 尾尾對比: 對比兩個數組的尾部,若是找到,把新節點patch到舊節點,尾指針前移
  • 舊尾新頭對比: 交叉對比,舊尾新頭,若是找到,把新節點patch到舊節點,舊尾指針前移,新頭指針後移
  • 舊頭新尾對比: 交叉對比,舊頭新尾,若是找到,把新節點patch到舊節點,新尾指針前移,舊頭指針後移
  • 利用key對比: 用新指針對應節點的key去舊數組尋找對應的節點,這裏分三種狀況,當沒有對應的key,那麼建立新的節點,若是有key而且是相同的節點,把新節點patch到舊節點,若是有key可是不是相同的節點,則建立新節點

咱們假設有新舊兩個數組:

  • 舊數組: [1, 2, 3, 4, 5]
  • 新數組: [1, 4, 6, 1000, 100, 5]

初始化

首先咱們進行頭頭對比,新舊數組的頭部都是1,所以將雙方的頭部指針後移.

頭頭對比

咱們繼續頭頭對比,可是2 !== 4致使對比失敗,我進入尾尾對比,5 === 5,那麼尾部指針則可前移.

尾尾對比

如今進入新的循環,頭頭對比2 !== 4,尾尾對比4 !== 100,此時進入交叉對比,先進行舊尾新頭對比,即4 === 4,舊尾前移且新頭後移.

舊尾新頭對比

接着再進入一個輪新的循環,頭頭對比2 !== 6,尾尾對比3 !== 100,交叉對比2 != 100 3 != 6,四種對比方式所有不符合,若是這個時候須要經過key去對比,而後將新頭指針後移

所有不符合靠key對比

繼續重複上述對比的循環方式直至任一數組的頭指針超過尾指針,循環結束.

2019-07-29-19-06-41

在上述循環結束後,兩個數組中可能存在未遍歷完的狀況: 循環結束後,

  • 先對比舊數組的頭尾指針,若是舊數組遍歷完了(可能新數組沒遍歷完,有漏添加的問題),添加新數組中漏掉的節點

    添加遺漏節點

  • 再對比新數組的頭尾指針,若是新數組遍歷完了(可能舊數組沒遍歷完,有漏刪除的問題),刪除舊數組中漏掉的節點

    刪除冗餘節點

Virtual DOM的優化

上一節咱們的Virtual DOM實現是參考了snabbdom.js的實現,固然Vue.js也一樣參考了snabbdom.js,咱們省略了大量邊緣狀態和svg等相關的代碼,僅僅實現了其核心部分.

snabbdom.js已是社區內主流的Virtual DOM實現了,vue 2.0階段與snabbdom.js同樣都採用了上面講解的「雙端比較算法」,那麼有沒有一些優化方案可使其更快?

其實,社區內有更快的算法,例如inferno.js就號稱最快react-like框架(雖然inferno.js性能強悍的緣由不只僅是算法,可是其diff算法的確是目前最快的),而vue 3.0就會借鑑inferno.js的算法進行優化.

咱們能夠等到Vue 3.0發佈後再一探究竟,具體的優化思想能夠先參考diff 算法原理概述,其中一個核心的思想就是利用LIS(最長遞增子序列)的思想作動態規劃,找到最小的移動次數.

例如如下兩個新舊數組,React的算法會把 a, b, c 移動到他們的相應的位置 + 1共三步操做,而inferno.js則是直接將d移動到最前端這一步操做.

* A: [a b c d]
 * B: [d a b c]
複製代碼

參考文章:


相關文章
相關標籤/搜索