【算法剖析】Virtual DOM

Virtual DOM 的優點

  1. 能夠緩存dom操做,按批執行,減小dom操做。
  2. 能夠知道DOM的具體更新位置,作到局部刷新。

Virtual dom至關於框架API與運行環境之間的中間層,將框架渲染與DOM API進行解耦,增長了跨平臺能力。(能夠將virtual dom映射爲DOM的步驟,改成映射到其餘的執行環境,好比安卓、IOS)javascript

Virtual DOM 的步驟

  1. 設計js數據結構來表示DOM節點:html

    function VNode(tagName, props, children, key) {
      this.tagName = tagName
      this.props = props
      this.children = children
      this.key = key
    }
  2. 實現一個方法,可以從VNode生成真正的DOM tree(VNode對應根節點):vue

    function render({tag, props, children, key}) {
      // 經過 tag 建立節點
      let el = document.createElement(tag)
      // 設置節點屬性
      for (const key in props) {
        if (props.hasOwnProperty(key)) {
          const value = props[key]
          el.setAttribute(key, value)
        }
      }
      if (key) {
        el.setAttribute('key', key)
      }
      // 遞歸建立子節點
      if (children) {
        children.forEach(element => {
          let child
          if (element instanceof VNode) {
            child = this._createElement(
              element.tag,
              element.props,
              element.children,
              element.key
            )
          } else {
            child = document.createTextNode(element)
          }
          el.appendChild(child)
        })
      }
      return el
    }
  3. 找到一個方法,根據data model生成vDOM tree:
    clipboard.png

    這個映射是由用戶來定義的(通常經過template),因此這個方法通常是經過編譯template來獲得。java

  4. 實現vdom的diff算法,計算兩棵樹的差別。
  5. 實現patch算法,將差別應用到真正的DOM tree上,使view(DOM)與新 vdom(data model)保持一致。

第4步和第5步的2個函數能夠合併。找出新vdom和DOM tree之間的差別,同時更新DOM tree來消除差別。git

Virtual DOM 管理視圖更新的流程

首次渲染:
github

狀態更新:根據model渲染新的vdom tree,與舊的vdom tree對比,計算出須要的更新。
算法

若是選擇合併diff和patch算法,渲染出新vdom之後,將新的vdom tree與真實的DOM tree對比,同時更新DOM tree,使view(DOM)與新 vdom(data model)保持一致。segmentfault

diff算法的實現

diff的算法有不少種實現(見下面的參考資料),目的都是計算出須要的更新步驟,以便應用到真實的DOM上。
各類vdom tree diff算法之間的主要差別在於diff子節點列表的算法(也就是下面的listDiff)。把握各類listDiff算法的關鍵在於,數組

  1. 在diff以前,舊vdom tree和新vdom tree的根節點做爲參數傳入。
  2. 開始對2棵樹同時進行深度優先遍歷。這是特殊的深度優先遍歷,每次同時訪問2個節點用於對比:舊vdom的節點(如下稱爲oldNode)和新vdom的對應節點(如下稱爲newNode)。緩存

    1. 若是newNode.tag === oldNode.tag && newNode.key === oldNode.key(key能夠都爲undefined),將它們視爲同一個元素在不一樣時刻的狀態。要分別diff它們的屬性和子節點。

      1. diffProps(oldNode, newNode),檢測節點上的屬性是否發生了增長、刪除、修改,這些修改應該記錄爲patch(後面討論patch)。
      2. diffChildren(oldNode.children, newNode.children),檢測子節點(數組)是否發生了變化,這些修改應該記錄爲patch。此外,diffChildren將會遞歸調用diffTree,來檢測子樹的變化。

        1. 要檢測子節點數組的變化,即須要一個算法來找出:oldNode.children數組如何經過 增長、刪除、移動 節點,變成newNode.children。咱們把這種算法稱爲listDiff:

          1. 檢測刪除的節點:遍歷oldNode.children,對於每一個child,查找newNode.children中是否有相同key的節點(用map數據結構,查找的時間爲log(n))。若是不存在,說明這個是被刪除的節點,要輸出刪除操做,並記錄它在中間狀態數組中的下標。

            • 中間狀態數組:在listDiff算法開始的時候,中間狀態數組就是oldNode.children。每檢測出一個增長、刪除、移動操做,都要對中間狀態數組進行這個操做,中間狀態數組躍遷到下一個狀態。最後,中間狀態數組躍遷變成了newNode.children。
            • 咱們每次檢測出的操做都是要做用在中間狀態數組上的。所以,在輸出刪除操做的時候,記錄的下標是被刪節點在中間狀態數組中的下標。輸出其餘類型的操做也同理。
            • 基於中間狀態數組輸出操做的目的是:咱們能將這些操做相繼執行,從oldNode.children獲得newNode.children。這也是listDiff算法須要保證的語義。
            • 逆序遍歷的技巧:若是你遍歷oldNode.children的時候是按照下標順序遍歷的,你會發現,直接輸出被刪除節點在oldNode.children中的下標,是不符合上面所說的語義的。可是,若是你遍歷oldNode.children的時候是按照下標逆序遍歷的,直接輸出被刪除節點在oldNode.children中的下標就剛好符合語義。這是由於先刪除數組後面的節點,不會影響數組前面的節點的下標。
          2. 檢測增長和移動的節點:遍歷newNode.children,對於每一個child,查找oldNode.children中是否存在相同key的節點。

            • 若是不存在,說明這個是被增長的節點,要輸出這個節點,以及它被插入後在中間狀態數組中的位置;
            • 若是存在,可是在newNode.children中的下標不等於在中間狀態數組中的下標,說明這個是被移動的節點,要輸出這個節點移動前和移動後的中間狀態數組中的位置。

              • 遍歷newNode.children的時候按照下標順序遍歷。直觀上,中間狀態數組從左往右被掃描和修正,被掃描過的節點一一匹配於newNode.children中的節點,就像一個分開的拉鍊被從左往右緩緩拉上同樣。仔細思考一下,有這樣的結論:節點被插入後在中間狀態數組中的位置==節點在newNode.children中的位置(由於插入完成之後,中間狀態數組的這個位置就不會再修改了),節點移動後在中間狀態數組中的位置==節點在newNode.children中的位置(由於移動完成之後,中間狀態數組的這個位置就不會再修改了)。
        2. listDiff完成之後,獲得一個由增長、刪除、移動組成的操做序列,能將oldNode.children變成newNode.children,要將這些修改記錄爲patch。
        3. 找到2個對應的子節點(一個在oldNode.children中,一個在newNode.children中,兩個節點是同一個元素在不一樣時刻的狀態)來調用diffTree:

          1. 遍歷oldNode.children,對於每個child,找出在newNode.children中有相同key的節點,這兩個節點就是相互對應的節點。以這兩個節點爲參數調用diffTree。

            • 遞歸調用時,只對那些相互對應的2個節點遞歸調用diffTree,若是節點在另外一個vdom沒有對應,則不會被遞歸遍歷到。
            • 咱們只須要對同時存在於舊vdom和新vdom中的節點遞歸調用diffTree。假設一個節點不存在於舊vdom但在新vdom中,那麼【它所在的整個子樹】會在【某個祖先節點增長子節點的時候】被建立,這個新建立的子樹確定是不須要更新的。不在舊vdom中也同理,詳見patch的討論。
    2. 若是newNode.tag !== oldNode.tag || newNode.key !== oldNode.key,說明newNode和oldNode根本不是同一個節點,所以直接替換(刪除oldNode,增長newNode)。在遍歷兩個樹的根節點的時候可能會出現這種狀況,從這之後,若是2個節點不是同一個節點,那根本就不會對它們調用diffTree。

diffTree的基本代碼以下(先不考慮patch):

function diffTree(oldTreeRootNode, newTreeRootNode) {
    diffProps(oldTreeRootNode, newTreeRootNode);
    // 舊樹和新樹中對應的節點結對返回
    const pairs = listDiff(oldTreeRootNode.children, newTreeRootNode.children);
    for (const [oldChild, newChild] of pairs) {
        diffTree(oldChild, newChild);
    }
}
上面的討論沒有考慮沒有key的節點。能夠將【舊list的無key節點】與【新list的無key節點】按出現順序一一對應,視爲同一個節點。

這個算法的實現能夠參考參考資料1。這個實現僅用於理解中間狀態diff算法的思想。

這個算法並非vue所使用的(見參考資料2)。這個算法僅用於理解diff的思想。

patches

patch 含義

patch的意思是「如何修改舊的vdom,將它變爲新的vdom」。它是diff vdom最重要的輸出,畢竟咱們diff vdom的目的就是要知道如何修改DOM。
patch的操做包括:

  1. 增長、刪除、修改某個節點的屬性。
  2. 增長、刪除、移動某個節點的子節點。

    • 在實現patch的操做的時候要注意,「增長某個節點的子節點」的patch操做,意味着增長整個子樹。刪除、移動同理。

可見,任何patch的操做都和某個節點相關,而且這個節點一定在舊vdom和新vdom中都存在的。
反證法:假設某個patch的操做(設爲patchA)做用的節點不存在於舊vdom中,說明這個節點或它的某個祖先節點是新增的節點,也就是說,一定有一個「增長某個節點的子節點」的patch操做(設爲patchB)做用於一個祖先節點。既然patchB意味着增長整個子樹,那麼patchA根本就沒有存在的必要,由於它所在的整個子樹在patchB的時候就被已經正確建立了。

存儲patches

由於每個patch操做都關聯於一個已存在節點,因此咱們存儲patch的方式是:爲每一箇舊vdom中的節點分配一個數組,這個數組包括了全部和這個節點有關的patch操做。所以最終的patches是一個二維數組。在第一個維度上,節點按照深度優先遍歷的順序排列,也就是第1行是根節點的patch操做,第2行是左子節點的patch操做,第3行是【左子節點的左子節點】的patch操做,以此類推。

應用patches

獲得了patches之後,經過將patches中的操做應用於對應的DOM節點,咱們就能夠更新DOM樹,使得DOM樹等價於新的vdom樹。
更新的方法就是,對dom進行深度優先遍歷,當前元素深度優先遍歷的序號是n,那麼patches[n]就是這個元素關聯的patch操做,而後將這些操做依次應用於這個元素。
若是有一個子節點是被增長的,那麼這個子節點下面的子樹就能夠被跳過了,由於這是按照新vdom建立的子樹,不須要更新。

對patch的處理上, 參考資料5實現得比較清晰。

參考資料

  1. 中間狀態 的diff算法: 與Angular的diff算法有一些相似,不過Angular的實現要高效不少(使用鏈表來加速插入刪除),而且考慮得更加完備(多個節點有同一個key)。
  2. 雙端對比 的diff算法: snabbdom使用了這個算法,Vue使用了snabbdom。
  3. 最長遞增子序列(LIS) 的diff算法: inferno使用這個算法。
  4. 最長公共子序列(LCS) 的diff算法: petit-dom實現了這個算法。
  5. Virtual DOM 算法實現框架: 實現了完整的Virtual DOM工做流程,不過list-diff有點太簡單了,以致於會忽略不少節點移動,而用插入/刪除代替。建議用其它的diff算法來代替它的list-diff。
  6. 各類框架的狀態-UI同步機制
相關文章
相關標籤/搜索