React 源碼剖析系列 - 難以想象的 react diff

目前,前端領域中 React 勢頭正盛,使用者衆多卻少有可以深刻剖析內部實現機制和原理。本系列文章但願經過剖析 React 源碼,理解其內部的實現原理,知其然更要知其因此然。javascript

React diff 做爲 Virtual DOM 的加速器,其算法上的改進優化是 React 整個界面渲染的基礎,以及性能提升的保障,同時也是 React 源碼中最神祕、最難以想象的部分,本文從源碼入手,深刻剖析 React diff 的難以想象之處。html

  • 閱讀本文須要對 React 有必定的瞭解,若是你不知何爲 React,請詳讀 React 官方文檔前端

  • 若是你對 React diff 存在些許疑惑,或者你對算法優化感興趣,那麼本文值得閱讀和討論。java

前言

React 中最值得稱道的部分莫過於 Virtual DOM 與 diff 的完美結合,特別是其高效的 diff 算法,讓用戶能夠無需顧忌性能問題而」任性自由」的刷新頁面,讓開發者也能夠無需關心 Virtual DOM 背後的運做原理,由於 React diff 會幫助咱們計算出 Virtual DOM 中真正變化的部分,並只針對該部分進行實際 DOM 操做,而非從新渲染整個頁面,從而保證了每次操做更新後頁面的高效渲染,所以 Virtual DOM 與 diff 是保證 React 性能口碑的幕後推手。react

行文至此,可能會有讀者質疑:React 無非就是引入 diff 這一律念,且 diff 算法也並不是其獨創,何須吹噓的如此天花亂墜呢?git

其實,正是由於 diff 算法的普識度高,就更應該承認 React 針對 diff 算法優化所作的努力與貢獻,更能體現 React 開發者們的魅力與智慧!github

傳統 diff 算法

計算一棵樹形結構轉換成另外一棵樹形結構的最少操做,是一個複雜且值得研究的問題。傳統 diff 算法經過循環遞歸對節點進行依次對比,效率低下,算法複雜度達到 O(n3),其中 n 是樹中節點的總數。O(n3) 到底有多可怕,這意味着若是要展現1000個節點,就要依次執行上十億次的比較。這種指數型的性能消耗對於前端渲染場景來講代價過高了!現今的 CPU 每秒鐘能執行大約30億條指令,即使是最高效的實現,也不可能在一秒內計算出差別狀況。算法

所以,若是 React 只是單純的引入 diff 算法而沒有任何的優化改進,那麼其效率是遠遠沒法知足前端渲染所要求的性能。性能

經過下面的 demo 能夠清晰的描述傳統 diff 算法的實現過程。優化

let result = [];
// 比較葉子節點
const diffLeafs = function(beforeLeaf, afterLeaf) {
  // 獲取較大節點樹的長度
  let count = Math.max(beforeLeaf.children.length, afterLeaf.children.length);
  // 循環遍歷
  for (let i = 0; i < count; i++) {
    const beforeTag = beforeLeaf.children[i];
    const afterTag = afterLeaf.children[i];
    // 添加 afterTag 節點
    if (beforeTag === undefined) {
      result.push({type: "add", element: afterTag});
    // 刪除 beforeTag 節點
    } else if (afterTag === undefined) {
      result.push({type: "remove", element: beforeTag});
    // 節點名改變時,刪除 beforeTag 節點,添加 afterTag 節點
    } else if (beforeTag.tagName !== afterTag.tagName) {
      result.push({type: "remove", element: beforeTag});
      result.push({type: "add", element: afterTag});
    // 節點不變而內容改變時,改變節點
    } else if (beforeTag.innerHTML !== afterTag.innerHTML) {
      if (beforeTag.children.length === 0) {
        result.push({
          type: "changed",
          beforeElement: beforeTag,
          afterElement: afterTag,
          html: afterTag.innerHTML
      });
      } else {
        // 遞歸比較
        diffLeafs(beforeTag, afterTag);
      }
    }
  }
  return result;
}

所以,若是想要將 diff 思想引入 Virtual DOM,就須要設計一種穩定高效的 diff 算法,而 React 作到了!

那麼,React diff 究竟是如何實現的呢?

詳解 React diff

傳統 diff 算法的複雜度爲 O(n3),顯然這是沒法知足性能要求的。React 經過制定大膽的策略,將 O(n3) 複雜度的問題轉換成 O(n) 複雜度的問題

diff 策略

  1. Web UI 中 DOM 節點跨層級的移動操做特別少,能夠忽略不計。

  2. 擁有相同類的兩個組件將會生成類似的樹形結構,擁有不一樣類的兩個組件將會生成不一樣的樹形結構。

  3. 對於同一層級的一組子節點,它們能夠經過惟一 id 進行區分。

基於以上三個前提策略,React 分別對 tree diff、component diff 以及 element diff 進行算法優化,事實也證實這三個前提策略是合理且準確的,它保證了總體界面構建的性能。

  • tree diff

  • component diff

  • element diff

本文中源碼 ReactMultiChild.js

tree diff

基於策略一,React 對樹的算法進行了簡潔明瞭的優化,即對樹進行分層比較,兩棵樹只會對同一層次的節點進行比較。

既然 DOM 節點跨層級的移動操做少到能夠忽略不計,針對這一現象,React 經過 updateDepth 對 Virtual DOM 樹進行層級控制,只會對相同顏色方框內的 DOM 節點進行比較,即同一個父節點下的全部子節點。當發現節點已經不存在,則該節點及其子節點會被徹底刪除掉,不會用於進一步的比較。這樣只須要對樹進行一次遍歷,便能完成整個 DOM 樹的比較。

react diff

updateChildren: function(nextNestedChildrenElements, transaction, context) {
  updateDepth++;
  var errorThrown = true;
  try {
    this._updateChildren(nextNestedChildrenElements, transaction, context);
    errorThrown = false;
  } finally {
    updateDepth--;
    if (!updateDepth) {
      if (errorThrown) {
        clearQueue();
      } else {
        processQueue();
      }
    }
  }
}

分析至此,大部分人可能都存在這樣的疑問:若是出現了 DOM 節點跨層級的移動操做,React diff 會有怎樣的表現呢?是的,對此我也好奇不已,不如試驗一番。

以下圖,A 節點(包括其子節點)整個被移動到 D 節點下,因爲 React 只會簡單的考慮同層級節點的位置變換,而對於不一樣層級的節點,只有建立和刪除操做。當根節點發現子節點中 A 消失了,就會直接銷燬 A;當 D 發現多了一個子節點 A,則會建立新的 A(包括子節點)做爲其子節點。此時,React diff 的執行狀況:create A -> create B -> create C -> delete A

由此可發現,當出現節點跨層級移動時,並不會出現想象中的移動操做,而是以 A 爲根節點的樹被整個從新建立,這是一種影響 React 性能的操做,所以 React 官方建議不要進行 DOM 節點跨層級的操做

注意:在開發組件時,保持穩定的 DOM 結構會有助於性能的提高。例如,能夠經過 CSS 隱藏或顯示節點,而不是真的移除或添加 DOM 節點。

DOM層級變換

component diff

React 是基於組件構建應用的,對於組件間的比較所採起的策略也是簡潔高效。

  • 若是是同一類型的組件,按照原策略繼續比較 virtual DOM tree。

  • 若是不是,則將該組件判斷爲 dirty component,從而替換整個組件下的全部子節點。

  • 對於同一類型的組件,有可能其 Virtual DOM 沒有任何變化,若是可以確切的知道這點那能夠節省大量的 diff 運算時間,所以 React 容許用戶經過 shouldComponentUpdate() 來判斷該組件是否須要進行 diff。

以下圖,當 component D 改變爲 component G 時,即便這兩個 component 結構類似,一旦 React 判斷 D 和 G 是不一樣類型的組件,就不會比較兩者的結構,而是直接刪除 component D,從新建立 component G 以及其子節點。雖然當兩個 component 是不一樣類型但結構類似時,React diff 會影響性能,但正如 React 官方博客所言:不一樣類型的 component 是不多存在類似 DOM tree 的機會,所以這種極端因素很難在實現開發過程當中形成重大影響的。

component diff

element diff

當節點處於同一層級時,React diff 提供了三種節點操做,分別爲:INSERT_MARKUP(插入)、MOVE_EXISTING(移動)和 REMOVE_NODE(刪除)。

  • INSERT_MARKUP,新的 component 類型不在老集合裏, 便是全新的節點,須要對新節點執行插入操做。

  • MOVE_EXISTING,在老集合有新 component 類型,且 element 是可更新的類型,generateComponentChildren 已調用 receiveComponent,這種狀況下 prevChild=nextChild,就須要作移動操做,能夠複用之前的 DOM 節點。

  • REMOVE_NODE,老 component 類型,在新集合裏也有,但對應的 element 不一樣則不能直接複用和更新,須要執行刪除操做,或者老 component 不在新集合裏的,也須要執行刪除操做。

function enqueueInsertMarkup(parentInst, markup, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.INSERT_MARKUP,
    markupIndex: markupQueue.push(markup) - 1,
    content: null,
    fromIndex: null,
    toIndex: toIndex,
  });
}

function enqueueMove(parentInst, fromIndex, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.MOVE_EXISTING,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: toIndex,
  });
}

function enqueueRemove(parentInst, fromIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.REMOVE_NODE,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: null,
  });
}

以下圖,老集合中包含節點:A、B、C、D,更新後的新集合中包含節點:B、A、D、C,此時新老集合進行 diff 差別化對比,發現 B != A,則建立並插入 B 至新集合,刪除老集合 A;以此類推,建立並插入 A、D 和 C,刪除 B、C 和 D。

no key

React 發現這類操做繁瑣冗餘,由於這些都是相同的節點,但因爲位置發生變化,致使須要進行繁雜低效的刪除、建立操做,其實只要對這些節點進行位置移動便可。

針對這一現象,React 提出優化策略:容許開發者對同一層級的同組子節點,添加惟一 key 進行區分,雖然只是小小的改動,性能上卻發生了翻天覆地的變化!

新老集合所包含的節點,以下圖所示,新老集合進行 diff 差別化對比,經過 key 發現新老集合中的節點都是相同的節點,所以無需進行節點刪除和建立,只須要將老集合中節點的位置進行移動,更新爲新集合中節點的位置,此時 React 給出的 diff 結果爲:B、D 不作任何操做,A、C 進行移動操做,便可。

add key

那麼,如此高效的 diff 究竟是如何運做的呢?讓咱們經過源碼進行詳細分析。

首先對新集合的節點進行循環遍歷,for (name in nextChildren),經過惟一 key 能夠判斷新老集合中是否存在相同的節點,if (prevChild === nextChild),若是存在相同節點,則進行移動操做,但在移動前須要將當前節點在老集合中的位置與 lastIndex 進行比較,if (child._mountIndex < lastIndex),則進行節點移動操做,不然不執行該操做。這是一種順序優化手段,lastIndex 一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),若是新集合中當前訪問的節點比 lastIndex 大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其餘節點的位置,所以不用添加到差別隊列中,即不執行移動操做,只有當訪問的節點比 lastIndex 小時,才須要進行移動操做。

以上圖爲例,能夠更爲清晰直觀的描述 diff 的差別對比過程:

  • 重新集合中取得 B,判斷老集合中存在相同節點 B,經過對比節點位置判斷是否進行移動操做,B 在老集合中的位置 B._mountIndex = 1,此時 lastIndex = 0,不知足 child._mountIndex < lastIndex 的條件,所以不對 B 進行移動操做;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),其中 prevChild._mountIndex 表示 B 在老集合中的位置,則 lastIndex = 1,並將 B 的位置更新爲新集合中的位置 prevChild._mountIndex = nextIndex,此時新集合中 B._mountIndex = 0nextIndex++ 進入下一個節點的判斷。

  • 重新集合中取得 A,判斷老集合中存在相同節點 A,經過對比節點位置判斷是否進行移動操做,A 在老集合中的位置 A._mountIndex = 0,此時 lastIndex = 1,知足 child._mountIndex < lastIndex的條件,所以對 A 進行移動操做enqueueMove(this, child._mountIndex, toIndex),其中 toIndex 其實就是 nextIndex,表示 A 須要移動到的位置;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),則 lastIndex = 1,並將 A 的位置更新爲新集合中的位置 prevChild._mountIndex = nextIndex,此時新集合中 A._mountIndex = 1nextIndex++ 進入下一個節點的判斷。

  • 重新集合中取得 D,判斷老集合中存在相同節點 D,經過對比節點位置判斷是否進行移動操做,D 在老集合中的位置 D._mountIndex = 3,此時 lastIndex = 1,不知足 child._mountIndex < lastIndex的條件,所以不對 D 進行移動操做;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),則 lastIndex = 3,並將 D 的位置更新爲新集合中的位置 prevChild._mountIndex = nextIndex,此時新集合中 D._mountIndex = 2nextIndex++ 進入下一個節點的判斷。

  • 重新集合中取得 C,判斷老集合中存在相同節點 C,經過對比節點位置判斷是否進行移動操做,C 在老集合中的位置 C._mountIndex = 2,此時 lastIndex = 3,知足 child._mountIndex < lastIndex 的條件,所以對 C 進行移動操做 enqueueMove(this, child._mountIndex, toIndex);更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),則 lastIndex = 3,並將 C 的位置更新爲新集合中的位置 prevChild._mountIndex = nextIndex,此時新集合中 A._mountIndex = 3nextIndex++ 進入下一個節點的判斷,因爲 C 已是最後一個節點,所以 diff 到此完成。

以上主要分析新老集合中存在相同節點但位置不一樣時,對節點進行位置移動的狀況,若是新集合中有新加入的節點且老集合存在須要刪除的節點,那麼 React diff 又是如何對比運做的呢?

如下圖爲例:

  • 重新集合中取得 B,判斷老集合中存在相同節點 B,因爲 B 在老集合中的位置 B._mountIndex = 1,此時 lastIndex = 0,所以不對 B 進行移動操做;更新 lastIndex = 1,並將 B 的位置更新爲新集合中的位置 B._mountIndex = 0nextIndex++進入下一個節點的判斷。

  • 重新集合中取得 E,判斷老集合中不存在相同節點 E,則建立新節點 E;更新 lastIndex = 1,並將 E 的位置更新爲新集合中的位置,nextIndex++進入下一個節點的判斷。

  • 重新集合中取得 C,判斷老集合中存在相同節點 C,因爲 C 在老集合中的位置C._mountIndex = 2,此時 lastIndex = 1,所以對 C 進行移動操做;更新 lastIndex = 2,並將 C 的位置更新爲新集合中的位置,nextIndex++ 進入下一個節點的判斷。

  • 重新集合中取得 A,判斷老集合中存在相同節點 A,因爲 A 在老集合中的位置A._mountIndex = 0,此時 lastIndex = 2,所以不對 A 進行移動操做;更新 lastIndex = 2,並將 A 的位置更新爲新集合中的位置,nextIndex++ 進入下一個節點的判斷。

  • 當完成新集合中全部節點 diff 時,最後還須要對老集合進行循環遍歷,判斷是否存在新集合中沒有但老集合中仍存在的節點,發現存在這樣的節點 D,所以刪除節點 D,到此 diff 所有完成。

建立移動刪除節點

_updateChildren: function(nextNestedChildrenElements, transaction, context) {
  var prevChildren = this._renderedChildren;
  var nextChildren = this._reconcilerUpdateChildren(
    prevChildren, nextNestedChildrenElements, transaction, context
  );
  if (!nextChildren && !prevChildren) {
    return;
  }
  var name;
  var lastIndex = 0;
  var nextIndex = 0;
  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }
    var prevChild = prevChildren && prevChildren[name];
    var nextChild = nextChildren[name];
    if (prevChild === nextChild) {
      // 移動節點
      this.moveChild(prevChild, nextIndex, lastIndex);
      lastIndex = Math.max(prevChild._mountIndex, lastIndex);
      prevChild._mountIndex = nextIndex;
    } else {
      if (prevChild) {
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        // 刪除節點
        this._unmountChild(prevChild);
      }
      // 初始化並建立節點
      this._mountChildAtIndex(
        nextChild, nextIndex, transaction, context
      );
    }
    nextIndex++;
  }
  for (name in prevChildren) {
    if (prevChildren.hasOwnProperty(name) &&
        !(nextChildren && nextChildren.hasOwnProperty(name))) {
      this._unmountChild(prevChildren[name]);
    }
  }
  this._renderedChildren = nextChildren;
},
// 移動節點
moveChild: function(child, toIndex, lastIndex) {
  if (child._mountIndex < lastIndex) {
    this.prepareToManageChildren();
    enqueueMove(this, child._mountIndex, toIndex);
  }
},
// 建立節點
createChild: function(child, mountImage) {
  this.prepareToManageChildren();
  enqueueInsertMarkup(this, mountImage, child._mountIndex);
},
// 刪除節點
removeChild: function(child) {
  this.prepareToManageChildren();
  enqueueRemove(this, child._mountIndex);
},

_unmountChild: function(child) {
  this.removeChild(child);
  child._mountIndex = null;
},

_mountChildAtIndex: function(
  child,
  index,
  transaction,
  context) {
  var mountImage = ReactReconciler.mountComponent(
    child,
    transaction,
    this,
    this._nativeContainerInfo,
    context
  );
  child._mountIndex = index;
  this.createChild(child, mountImage);
},

固然,React diff 仍是存在些許不足與待優化的地方,以下圖所示,若新集合的節點更新爲:D、A、B、C,與老集合對比只有 D 節點移動,而 A、B、C 仍然保持原有的順序,理論上 diff 應該只需對 D 執行移動操做,然而因爲 D 在老集合的位置是最大的,致使其餘節點的 _mountIndex < lastIndex,形成 D 沒有執行移動操做,而是 A、B、C 所有移動到 D 節點後面的現象。

在此,讀者們能夠討論思考:如何優化上述問題?

建議:在開發過程當中,儘可能減小相似將最後一個節點移動到列表首部的操做,當節點數量過大或更新操做過於頻繁時,在必定程度上會影響 React 的渲染性能。

key last

總結

  • React 經過制定大膽的 diff 策略,將 O(n3) 複雜度的問題轉換成 O(n) 複雜度的問題;

  • React 經過分層求異的策略,對 tree diff 進行算法優化;

  • React 經過相同類生成類似樹形結構,不一樣類生成不一樣樹形結構的策略,對 component diff 進行算法優化;

  • React 經過設置惟一 key的策略,對 element diff 進行算法優化;

  • 建議,在開發組件時,保持穩定的 DOM 結構會有助於性能的提高;

  • 建議,在開發過程當中,儘可能減小相似將最後一個節點移動到列表首部的操做,當節點數量過大或更新操做過於頻繁時,在必定程度上會影響 React 的渲染性能。

參考資料

若是本文可以爲你解決些許關於 React diff 算法的疑惑,請點個贊吧!

相關文章
相關標籤/搜索