前端框架

-

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 是如何作用於實際的程式碼中,我會將它應用到我長期開發的一個應用程式上,並展示測量的結果。

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 影片甚至寫了一本書,書裡面有一半的內容再深入講解關於重新渲染的問題,以及如何解決,很推薦去看看~

相關系列文章