前端框架
-React Compiler 在實際的程式碼中表現如何?(翻譯文章)
本文為翻譯文章,來源於 《How React Compiler Performs on Real Code》。作者為 Nadia Makarevich。
本翻譯內容已經過作者同意。
注意:以下文章中的「我」,均指原作者本人。
過去幾年,React Compiler(先前名為 React Forget)是 React 社群中最令人興奮和期待的工具之一。
React Compiler 核心概念是提升 React 應用程式的整體效能。除了效能以外,最讓人興奮的事 是 React Compiler 讓我們不再需要擔心重新渲染、記憶化(memoization),以及 useMemo
和 useCallback
這些 Hook 的問題。
話說回來,React 的效能問題到底是什麼?又為什麼很多開發者希望擺脫記憶化和這些 Hook呢?這篇文章會一一回答這些問題。
我會先概述這個 React Compiler 試圖解決的問題,以及目前在沒有編譯器的情況下是如何解決的。
最後會展示 React Compiler 是如何作用於實際的程式碼中,我會將它應用到我長期開發的一個應用程式上,並展示測量的結果。
React 中重新渲染與記憶化的問題
首先 React 重新渲染和記憶畫的問題究竟是什麼?
大多數的 React 都是用來展示一些互動式的使用者介面(UI)。當使用者與 UI 進行互動時,我們通常希望根據該互動更新頁面,顯示新的資訊。
為了同步 UI 和資料,在 React 中我們會觸發所謂的重新渲染(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:useMemo 和 useCallback。
這兩個 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 通過一連串的元件傳遞後,情況會變得更難控制,
我們要追蹤在元件上所有可能改變的值確保引用在過程中沒有丟失。
所以最後很多工程師,要麼是完全不使用這種方式,要麼就是在所有地方都進行記憶化以防萬一。這樣原本清晰的程式碼就會變成一堆難以理解的 useMemo 和 useCallback。
React Compiler 正是要解決這個問題。
React Compiler 來拯救大家
React Compiler 是由 React 核心團隊開發的一個 Babel 插件,其 Beta 版本於 2024 年 10 月發布。
在建構(build)階段,它會嘗試轉換一般的 React 程式碼,並自動記憶化元件、它們的 props 和 Hook 的依賴項都。
最後的結果就是,所以 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 將自動記憶化一切」時,幾乎會立即想到以下幾個問題:
- 初次加載效能如何?一個主要的反對意見是,預設「記憶化一切」可能會對初次加載效能產生負面影響,因為 React 必須提前做更多處理。
- 它真的會提升效能嗎?重新渲染的問題到底有多嚴重?記憶化是否真的能帶來顯著的效能改進?
- 它能捕捉到所有重新渲染嗎?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
不會在狀態改變時觸發。
這說明記憶化確實正常運作,並解決了效能問題。觸發對話框後,它會即時彈出,沒有延遲。
第二個範例
在第二個範例中,我為慢元件新增了更多的 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.memo
、useMemo
和 useCallback
。
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 對初次加載的影響非常小甚至沒有,這是一個很好的現象,這代表我們不需要擔心 React Compiler 影響初次加載的性能。
測量第一個頁面
為了測量互動效能(interaction performance),我從「元件」頁面開始測試。
在這個頁面上,我展示了一個 UI 元件庫的 React 元件預覽。這些預覽可能是一個按鈕,也可能是一整個頁面。我測試了「設定」頁面的預覽效能。
該預覽頁面具有「明亮模式」和「暗黑模式」的切換功能。如下圖所示,切換模式時會導致預覽重新渲染,綠色線條表示渲染情況。
開啟 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 卡片的效能比較如下:
結果:總阻塞時間從 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 無法正確處理。
又或者,就像我的案例一樣,程式碼本身並沒有錯,只是並未針對記憶化進行細緻調整。
快速總結
以下是調查結果和結論的摘要:
- 初次加載效能 - 沒有發現負面影響。
- 互動效能 - 提升顯著,有些提升很大,有些提升較小。
- 能捕捉所有重新渲染嗎 - 不可能,永遠不會。
所以,我們是否能忘記手動記憶化的方式呢(memo, useMemo, useCallback)?
答案是否定的?這取決於情況。
- 如果你的應用效能並非至關重要,或者目前效能「還算過得去,但可以更好」,那麼啟用 Compiler 可能會讓它稍微好一些,可以說是足夠好了,且成本低廉。當然,「夠好」的定義是由你決定的。對大多數人來說,啟用 Compiler 並忘記記憶化應該已經足夠。
- 但如果「夠好」對你來說並不夠,你需要從應用中擠出每一毫秒,那麼你還是需要回到手動記憶化的世界。
所以我們不能忘記記憶化,所以我們除了常見的記憶化方式,還要理解 Compiler 的工作原理及其處理方式。這可能代表開發上會更加困難。
不過我認為實際需要了解這一切的人並不會很多。
如果你想更了解 React 的底層原理,這篇文章的原作者寫了很多相關的文章、YT 影片,甚至寫了一本書,書裡面有一半的內容再深入講解關於重新渲染的問題,以及如何解決,很推薦去看看~