React原始碼分析 - Diff演算法

鼓延發表於2018-03-08

在《React原始碼分析 - 元件更新與事務》中的流程圖的最後:

diff update

藍色框框的部分分別是Diff演算法的核心程式碼updateChildren以及processUpdates,通過Diff演算法獲取了元件更新的updates佇列之後一次性進行更新。

Diff演算法的程式碼(先彆著急下面會具體解釋演算法的主要步驟):

    _updateChildren: function (nextNestedChildrenElements, transaction, context) {
      var prevChildren = this._renderedChildren;
      var removedNodes = {};
      var nextChildren = this._reconcilerUpdateChildren(prevChildren, nextNestedChildrenElements, removedNodes, transaction, context);
      if (!nextChildren && !prevChildren) {
        return;
      }
      var updates = null;
      var name;
      var lastIndex = 0;
      var nextIndex = 0;
      var lastPlacedNode = null;
      for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
          continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {
          updates = enqueue(updates, this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex));
          lastIndex = Math.max(prevChild._mountIndex, lastIndex);
          prevChild._mountIndex = nextIndex;
        } else {
          if (prevChild) {
            lastIndex = Math.max(prevChild._mountIndex, lastIndex);
          }
          updates = enqueue(updates, this._mountChildAtIndex(nextChild, lastPlacedNode, nextIndex, transaction, context));
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getNativeNode(nextChild);
      }
      for (name in removedNodes) {
        if (removedNodes.hasOwnProperty(name)) {
          updates = enqueue(updates, this._unmountChild(prevChildren[name], removedNodes[name]));
        }
      }
      if (updates) {
        processQueue(this, updates);
      }
      this._renderedChildren = nextChildren;
    }
複製程式碼

《深入React技術棧》這本書對Diff演算法的解釋比較好。其實只要記住幾個原則以及在具體的計算updates佇列的時候的演算法優化的點就好了。

傳統的diff演算法的複雜度是O(n^3),想要具體的瞭解可以去看"A Survey on Tree Edit Distance and Related Problems"

這種複雜度在實際中應用會爆炸的,雖然現在的電腦的CPU很強,但一個頁面也不能這樣任性~。

對此React的做法是給出合理的假設和方法來讓整個diff過程合理簡化。

  • DOM節點跨層級的移動操作的場景是很少見的,可以忽略不計。(合理,可以通過元件的設計來儘量保證DOM結構的穩定,必要時可以通過CSS的方法來進行DOM在展示上的調整,因為建立、刪除以及移動DOM的操作是能少則少,瀏覽器的每個DOM節點都是一個大物件,有著很多的方法和屬性
  • 同一類的兩個元件將會生成相似的樹形結構,不同類的兩個元件將會生成不同的樹形結構。(合理,本身元件就有提高頁面的可複用性的作用,也就是將結構功能類似的頁面結構(或者說相似的DOM樹形結構)抽象成一類元件,所以合理的元件抽象就應該滿足這條假設)
  • 對於同一層級的一組節點可以通過設定唯一的key來進行區分,從而做到diff的進一步優化。(這個不算是一個假設而是一個提高效能的方法)
  • 對於同一類的兩個元件,有可能其Virtual Dom是沒有任何變化的。因此React允許開發者通過shouldComponentUpdate()來判斷元件是否需要進行diff演算法分析。(合理,開發者本身對頁面的理解來進一步進行diff的優化,當然這有可能會因為開發者錯誤的使用shouldComponentUpdate()判斷錯誤了是否需要更新,從而得到了錯誤的結果.....但是這怪sei ???,寫bug了還不老實)

基於上面的幾條,在具體的Diff過程中React只進行分層比較,新舊的樹之間只比較同一個層次的節點。節點的操作分為3種:插入、移動和刪除。

節點移動操作判斷的過程,引用《深入React技術棧》中的話:

首先對新集合的節點進行迴圈遍歷,for (name in nextChildren),通過唯一 key 可以判斷新老集合中是否存在相同的節點,if (prevChild === nextChild),如果存在相同節點,則進行移動操作,但在移動前需要將當前節點在老集合中的位置與 lastIndex 進行比較,if (child._mountIndex < lastIndex),則進行節點移動操作,否則不執行該操作。這是一種順序優化手段,lastIndex 一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),如果新集合中當前訪問的節點比 lastIndex 大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其他節點的位置,因此不用新增到差異佇列中,即不執行移動操作,只有當訪問的節點比 lastIndex 小時,才需要進行移動操作。

需要注意的是”這是一種順序優化手段,lastIndex 一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),如果新集合中當前訪問的節點比 lastIndex 大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其他節點的位置,因此不用新增到差異佇列中,即不執行移動操作“這句話。意思是如果一個節點在舊集合中的位置已經在你之前進行判斷的最後一個節點的背後,那麼這個節點已經在被diff過的節點的後面了和之前的diff過的節點在順序上就已經是正確的了,不需要移動了,反之的節點需要被移動。

另外需要知道的是如果沒有給key賦值,React會預設使用的是遍歷過程中的 index 值。這裡的index值指的是節點遍歷的順序號,效果等同於有些小夥伴用列表陣列的index來當做key。這樣其實是不好的,因為節點的key和節點的位置有關係和節點本身沒關係,也就是如果我一個列表有10個節點,按照遍歷的順序key為1到10,然後我在列表的最開始增加了一個節點,這個時候按照列表遍歷的順序來設定key,則原來的10個節點的key都變了,而且新舊節點的key錯誤的對上了,要知道key在React中時對一個元件的身份識別的標示,錯誤或者重複的key會造成React錯誤的結果......so.......key需要是一個和節點本身有聯絡的唯一標示。

react的作者之一Paul O’Shannessy有提到:

Key is not really about performance, it’s more about identity (which in turn leads to better performance). Randomly assigned and changing values do not form an identity

你可能會問,上面的diff演算法的原始碼部分沒看到key啊,恩,其實每個component的key會變成nextChildren&prevChildren物件中的name對應的value是component,另外在_reconcilerUpdateChildren中的shouldUpdateReactComponent元件的key也有使用到。

對於新增和刪除節點的操作簡單來說:

  • 新增節點就是建立新的節點放在順序遍歷到的位置上。
  • 刪除節點則是在該層次遍歷結束後,對舊集合進行迴圈遍歷,判斷是否在新集合中沒有,沒有的話,則刪除節點。

當然上面說的移動、新增和刪除節點的操作,不是馬上執行的,而是收集到updates陣列中,然後用processUpdates方法一次性進行具體的DOM的的更新。

  processUpdates: function (parentNode, updates) {
    for (var k = 0; k < updates.length; k++) {
      var update = updates[k];
      switch (update.type) {
        case ReactMultiChildUpdateTypes.INSERT_MARKUP:
          insertLazyTreeChildAt(parentNode, update.content, getNodeAfter(parentNode, update.afterNode));
          break;
        case ReactMultiChildUpdateTypes.MOVE_EXISTING:
          moveChild(parentNode, update.fromNode, getNodeAfter(parentNode, update.afterNode));
          break;
        case ReactMultiChildUpdateTypes.SET_MARKUP:
          setInnerHTML(parentNode, update.content);
          break;
        case ReactMultiChildUpdateTypes.TEXT_CONTENT:
          setTextContent(parentNode, update.content);
          break;
        case ReactMultiChildUpdateTypes.REMOVE_NODE:
          removeChild(parentNode, update.fromNode);
          break;
      }
    }
  }
複製程式碼

其中的節點的具體的操作就是到具體的瀏覽器的DOM的節點的操作了,舉個栗子。

function insertLazyTreeChildAt(parentNode, childTree, referenceNode) {
  DOMLazyTree.insertTreeBefore(parentNode, childTree, referenceNode);
}

var insertTreeBefore = createMicrosoftUnsafeLocalFunction(function (parentNode, tree, referenceNode) {
  if (tree.node.nodeType === 11) {
    insertTreeChildren(tree);
    parentNode.insertBefore(tree.node, referenceNode);
  } else {
    parentNode.insertBefore(tree.node, referenceNode);
    insertTreeChildren(tree);
  }
});
複製程式碼

Node.insertBefore()就是瀏覽器DOM操作的API了。

想要跟著具體的Diff的過程來理解的話,推薦單步除錯或者看《深入React技術棧》中的栗子,這裡我就不畫了.....畫圖很累的.....網上也非常多類似的搜一下就好了。

本文對key的具體的使用的部分有待進一步深入。【TBD】

參考資料:

相關文章