前端框架

-

React Compiler 是什麼?實際應用與表現全解析

this.web

本文為翻譯文章

來源於 《How React Compiler Performs on Real Code》

作者為 Nadia Makarevich。

本翻譯內容已經過作者同意。

注意:以下文章中的「我」,均指原作者本人。

本翻譯內容已經過作者同意。

過去幾年,React Compiler(先前名為 React Forget)是 React 社群中最令人興奮和期待的工具之一。

React Compiler 核心概念是提升 React 應用程式的整體效能。除了效能以外,最讓人興奮的事 是 React Compiler 讓我們不再需要擔心重新渲染、記憶化(memoization),以及 useMemouseCallback 這些 Hook 的問題。

話說回來,React 的效能問題到底是什麼?又為什麼很多開發者希望擺脫記憶化和這些 Hook呢?這篇文章會一一回答這些問題。

我會先概述這個 React Compiler 試圖解決的問題,以及目前在沒有編譯器的情況下是如何解決的。

最後會展示 React Compiler 是如何作用於實際的程式碼中,我會將它應用到我長期開發的一個應用程式上,並展示測量的結果。

想提升技術但沒有方向?

如果你希望在接下來一年:

➊ 有計劃地變強,停止自我懷疑

➋ 把學到的技術變成履歷亮點

➌ 成為團隊中前 10% 的工程師

我會帶你用 2.5 小時,

打造接下來 12 個月的技術成長攻略

免費報名體驗課

image

React 中重新渲染與記憶化的問題

首先 React 重新渲染和記憶畫的問題究竟是什麼?

大多數的 React 都是用來展示一些互動式的使用者介面(UI)。當使用者與 UI 進行互動時,我們通常希望根據該互動更新頁面,顯示新的資訊。

為了同步 UI 和資料,在 React 中我們會觸發所謂的重新渲染(re-render)

重新渲染(re-render)

在 React 中,重新渲染通常具有層疊性。也就是當一個元件被觸發重新渲染時,它會觸發所有子元件的重新渲染,接著這些子元件又會觸發它們內部的子元件重新渲染,如此類推,直到整個 React 元件樹的底部為止。

層疊性

通常不需要過於擔心重新渲染影響性能的問題,因為 React 的性能已經相當出色。

但是,如果這些子元素的重新渲染影響到一些消耗效能很大的元件,或是那些重新渲染次數過多的元件,就可能引發性能問題,導致應用程式變慢。

消耗效能很大的元件

解決這種效能問題的一種方式,是阻止重新渲染的連鎖反應發生

阻止重新渲染的連鎖反應發生

在 React 裡,我們有多種方法可以達成這個目的,例如:

  • 將狀態下移:將狀態(state)移動到更靠近需要它的元件。
  • 作為屬性傳遞元件:將元件本身當作屬性(props)傳遞。
  • 將狀態提取到類似 Context 的解決方案中,以避免屬性鑽取(props drilling)。
  • 當然,還有記憶化(Memoization)

記憶化是用 React.memo 做到的,這是 React 團隊為我們提供的一個高階元件(Higher-Order Component)。使用它的方法很簡單:只需用 React.memo 將原始元件包裹起來,然後在相應的位置渲染這個記憶化的元件即可。

// 記憶化一個 Slow Component
const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {

  // 使用記憶化的 Slow Component
  return <VerySlowComponentMemo />;
};

現在當 React 處理元件樹中的這個元件時,它會停止並檢查其 props 是否有變化。如果 props 沒有任何改變,那麼重新渲染就會停止。

但是只要有一個 prop 發生了改變,React 的記憶化就會失效,繼續重新渲染子組件!

這代表要讓記憶化(memoization)正確發揮作用,我們需要確保在重新渲染之間,所有的 props 完全保持不變。

對於像字串(string)或布林值(boolean)這樣的原始值(primitive values),這很容易達成,我們只需確保這些值不會被改變即可。

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {
  // trigger re-render somewhere here

  // "data" string between re-renders stays the same
  // so memoization will work as expected
  return <VerySlowComponentMemo data="123" />;
};

然而,對於像物件(object)或陣列(array)這樣的引用型值(reference types)就麻煩多了。

因為即使它們的內容沒有改變,但只要引用不同(例如創建新物件),React 就會認為它們是新的 props,進而觸發重新渲染。

React 會檢查重新渲染時,引用值的變化。如果我們在元件內部宣告這些非原始值,那麼它們會在每次重新渲染時被重新創建,導致它們的引用改變,從而使記憶化(memoization)失效。

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const Parent = () => {
  // trigger re-render somewhere here

  // "data" object is re-created with every re-render
  // memoization is broken here
  return <VerySlowComponentMemo data={{ id: "123" }} />;
};

為了解決這個問題可以使用兩種 Hooks:useMemouseCallback

這兩個 Hook 都能在重新渲染之間保持引用不變。

useMemo 通常用於物件和陣列,而 useCallback 則用於函數。將 props 包裹在這些 Hook 中的做法,通常被稱為「記憶化 props」。

const Parent = () => {
  // reference to { id:"123" } object is now preserved
  const data = useMemo(() => ({ id: "123" }), []);
  // reference to the function is now preserved
  const onClick = useCallback(() => {}, []);

  // props here don't change between re-renders anymore
  // memoization will work correctly
  return (
    <VerySlowComponentMemo
      data={data}
      onClick={onClick}
    />
  );
};

現在,當 React 在渲染樹中遇到 VerySlowComponentMemo 元件時,它會檢查他的 props 是否有變化,如果發現它們都沒有變化,就會跳過該元件的重新渲染。這樣 app 的效能就會提升。

雖然上面已經一個非常簡化的說明,但使用起來還是有點複雜。更麻煩的是,這些記憶化的 props 通過一連串的元件傳遞後,情況會變得更難控制,

我們要追蹤在元件上所有可能改變的值確保引用在過程中沒有丟失。

所以最後很多工程師,要麼是完全不使用這種方式,要麼就是在所有地方都進行記憶化以防萬一。這樣原本清晰的程式碼就會變成一堆難以理解的 useMemouseCallback

一堆難以理解的 useMemo 和 useCallback

React Compiler 正是要解決這個問題。

React Compiler 來拯救大家

React Compiler 是由 React 核心團隊開發的一個 Babel 插件,其 Beta 版本於 2024 年 10 月發布。

在建構(build)階段,它會嘗試轉換一般的 React 程式碼,並自動記憶化元件、它們的 props 和 Hook 的依賴項都。

最後的結果就是,所以 React 程式碼都彷彿被包在 memo、useMemo、useCallback 中。

React 程式碼都彷彿被包在 memo、useMemo、useCallback 中

不過實際上,React Compiler 為了做到這件事情,會進行更複雜的轉換,並盡可能高效地調整程式碼。例如以下程式碼

function Parent() {
  const data = { id: "123" };
  const onClick = () => {};

  return <Component onClick={onClick} data={data} />;
}

會被轉換為:

function Parent() {
  const $ = _c(1);
  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    const data = {
      id: "123",
    };
    const onClick = _temp;
    t0 = <Component onClick={onClick} data={data} />;
    $[0] = t0;
  } else {
    t0 = $[0];
  }
  return t0;
}

function _temp() {}

注意,onClick 被緩存為 _temp 變數,而 data 則被移動到 if 語句內部。你可以在 Compiler Playground. 中進一步嘗試和探索。

React Compiler 的運作機制令人著迷。如果你想了解更多,可以參考 React 核心團隊的一些講座,例如 Deep dive into the Compiler talk.。

筆者在半年前有寫一篇關於 React Compiler 的原理介紹,可以參考這篇貼文:React Compiler 介紹 - 甚麼是 compiller?他有哪些好處? | ThisWeb

然而,對於本文而言,我更關心的是:Compiler 是否符合我們的期望,以及它是否準備好讓像我這樣的大眾開發者使用

當人們聽到「Compiler 將自動記憶化一切」時,幾乎會立即想到以下幾個問題:

  1. 初次加載效能如何?一個主要的反對意見是,預設「記憶化一切」可能會對初次加載效能產生負面影響,因為 React 必須提前做更多處理。
  2. 它真的會提升效能嗎?重新渲染的問題到底有多嚴重?記憶化是否真的能帶來顯著的效能改進?
  3. 它能捕捉到所有重新渲染嗎?JavaScript 以靈活和模糊性著稱。Compiler 是否足夠聰明,能真正捕捉所有的重新渲染?我們真的可以完全不用再擔心記憶化和重新渲染的問題了嗎?

為了回答這些問題,我首先使用 Compiler 處理了一些複合性測試,確保它確實能正常運作,然後將其應用到我正在開發的一個應用程式的幾個頁面上進行測試。

React Compiler 在簡單範例中的應用

第一個範例

第一個簡單的例子:

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponent />
    </div>
  );
};

這段程式碼中有一個對話框元件(Dialog)、控制它的狀態(state)、一個可以開啟對話框的按鈕,以及一個執行速度很慢的元件(VerySlowComponent)。假設這個慢元件的重新渲染需要 500 毫秒。

在普通的 React 行為下,當狀態變化時,所有元件都會重新渲染。由於這個慢元件需要時間重新渲染,所以對話框會延遲顯示。

如果要通過記憶化來解決這個問題,我們需要使用 React.memo 將慢元件包裹起來:

const VerySlowComponentMemo = React.memo(VerySlowComponent);

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponentMemo />
    </div>
  );
};

然而,啟用 React Compiler 後,我發現以下結果:

  • 在 React 開發者工具中顯示按鈕和 VerySlowComponent 已經被 Compiler 記憶化。
  • 我在 VerySlowComponent 中新增的 console.log 不會在狀態改變時觸發。

這說明記憶化確實正常運作,並解決了效能問題。觸發對話框後,它會即時彈出,沒有延遲。

console.log 不會在狀態改變時觸發

第二個範例

在第二個範例中,我為慢元件新增了更多的 props

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      // 👇 add "data" and "onClick" props
      <VerySlowComponent data={{ id: "123" }} onClick={() => {}} />
    </div>
  );
};

如果手動實現記憶化,我需要用到三個工具:React.memouseMemouseCallback

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);
  const data = useMemo(() => ({ id: "123" }), []);
  const onClick = useCallback(() => {}, []);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponentMemo data={data} onClick={onClick} />
    </div>
  );
};

而啟用 Compiler 後,結果與第一個範例相同:所有元件都正確地被記憶化,對話框即時顯示,沒有延遲。

第三個範例

在第三個範例中,我將另一個元件作為子元件傳遞給慢元件:

const SimpleCase = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>toggle</button>
      {isOpen && <Dialog />}
      <VerySlowComponent>
        <Child />
      </VerySlowComponent>
    </div>
  );
};

如果要手動記憶化,大多數人可能會這樣寫:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {
  return (
    <div>
      ...
      <VerySlowComponentMemo>
        <ChildMemo />
      </VerySlowComponentMemo>
    </div>
  );
};

但這其實是錯誤的。這種樹狀語法只是一種 children prop 的語法糖,實際上可以被重寫為:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {
  return (
    <div>
      ...
      <VerySlowComponentMemo children={<ChildMemo />} />
    </div>
  );
};

此處的 <ChildMemo /> 其實是 React.createElement 的結果,一個帶有 type 屬性的物件,而這個物件並沒有被記憶化,因此慢元件依然會在每次狀態改變時重新渲染。

正確的記憶化方式是將子元件作為物件進行記憶化:

const VerySlowComponentMemo = React.memo(VerySlowComponent);
const ChildMemo = React.memo(Child);

const SimpleCase = () => {
  const children = useMemo(() => <ChildMemo />, []);

  return (
    <div>
      ...
      <VerySlowComponentMemo>
        {children}
      </VerySlowComponentMemo>
    </div>
  );
};

啟用 Compiler 後,未記憶化的範例也得到了正確的記憶化處理,效能問題被解決。

小結論

在這三個範例中,Compiler 的表現都非常出色,成功解決了重新渲染的效能問題。這表明它在處理簡單案例時完全能勝任。🏆🏆🏆

然而,這些簡單的例子只是一個起點。為了更深入地測試 Compiler 的能力,我會將其應用到一個我正在開發的真實應用程式中。

React Compiler 在真實應用中的表現

這是一個全新、完全使用 TypeScript 開發的應用,沒有老舊的程式碼,僅使用 Hook,並採用了最新的最佳實踐。

這個應用包含一個登陸頁面、一些內部頁面,程式碼約 15,000 行。雖然不是最大的應用,但足以進行測試。

在啟用 Compiler 之前,我運行了 React 團隊提供的健康檢查和 eslint 規則,結果如下:

  • 成功編譯了 363 個元件中的 361 個。
  • 沒有發現不兼容的庫。

並且也沒有任何違反 eslint 的項目。

我使用 Lighthouse 測量初次加載(initial load)和互動效能(interactive performance),所有測試都基於線上的版本(production),並且在移動裝置上,將 CPU 速度減慢 4 倍,執行了 5 次測試取平均值。

測量應用的「登陸頁面」效能,以下是啟用 Compiler 前的數據:

啟用 Compiler 前的數據

以下是啟用 Compiler 並確認其運行正常後的數據:

啟用 Compiler 並確認其運行正常後的數據

結果顯示,啟用 Compiler 前後數據幾乎完全相同。我測試了其他幾個頁面,結果大致一致,有些數據略有增加,有些則略有減少,但沒有顯著變化。

結論:Compiler 對初次加載的影響非常小甚至沒有,這是一個很好的現象,這代表我們不需要擔心 React Compiler 影響初次加載的性能。

測量第一個頁面

為了測量互動效能(interaction performance),我從「元件」頁面開始測試。

在這個頁面上,我展示了一個 UI 元件庫的 React 元件預覽。這些預覽可能是一個按鈕,也可能是一整個頁面。我測試了「設定」頁面的預覽效能。

該預覽頁面具有「明亮模式」和「暗黑模式」的切換功能。如下圖所示,切換模式時會導致預覽重新渲染,綠色線條表示渲染情況。

切換模式時會導致預覽重新渲染

開啟 Compiler 前後,互動效能對比如下:

開啟 Compiler 前後,互動效能對比

啟用 Compiler 後,總阻塞時間從 280ms 降至 0!

這非常驚艷,但也讓我產生好奇:到底是什麼導致了這麼大的改變?我的程式碼究竟做錯了什麼?

以下是該頁面的程式碼:

export default function Preview() {
  const renderCode = useRenderCode();
  const darkMode = useDarkMode();

  return (
    <div
      className={merge(
        darkMode === "dark" ? "dark bg-buGray900" : "bg-buGray25",
      )}
    >
      <LiveProvider
        code={renderCode.trim()}
        language="tsx"
      >
        <LivePreview />
      </LiveProvider>
    </div>
  );
}

LiveProvider 是渲染整個「設定」元件的地方,它接受一個字符串作為參數渲染。我在這裡的確有一個非常典型的例子:一個非常慢的元件(LiveProvider)和幾個 props。

Compiler 能夠正確識別並處理這種情況,非常棒!但也讓我覺得點像在作弊 😅。更常見的場景是頁面中分布許多中小型元件,所以我測量了另一個更接近這種情況的頁面。

測量第二個頁面

在下一個頁面中,我有一個包含標題、頁尾以及卡片列表的頁面。標題部分有幾個「快速篩選器」,如按鈕、輸入框和複選框。

當我選擇按鈕時,會顯示包含按鈕的卡片列表;啟用複選框後,會顯示包含按鈕和複選框的卡片列表。

在未啟用記憶化的情況下,整個頁面(包括卡片列表)會重新渲染:

整個頁面(包括長卡片列表)會重新渲染

而在啟用 Compiler 前後,添加 checkbox 卡片的效能比較如下:

啟用 Compiler 前後,添加 checkbox 卡片的效能比較

結果:總阻塞時間從 130ms 降到 90ms,仍然很不錯!但如果該頁面上的所有重新渲染都被消除,我預期速度應該更快。將幾個卡片添加到現有列表應該幾乎能瞬間完成的。

問題分析與解決

檢查重新渲染情況後發現,大部分重新渲染已被消除,但頁面中最重的卡片仍在重新渲染。

頁面中最重的卡片仍在重新渲染

以下是該頁面的一段程式碼:

{data?.data?.map((example) => {
    return (
      <GalleryCard
        href={`/examples/code-examples/${example.key}`}
        key={example.key}
        title={example.name}
        preview={example.previewUrl}
      />
    );
  })
}

調試 Compiler 問題的第一步是使用傳統的工具(memo、useMemo、useCallback)重新實現記憶化。

在這種情況下,我需要將卡片包裹在 React.memo 中。如果程式碼正確,現有的卡片應該停止重新渲染,這說明 Compiler 出於某種原因放棄了該元件。

// somewhere before
const GalleryCardMemo = React.memo(GalleryCardMemo);

// somewhere in render function
{data?.data?.map((example) => {
  return (
    <GalleryCardMemo
      href={`/examples/code-examples/${example.key}`}
      key={example.key}
      title={example.name}
      preview={example.previewUrl}
    />
  );
})}

然而,這並未發生,這說明問題出在程式碼本身。React Compiler 沒有出錯。

我們知道,如果記憶化元件的某個 prop 發生變化,記憶化將失效並導致重新渲染。仔細檢查後,所有 props 都是原始值字符串,除了 example.previewUrl,這是一個物件:

{
  light: "/public/light/...",
  dark: "/public/dark/...",
}

該物件在重新渲染之間更改了引用。這是因為它來自於 React Query 的 REST 查詢

const { data } = useQuery({
  queryKey: ["examples", elements.join(",")], // 👈 根據 elements array 動態生成 key
  queryFn: async () => {
    const json = await fetch(`/examples?elements=${elements.join(",")}`);
    const data = await json.json();
    return data;
  },
});

React Query 根據 queryKey 提供的鍵來快取 queryFn 返回的數據。在這個案例中,我透過合併元素陣列來改變鍵值。例如,如果只選擇了按鈕,鍵值會是 button;如果列表中新增了 checkbox,鍵值則會變成 button,checkbox

我的推測是,React Query 將這兩個 key 以及它們對應的資料視為完全不同的陣列。這對我來說是合理的,因為我並未告知它這些陣列是相同的,只需更新即可。

因此,我猜測當鍵值從 button 變為 button,checkbox 時,查詢庫會提取新的資料,並返回一個全新的陣列,其中所有物件的引用也會變成新的。

結果是,記憶化的 GalleryCard 元件接收到了一個非原始值 props 的新引用,導致記憶化失效,即使數據實際上是相同的,元件仍然會重新渲染。

驗證這一點的方法很簡單:只需要將該物件轉換為原始值 props,以消除引用的改變。

{data?.data?.map((example) => {
  return (
    <GalleryCardMemo
      href={`/examples/code-examples/${example.key}`}
      key={example.key}
      title={example.name}
      // pass primitive values instead of the entire object
      previewLight={example.previewUrl.light}
      previewDark={example.previewUrl.dark}
    />
  );
})}

完成後,所有重新渲染完全停止!測量效能的最終結果如下:

測量效能的最終結果

Boom!阻塞時間降到了零,從互動到下一次繪製的時間減少了一半以上。這種情況真是讓人過癮,因為 Compiler 確實提升了一些效能,但我的手動優化效果更好。

我認為這可以回答第二個常見問題:Compiler 能否對互動效能產生影響? 答案是:可以,而且效果顯著,但會因頁面而異。如果我們真的全力進行優化,目前還是比 Compiler 更勝一籌。

React Compiler 可以抓到所有的 re-renders 嗎?

是時候回答最後一個問題了:Compiler 是否足夠聰明,能捕捉到所有的重新渲染?根據我們目前的觀察,答案是否定的。

為了進一步測試這一點,我整理了一份應用中最明顯的重新渲染清單,並檢查在啟用 Compiler 後,還有多少重新渲染仍然存在。

我識別出 9 個明顯的重新渲染案例,比如「切換 tabs 時整個 drawer 元件會重新渲染」等情況。以下是結果總結:

  • 有 2 個案例的重新渲染被完全解決(100% 修復)。
  • 有 2 個案例的重新渲染完全未被修復。
  • 其餘的案例結果介於兩者之間,就像前述的調查情況一樣。

在未修復的案例中,Compiler 因以下程式碼行跳過了該元件的處理

const filteredData = fuse.search(search);

僅僅這一行代碼。我甚至沒有在任何地方使用 filteredData 變數。fuse 是一個外部模糊搜索庫,因此很可能是該庫的行為與 Compiler 不兼容,但這已超出我的控制範圍。

因此,Compiler 是否能捕捉到所有重新渲染的答案非常明確:不可能。總會有一些外部依賴與 Compiler 或記憶化規則不兼容的情況。

或者,可能存在一些奇怪的老舊程式碼,Compiler 無法正確處理。

又或者,就像我的案例一樣,程式碼本身並沒有錯,只是並未針對記憶化進行細緻調整。

快速總結

以下是調查結果和結論的摘要:

結論
  1. 初次加載效能 - 沒有發現負面影響。
  2. 互動效能 - 提升顯著,有些提升很大,有些提升較小。
  3. 能捕捉所有重新渲染嗎 - 不可能,永遠不會。

所以,我們是否能忘記手動記憶化的方式呢(memo, useMemo, useCallback)?

答案是否定的?這取決於情況。

  • 如果你的應用效能並非至關重要,或者目前效能「還算過得去,但可以更好」,那麼啟用 Compiler 可能會讓它稍微好一些,可以說是足夠好了,且成本低廉。當然,「夠好」的定義是由你決定的。對大多數人來說,啟用 Compiler 並忘記記憶化應該已經足夠。
  • 但如果「夠好」對你來說並不夠,你需要從應用中擠出每一毫秒,那麼你還是需要回到手動記憶化的世界。

所以我們不能忘記記憶化,所以我們除了常見的記憶化方式,還要理解 Compiler 的工作原理及其處理方式。這可能代表開發上會更加困難。

不過我認為實際需要了解這一切的人並不會很多。

如果你想更了解 React 的底層原理,這篇文章的原作者寫了很多相關的文章YT 影片甚至寫了一本書,書裡面有一半的內容再深入講解關於重新渲染的問題,以及如何解決,很推薦去看看~

你可能會感興趣的文章 👇