紀錄工作經驗、相關知識,解決技術相關問題。

React, 網站相關

深入 React Compiler:忘記 useMemo 與 useCallback 吧!全新自動化渲染優化機制解析

深入 React Compiler:忘記 useMemo 與 useCallback 吧!全新自動化渲染優化機制解析 特色圖片

在過去的 React 開發日常中,為了避免元件非必要的重複渲染(Re-render),我們不得不小心翼翼地在程式碼中塞滿了 useMemo 與 useCallback。然而,這不僅增加了心智負擔,還常常因為依賴陣列(Dependency Array)填寫錯誤而引發難以追蹤的 Bug。

隨著 React 19 與 React Compiler(先前被稱為 React Forget)的正式到來,這一切即將迎來顛覆性的改變。本文將深入探討 React Compiler 的底層運作機制、它如何幫我們自動處理 Memoization,以及在全新時代下,我們該如何調整開發習慣。


痛點回顧:為什麼我們討厭手動優化?

在 React Compiler 出現之前,React 的渲染機制是基於「當 State 改變時,該元件及其所有子元件預設都會重新渲染」。為了避免昂貴的計算或子元件無謂的重繪,我們通常會寫出如下的程式碼:

import React, { useState, useMemo, useCallback } from 'react';
import { ExpensiveList } from './ExpensiveList';

export const ProductDashboard = () => {
  const [filter, setFilter] = useState('');
  const [products, setProducts] = useState([]);

  // 1. 為了避免每次 render 都重新計算篩選,手動加上 useMemo
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.name.includes(filter));
  }, [products, filter]); // 必須小心維護依賴陣列

  // 2. 為了避免子元件因為 function reference 改變而重新 render,手動加上 useCallback
  const handleItemClick = useCallback((id: string) => {
    console.log(`Product clicked: ${id}`);
  }, []); // 空依賴陣列

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ExpensiveList items={filteredProducts} onItemClick={handleItemClick} />
    </div>
  );
};

手動優化的三大硬傷

  1. 高昂的心智負擔:開發者必須時刻思考「這個 Function 需要 wrap 嗎?」、「這個 Array 需要 memo 嗎?」。
  2. 依賴陣列的維護地獄:遺漏依賴會導致閉包陷阱(Stale Closures)拿取到舊值;多寫了依賴則會讓優化形同虛設。
  3. 程式碼可讀性變差:滿堂的 useMemo 與 useCallback 讓原本乾淨的業務邏輯被大量的樣板程式碼(Boilerplate)所淹沒。

React 團隊意識到,效能優化不應該是開發者的責任,而應該是框架與編譯器的責任。這就是 React Compiler 誕生的核心初衷。


React Compiler 核心機制:它是如何運作的?

React Compiler 是一個**編譯期(Compile-time)**工具。它會在你的專案建置(Build)階段,分析 JavaScript/TypeScript 的抽象語法樹(AST),並自動注入類似 Memoization 的快取程式碼。

它不是在 Runtime 去做深比對(Deep Comparison),而是在編譯時注入一種稱為 useMemoCache(內部常表示為 c() 運算)的機制。

編譯前後的直觀對比

讓我們看看以下這段極簡的元件程式碼:

// 編譯前的原始碼:我們完全不寫任何 useMemo
function TodoList({ todos, filter }) {
  const filteredTodos = todos.filter(t => t.text.includes(filter));
  return <List items={filteredTodos} />;
}

經過 React Compiler 編譯後,產出的 JavaScript 程式碼邏輯大致會被轉換成如下的形式(虛擬碼表示其底層邏輯):

// 編譯後的等價邏輯(簡化概念)
function TodoList({ todos, filter }) {
  // 1. 從 React 內部獲取一個快取陣列(由編譯器自動配置大小)
  const $ = child_min_cache(4); 

  // 2. 檢查 todos 與 filter 是否與上一次渲染時相同
  let filteredTodos;
  if ($[0] !== todos || $[1] !== filter) {
    // 若有變更,重新計算,並存入快取
    filteredTodos = todos.filter(t => t.text.includes(filter));
    $[0] = todos;
    $[1] = filter;
    $[2] = filteredTodos;
  } else {
    // 若無變更,直接讀取快取值
    filteredTodos = $[2];
  }

  // 3. 對 JSX 節點本身也進行快取優化
  let t0;
  if ($[3] !== filteredTodos) {
    t0 = <List items={filteredTodos} />;
    $[3] = filteredTodos;
    $[4] = t0;
  } else {
    t0 = $[4];
  }

  return t0;
}

關鍵機制解析

  • 細粒度快取(Fine-grained Memoization):編譯器不僅僅快取了計算結果(filteredTodos),連產出的 React Element(<List />)也一併進行了快取。這意味著如果 filteredTodos 沒有改變,<List /> 的 Reference 就不會變,React 就會自動跳過該子元件的 Re-render。
  • 無感注入:這一切都在建置時完成,開發者在撰寫程式碼時,完全不需要引入任何特殊的語法。

實戰演練:生產環境等級的自動優化範例

讓我們來看一個更接近實際專案的範例。在這個範例中,我們有一個複雜的儀表板元件,包含資料過濾、統計計算,以及傳遞給子元件的事件處理器。

原始乾淨的程式碼(React Compiler 適用)

import React, { useState } from 'react';

interface Order {
  id: string;
  amount: number;
  status: 'pending' | 'completed';
}

interface OrderStatsProps {
  orders: Order[];
  onExport: (data: Order[]) => void;
}

// 子元件:如果 props 沒變,我們希望它不要 re-render
const OrderStats = React.memo(({ orders, onExport }: OrderStatsProps) => {
  console.log('OrderStats 渲染了!');
  const totalAmount = orders.reduce((sum, o) => sum + o.amount, 0);

  return (
    <div className="p-4 border rounded">
      <h3>訂單統計</h3>
      <p>總金額:${totalAmount}</p>
      <button onClick={() => onExport(orders)} className="bg-blue-500 text-white p-2 rounded">
        匯出資料
      </button>
    </div>
  );
});

OrderStats.displayName = 'OrderStats';

export default function OrderDashboard() {
  const [orders, setOrders] = useState<Order[]>([
    { id: '1', amount: 100, status: 'completed' },
    { id: '2', amount: 250, status: 'pending' },
  ]);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  // 1. 複雜的計算邏輯(以往需要 useMemo)
  const completedOrders = orders.filter(o => o.status === 'completed');

  // 2. 事件處理器(以往需要 useCallback)
  const handleExport = (data: Order[]) => {
    console.log('Exporting data:', data);
  };

  return (
    <div className={`p-6 ${theme === 'dark' ? 'bg-gray-800 text-white' : 'bg-white'}`}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')} className="mb-4 border p-2">
        切換主題:{theme}
      </button>

      {/* 
        當我們點擊「切換主題」時,OrderDashboard 的 state (theme) 改變。
        在以往,如果沒有手動 memoize completedOrders 與 handleExport,
        即使 orders 沒變,<OrderStats /> 也會跟著重新渲染。

        但在 React Compiler 啟用下,編譯器會自動為 completedOrders 與 handleExport 進行快取,
        因此切換主題時,<OrderStats /> 完全不會重複渲染!
      */}
      <OrderStats orders={completedOrders} onExport={handleExport} />
    </div>
  );
}

為什麼這很神奇?

在上述程式碼中,我們沒有寫任何一個 useMemo 或 useCallback。當使用者點擊「切換主題」按鈕時,只有 theme 的 State 發生改變。

  • 在傳統 React 中:completedOrders 會產生新的陣列 Reference,handleExport 會產生新的 Function Reference,導致 OrderStats 子元件重新渲染。
  • 在 React Compiler 中:編譯器偵測到 orders 並未改變,因此自動重用了上一次的 completedOrders 與 handleExport 參照,完美避免了 OrderStats 的無效渲染。

現有專案升級與相容性檢測實務指南

如果你想要在現有的專案中啟用 React Compiler,建議遵循以下步驟進行安全的漸進式升級。

Step 1: 使用官方檢測工具

React 官方提供了一個檢測指令,可以掃描你的專案程式碼,評估是否適合引入 React Compiler:

npx react-compiler-healthcheck@latest

該工具會檢查:

  • 專案中的 React 版本是否相容。
  • 是否有使用到不符合 React 規範的寫法(例如在 Render 期間修改全域變數)。
  • 預估可以被安全優化的元件比例。

Step 2: 安裝 ESLint 插件(強烈推薦)

在正式啟用 Compiler 之前,建議先安裝 ESLint 插件。它能主動在開發時提醒你哪些程式碼違反了 React 的規則(Rules of React):

npm install eslint-plugin-react-compiler --save-dev

在你的 .eslintrc.js 或 eslint 設定檔中加入:

{
  "plugins": [
    "eslint-plugin-react-compiler"
  ],
  "rules": {
    "react-compiler/react-compiler": "error"
  }
}

Step 3: 在 Vite 專案中啟用 React Compiler

目前 React Compiler 已經可以透過 Babel 插件整合至主流的建置工具中。以 Vite 為例:

npm install babel-plugin-react-compiler --save-dev

修改你的 vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

const ReactCompilerConfig = {
  // 可以在此處設定相關參數
};

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [
          ["babel-plugin-react-compiler", ReactCompilerConfig],
        ],
      },
    }),
  ],
});

如何排除特定元件?(Opt-out 機制)

如果某個舊元件因為歷史包袱,寫法極度不規範且重構難度高,你可以使用 "use no memo" 指令來告訴 React Compiler 跳過該元件的優化:

function LegacyComponent() {
  "use no memo"; // 告訴編譯器:請不要動這個元件
  
  // 內部可能包含違反 React 規範的髒程式碼
  return <div>Legacy</div>;
}

在自動優化時代,開發者仍需注意的寫作規範

React Compiler 雖然強大,但它並非魔法。它能夠正常運作的前提是:你的程式碼必須遵守 React 的核心規範(Rules of React)。如果程式碼違反了規範,Compiler 為了保證程式邏輯的正確性,會選擇直接放棄優化該元件。

以下是進入 React Compiler 時代後,依然必須恪守的兩大寫作規範:

1. 嚴格遵守「不可變性(Immutability)」

絕對不要在 Render 階段或元件內部直接修改(Mutate)傳入的 Props 或現有的 State。

❌ 錯誤示範(會導致 Compiler 優化失效或行為異常):

function BadComponent({ user }) {
  // 錯誤:直接修改了傳入的 prop 物件屬性
  user.lastActive = new Date(); 

  return <div>{user.name}</div>;
}

👉 正確寫法:

function GoodComponent({ user }) {
  // 保持不可變性,使用拷貝或在 Effect/Event Handler 中處理
  const displayUser = { ...user, lastActive: new Date() };

  return <div>{displayUser.name}</div>;
}

2. 確保 Render 過程是「純粹的(Pure)」

Render 過程不應該產生任何副作用(Side Effects),例如修改外部全域變數、發送 API 請求等。

❌ 錯誤示範:

let renderCount = 0; // 全域變數

function TrackerComponent() {
  renderCount++; // 錯誤:在 render 期間產生了副作用

  return <div>Rendered {renderCount} times</div>;
}

👉 正確寫法:

import { useEffect, useState } from 'react';

function TrackerComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 副作用應該安全地放在 useEffect 中
    setCount(c => c + 1);
  }, []);

  return <div>Rendered {count} times</div>;
}

總結:我們真的可以完全忘記 useMemo 嗎?

是的,在絕大多數的日常開發情境中,你真的可以忘記它們了!

React Compiler 的出現,標誌著 React 生態系邁向「自動化效能優化」的新里程碑。它將開發者從繁瑣的手動優化中解放出來,讓我們能將寶貴的精力專注於打造產品業務邏輯與使用者體驗。

本文重點回顧:

  • 自動化優化:React Compiler 在建置期分析 AST,自動為元件與 JSX 節點注入細粒度的快取機制(useMemoCache)。
  • 漸進式升級:透過 react-compiler-healthcheck 與 ESLint 插件,可以安全地在既有專案中逐步導入。
  • 寫作規範不變:Compiler 極度依賴純函數(Pure Function)與不可變性(Immutability)。寫出符合 React 規範的程式碼,才是享有自動優化的不二法門。

現在就嘗試在你的下一個專案或實驗性分支中啟用 React Compiler,體驗不用寫 useMemo 的純淨程式碼魅力吧!

發表迴響