一文講透 React Diff 演算法核心

Duang發表於2024-12-02

前言

一直以來我對八股文一直是深惡痛絕的,總覺得這種東西如空中樓閣,對實際解決工程問題沒有任何幫助。並且很多人只從網上搜尋麵試題答案死記硬背,反而可能由於該題目版本老舊而自己又沒有從實際場景中理解,而導致獲得了過時的甚至是錯誤的知識。

但隨著工作的深入,我發現這種觀點是有些偏頗的,因為在實際工程中,如果我們自己都不知道這個技術的執行原理,又何談對它的深入最佳化呢?這在 AI 輔助工作的時代更是如此,AI 無法回答你提不出來的問題。

因此,我想新開闢一個板塊,就寫一些老生常談的"八股文", 但是我會盡量從原始碼層面去理解這些問題,並且儘量做到一通百通,而不是死記硬背。希望這樣的方式能夠幫助大家更好地理解這些問題。

這一次,讓我們重走西遊,踏上取經路的是 React 的 Diff 演算法。(注:以下程式碼實現均基於 React 18.3 版本實現進行)

React Diff 演算法的誕生背景

要講透 React Diff,就一定不能只講 React Diff。我們需要知道 React Diff 演算法為什麼會被設計出來。

在 React 之前,我們在操作 DOM 的時候,通常是直接操作 DOM,比如我們要在一個列表中插入一個新的元素,我們會直接在 DOM 中插入一個新的元素。這樣的操作會導致瀏覽器的重排和重繪,效能開銷很大。為了解決這個問題,React 引入了 Virtual DOM。

Virtual DOM 用於描述 UI 樹的理想狀態,是一個純粹的 JavaScript 物件。這樣一來,React 的更新操作就從 DOM 操作中解放出來,只需要在記憶體中對一個
JavaScript 物件頻繁進行更新即可。當需要更新 UI 時,React 擁有雙快取機制,會透過 Diff 演算法比較新舊 Virtual DOM 的差異,算出需要更新的節點,將需要更新的部分一次性更新到真實的 DOM 中。

這樣一來,React 不僅僅大大減少了瀏覽器的重排和重繪,提高了效能,同時還帶來了一個巨大的好處:邏輯抽象層與檢視層操作完全分離,為 React 的跨平臺開發提供了可能。實際上,包括 React Native 在內的所有跨平臺框架,他們在抽象邏輯層的程式碼,即 Virtual DOM 以及 React Diff 部分(在 React 中稱為 React-Reconciler 庫),都是與平臺無關,完全相同且複用的。

React Diff 演算法執行效能

現在我們知道了 React Diff 演算法本質上就是用於比較新舊 Virtual DOM 的差異,得出需要更新的 DOM 行為。顯而易見這個演算法在整個 React 中的使用頻率相當高,因此 React Diff 演算法的執行效能是非常重要的。

我們知道,DOM 節點本質上是一個樹型結構,因此通常來說,我們可以透過樹的遍歷演算法來比較新舊 Virtual DOM 的差異。但是,即使在最前沿的演算法中,將兩棵樹完全比對的複雜度仍為 O(n^3) (我們可以在 LeetCode 中很容易找到這樣的題型,感興趣可以自己實現一下),這顯然是不可接受的。而 React Diff 演算法將這一行為的時間複雜度降低到了 O(n)。

當然,科學領域裡沒有銀彈,React Diff 能實現這麼優異的效能,也是因為設計團隊根據 React 本身的特點,預設了比對的 3 個限制:

  1. 只對同級元素 diff。若同一個 DOM 節點在更新中變更了層級,則 react 不會複用。
  2. 不同型別的元素變更時,元素不會複用。例如元素從 div 變成 p,react 會銷燬 div 和所有子孫節點,並重新建立。
  3. 開發者可以對元素指定 key 屬性來表示該元素能在更新後保持穩定,幫助演算法最佳化。

正是由於 React 只針對同級同型別元素進行比對,所以 React Diff 將不會存在遞迴與回溯,從而保證瞭如此優異演算法的複雜度。這就是 React Diff 演算法的核心,也是八股文中真正有價值的內容,可惜不是每一個熟背的人都能真正理解。

React Diff 演算法的實現

為了加深理解,我們也可以自己動手根據上述的思路實現 React Diff 演算法。

透過進入 Diff 演算法的 React 節點的子節點個數不同,我們可以將 React Diff 演算法分為兩種情況:單節點 Diff 和多節點 Diff。在實際的使用中,我們可以透過判斷節點的 props.children 是否為陣列來判斷當前節點是單節點還是多節點。

單節點 Diff(reconcileSingleElement)

單節點 Diff 是指新節點只有一個子節點的情況(但舊節點可能有多個 children)。在這種情況下,我們只需要比較新舊節點的 props 和 children 是否相同即可。若舊的 React 節點可以被複用,將舊的 React 節點生成副本並返回。若舊的 React 節點無法被複用,則新生成一個 React 節點並直接返回,進入下一個節點判斷。

那麼我們如何判斷舊節點是否能被複用呢?根據上面的限制我們可以得出,若當前元素的元素型別不同,則直接不復用。若相同,則遍歷該舊節點的所有 children,判斷元素的 key 和 type 與新節點的子元素是否相同。此時有會出現兩種情況:

遍歷時找到了子元素的 key 匹配,但此時元素 type 不同,表示該元素型別變更,依據限制 2,不進行復用。此時由於新節點為單 children,因此沒有節點會與剩下舊子節點匹配了,直接將舊節點下的所有其他子節點標記為刪除,並將新子節點標記為插入。

遍歷時發現 key 不同,則表示該舊子節點不能被複用,將當前節點標記為刪除,之後接著遍歷後續的其它子節點去尋找是否有 key 匹配的子節點。

  function reconcileSingleElement(returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    while (child !== null) {
      if (child.key === key) {
        const elementType = element.type;
        // Key 相同,比較 type
        if (element.$$typeof === REACT_ELEMENT_TYPE) {
          // type 相同 可以複用
          if (child.elementType === elementType) {
            // 當前節點可複用,其他兄弟節點都刪除
            deleteRemainingChildren(returnFiber, child.sibling);

            const existing = useFiber(child, element.props);
            existing.return = returnFiber;

            return existing;
          }
          // key 相同但 type 不同,沒法複用。後面的兄弟節點也沒有複用的可能性了,都刪除
          deleteRemainingChildren(returnFiber, child);
          break;
        } else {
          // type 不同,刪除舊的,並繼續比較
          deleteChild(returnFiber, child);
        }
        child = child.sibling;
      }
    }

    // 建立新節點
    const created = createFiberFromElement(element);
    created.return = returnFiber;
    return created;

多節點 Diff(reconcileChildrenArray)

多節點 Diff 是指新節點有多個子節點的情況。這種情況的處理比較複雜,我們需要將新節點的每個子元素 (即 newChildren 陣列) 與舊節點的所有兄弟節點 (即 old.sibling) 相比較,去尋找是否有複用的可能,此時,每一次對 old.sibling 的比較都能簡化為,old.sibling 與 newChildren 陣列進行 diff,判斷邏輯應當與單節點 Diff 類似。

  1. 如果找到可複用元素,則繼續遍歷其他 newChildren 看是否有可複用。
  2. 如果 key 相同,type 不同,由規則 2,不復用。將舊節點標記刪除,新節點標記新增。並繼續對其他 newChildren 進行遍歷。
  3. 如果 key 不同導致的不可複用,此時說明該節點位置變更,立即跳出遍歷迴圈,在接下來的邏輯中處理。
  4. 如果 newChildren 或者舊 oldfiber.sibling 任意一個遍歷完,此時可能有新增新節點,刪除舊節點和沒有節點變更三種可能,也在接下來的邏輯中處理

在這一輪的比較結束後,我們可以來檢視並判斷一下 newChildren 和 old.sibling 的狀態。

  1. 如果兩者都遍歷完畢,那說明已經完成所有子節點的比對,Diff 環節結束。
  2. 如果 newChildren 遍歷完畢,但 old.sibling 還有剩餘節點,說明這些剩餘節點都是需要刪除的。
  3. 如果 old.sibling 遍歷完畢,但 newChildren 還有剩餘節點,說明這些剩餘節點都是新增節點,需要建立並插入。
  4. 如果兩者都沒有遍歷完畢,說明此時是由上一輪的條件 3 跳出迴圈的,說明此時有節點改變了位置。

這種情況比較複雜,也是 diff 演算法處理的精髓所在。可以將剩下的 old.sibling 儲存為 map,判斷剩餘的 newChildren 的 key 是否在 old 節點中存在。若存在則找變更位置,判斷 oldIndex 與 lastPlacedIndex 的大小,lastPlacedIndex 初始為 0,若 oldIndex >= lastPlacedIndex,則節點不需要移動,將 lastPlacedIndex = oldIndex,否則將當前節點標記為向右移動。

透過這個實現我們也可以看出來,React Diff 在判斷節點是否移動時,是透過從前往後遍歷判斷移動位置的。因此,從效能最佳化的角度考慮,我們要儘量減少將節點從後面移動到前面的操作。

// 若 newChildren 為陣列,則需要遍歷比較來更新當前 Fiber 樹
// 注:該演算法不能透過頭尾兩側遍歷來最佳化,因為 Fiber 樹是單連結串列結構
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: (ReactElement | string)[]
): Fiber | null {
  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  // 舊 Fiber 列表
  let oldFiber = currentFirstChild;
  let nextOldFiber = null;

  // !!! 重要變數。遍歷到的最後一個可複用 fiber 在舊節點中的索引位置
  let lastPlacedIndex = 0;

  // 指向當前新節點的索引位置
  let newIdx = 0;
  // 多節點 Diff 第一次遍歷所有舊的和新的子節點,找到需要更新的節點,設定更新標記
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }

    // 獲取最新的 fiber 節點
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);

    // 如果此時新舊節點都已經遍歷完畢,則直接跳出迴圈
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }

    // 標記該節點的插入狀態,並返回標記順序
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    // 如果是第一個新子節點,設定 resultingFirstChild,否則將其作為上一個新子節點的兄弟節點。
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;

    oldFiber = nextOldFiber;
  }

  // 情況 1:如果新 children 列表已經遍歷完成,但舊 children 列表還有剩餘節點,刪除這些舊的剩餘節點,返回
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  // 情況 2:舊節點已經遍歷完,但還剩餘新節點,說明剩餘的新節點都是新增節點,直接建立並插入
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx]);
      if (newFiber === null) {
        continue;
      }

      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

      // 如果是第一個新子節點,設定 resultingFirstChild,否則將其作為上一個新子節點的兄弟節點。
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  // 情況 3:新舊節點都沒遍歷完,需要進行 Diff 操作
  // 設立一個 map 用於儲存所有舊節點,方便後續查詢
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // 繼續遍歷剩餘的新 fiber 節點,並利用 map,判斷新節點為新增節點還是原有舊節點的移動導致。
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx]
    );

    if (newFiber !== null) {
      // 將新節點插入,並返回標記順序
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

      // 如果是第一個新子節點,設定 resultingFirstChild,否則將其作為上一個新子節點的兄弟節點。
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  return resultingFirstChild;
}

至此我們就已經完全理解並實現了 React Diff 演算法的核心邏輯。

在 React 中,Diff 演算法的實現是在 React-Reconciler 庫中的 ReactChildFiber 模組中。我們可以透過 閱讀原始碼 來了解完整的 React Diff 演算法的實現。

利用 React Diff 特性最佳化效能

在進行上面對於實現邏輯的理解中,我們對 React 的底層執行邏輯也有了更深的瞭解,從而也能更好的觸類旁通理解一些 React 的一些效能最佳化技巧。

例如,我們耳熟能詳的如下這些"React 開發最佳實踐",其實都是基於 React Diff 演算法的特性而演化出來的。

避免使用 index 作為 key

這是由於在 React Diff 中,key 是用來判斷節點是否可以複用的重要依據。如果我們使用 index 作為 key,那麼在節點的插入和刪除時,會導致節點的 key 發生變化,從而導致 React Diff 演算法無法正確判斷節點是否可以複用,從而會觸發不必要的重渲染,導致效能下降。

// good
const items = itemsArray.map((item, index) => (
  <ListItem key={item.id} data={item} />
));

// bad
const items = itemsArray.map((item, index) => (
  <ListItem key={index} data={item} />
));

對複雜元件樹進行結構拆分

在 React Diff 演算法中,只有同級同型別的節點才會進行比對。因此,如果我們的元件樹結構過於複雜,會導致 React Diff 演算法的比對過程變得複雜,從而影響效能。因此,這也是為什麼 React 推崇元件封裝與拆分,將同級同型別的節點提取出來,從而減少 React Diff 演算法的比對複雜度,提高效能。

// 將大型元件拆分為更小的子元件
function LargeComponent({ data }) {
  return (
    <div>
      <Header title={data.title} />
      <Content items={data.items} />
      <Footer info={data.info} />
    </div>
  );
}

避免不必要的重渲染

在 React Diff 演算法中,只有節點的 props 或者 children 發生變化時,才會觸發節點的重渲染。因此,我們應該儘可能地保證,當元件在不需要變化時,避免因為傳入元件的 props 改變而導致不必要的重渲染。

例如我們可以使用 useMemo (React.memo) 或 useCallback 來避免觸發不必要的重渲染。

const MyComponent = React.memo(
  (props) => {
    return <div>{props.value}</div>;
  },
  (prevProps, nextProps) => nextProps.value === prevProps.value
);

除此之外,包括推薦使用不可變的資料結構,避免頻繁地進行 setState 操作,透過 CSS 動畫來代替 JS 動畫等等,都是基於 React Diff 演算法的特性,為了減少不必要的 Diff 開銷而推薦的效能最佳化技巧。

結語

所以你看,從一個 React Diff 是怎麼實現的八股文中,我們可以學習並融會貫通這麼多個效能最佳化技巧。這無疑才是我認為的真正有價值的技術技巧。

很多時候,我們抵制八股文,本質上是在抵制沒有任何基礎理解的,應試的死記硬背。比如幾乎沒有運用場景的 ie 相容性問題,或者是已經被瀏覽器最佳化過的 css 渲染問題。而對那些我們時刻都需要使用到的技術架構,我們更需要仔細研究其核心原理,甚至自己動手實現一遍,這樣才能更好地理解其執行機制,在實際工作中遇到效能問題時,有很大的幫助。

相關文章