💡 核心觀念:參考相等性 (Referential Equality)

理解 React 效能優化的基礎在於 JavaScript 的物件比較:

useMemouseCallback 的核心價值,是讓上述的 False 變成 True,提供「穩定的記憶體位址」,以騙過 React 的依賴檢查,阻止不必要的重新渲染。


目錄:

  1. useEffect:處理副作用 (Side Effects)
  2. useLayoutEffect:同步的 UI 守門員
  3. useMemo:快取「計算結果」
  4. useCallback:快取「函式參考」
  5. 進階防線:React.memo 與 arePropsEqual
  6. useEffectEvent (React 19+):分離響應式邏輯
  7. Next.js (App Router) 執行環境差異

1. useEffect:處理副作用 (Side Effects)

useEffect 是 React 函數元件中最核心的「逃生艙 (Escape Hatch)」。它的存在是為了讓 React 這個純粹的 UI 渲染引擎,能夠與「外部系統」產生互動。

什麼是外部系統?

💡 核心守則: useEffect 的目的不是用來處理使用者的操作事件(如點擊按鈕請用 onClick),也不是用來做資料的轉換(如陣列過濾請直接寫在渲染邏輯或 useMemo 中)。它是專為**「同步狀態」**而生的。

⏳ 執行時機與依賴陣列 (Dependency Array)

useEffect 永遠保證在瀏覽器完成畫面渲染 (Paint) 之後才非同步執行,這確保了複雜的副作用不會阻塞使用者的視覺體驗。

它透過依賴陣列來決定何時該重新執行:


🚨 四大常見陷阱與防禦策略

陷阱 A:陳舊閉包 (Stale Closure) 當 Effect 內部使用了外部的 State 或 Props,卻沒有將其加入依賴陣列時,閉包會「鎖住」第一次渲染時的變數狀態。

陷阱 B:依賴物件引發的無窮迴圈 (Infinite Loop) 如果在 Effect 內部呼叫 setState,且依賴陣列中包含了一個「每次 render 都會重新產生的物件或陣列」。

陷阱 C:競態條件 (Race Conditions) 當依賴改變導致多次發送非同步的 API 請求時,由於網路延遲不穩定,後發出的請求可能比先發出的請求更早回來,導致畫面顯示舊資料。

陷阱 D:濫用 Effect 處理衍生狀態 (Anti-Pattern) 為了根據 items 算出 totalPrice,而在 useEffect 裡面呼叫 setTotalPrice。這會導致元件強制渲染兩次(一次更新 items,一次更新 totalPrice)。

💻 實戰範例:完美防禦的 API 請求與計時器

範例 1:防禦競態條件與記憶體洩漏的 Fetch

import { useState, useEffect } from 'react';

export default function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // 1. 建立 AbortController 準備隨時中斷網路請求
    const controller = new AbortController();
    // 2. 建立本地旗標,防止在元件卸載後還嘗試 setState (會噴 Warning)
    let isMounted = true; 

    async function fetchUser() {
      setIsLoading(true);
      try {
        const res = await fetch(`/api/users/${userId}`, { 
          signal: controller.signal // 綁定中斷訊號
        });
        const data = await res.json();
        
        if (isMounted) setUserData(data);
      } catch (error) {
        // 忽略因為 AbortController 主動取消所引發的錯誤
        if (error.name !== 'AbortError') {
          console.error('Fetch error:', error);
        }
      } finally {
        if (isMounted) setIsLoading(false);
      }
    }

    fetchUser();

    // 🧹 Cleanup Function:
    // 當 userId 改變 (下次 Effect 執行前),或是元件被卸載 (Unmount) 時觸發。
    return () => {
      isMounted = false; // 阻擋未完成的 Promise 執行 setState
      controller.abort(); // 直接中斷尚未回來的 API 網路請求
    };
  }, [userId]); // 只有 userId 改變時才重新 Fetch

  if (isLoading) return <div>載入中...</div>;
  return <div>{userData ? userData.name : '無資料'}</div>;
}

範例 2:破解陳舊閉包的計時器

import { useState, useEffect } from 'react';

export default function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // ❌ 錯誤寫法:setSeconds(seconds + 1); 
      // 因為這裡沒有把 seconds 放進依賴陣列,seconds 永遠是 0,畫面卡在 1。

      // ✅ 正確寫法:使用 Functional Update (傳入 callback)
      // 這樣我們就不需要依賴外部的 seconds 變數,React 內部會自動傳入前一次的正確狀態。
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    // 🧹 Cleanup Function:一定要清除計時器,否則會發生嚴重的記憶體洩漏
    return () => clearInterval(intervalId);
    
  }, []); // 空陣列:計時器只需要在 Mount 時啟動一次

  return <div>已耗時:{seconds} 秒</div>;
}

2. useLayoutEffect:同步的 UI 守門員

useLayoutEffect 的 API 簽名與 useEffect 完全相同,但執行時機有著本質上的差異:它是同步執行的。

🎯 核心守則:只在「測量 DOM 並同步更新 UI」時使用

如果你的 UI 需要根據實體節點的寬高、位置來決定最終樣貌,使用 useEffect 會導致使用者先看到「初始位置」,隨後才看到「正確位置」,產生肉眼可見的閃爍(Flickering)

💻 實戰範例:Pixel Perfect 的 Tooltip 定位(Ref 傳遞模式)

import { useState, useLayoutEffect, useRef } from 'react';

// 子組件:高度封裝的 Tooltip
export default function Tooltip({ targetRef, text, isVisible }) {
  const tooltipRef = useRef(null);
  // 預設位置,等待精準計算後覆寫
  const [position, setPosition] = useState({ x: -9999, y: -9999 }); 

  useLayoutEffect(() => {
    // 💡 關鍵:當 isVisible 變為 true 時,React 已在 DOM 層級拔除 display: none。
    // 此時瀏覽器還沒 Paint,但我們已經可以正確測量出 Tooltip 的真實寬高了!
    if (isVisible && targetRef.current && tooltipRef.current) {
      const targetRect = targetRef.current.getBoundingClientRect();
      const tooltipRect = tooltipRef.current.getBoundingClientRect();

      // 計算精準的水平置中座標
      const finalX = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
      const finalY = targetRect.bottom + 8; // 距離目標下方 8px

      setPosition({ x: finalX, y: finalY });
    }
  }, [targetRef, isVisible]); // 依賴陣列加入 isVisible,確保狀態改變時重新計算

  return (
    <div 
      ref={tooltipRef} 
      style={{ 
        position: 'absolute', 
        left: position.x, 
        top: position.y,
        display: isVisible ? 'block' : 'none', // 透過 prop 直接控制顯示狀態
        
        // 📝 實務防禦附註:
        // 只要 CSS 沒有針對 left / top 設定補間動畫(如 transition: all 0.3s),
        // 配合 useLayoutEffect 的同步阻塞特性,Tooltip 會直接在正確位置「完美顯現」。
        // 如要更細膩的視覺需求需要更繁瑣的調整。
      }}
    >
      {text}
    </div>
  );
}

🚨 陷阱與防禦策略

陷阱:微優化陷阱(Micro-optimization Trap)

試圖將 API 請求或第三方 Script 掛載移至 useLayoutEffect 以追求「提早幾毫秒」發送請求。

⚠️Next.js (App Router) 環境提醒:

即使宣告為 "use client",組件依然會先在伺服器端執行一次 SSR 預渲染。由於伺服器環境缺乏 window/documentuseLayoutEffect 無法執行且可能導致 Hydration 錯誤。在現代 SSR 架構下應謹慎檢查執行環境,或使用 useIsomorphicLayoutEffect 模式進行防禦。


3. useMemo:快取「計算結果」

useMemo 是 React 中最常被誤解,但也最常被用來進行效能優化的 Hook。它的本質是**「以記憶體空間換取 CPU 執行時間」**。它會在元件渲染期間 (During Render) 執行,並將回傳的結果快取起來。只要依賴陣列沒有改變,下次渲染時就會直接拿取快取值,跳過重新計算的過程。

在實務架構上,useMemo 解決了三個不同維度的效能問題:

  1. 降低單次渲染成本 (Reduce Render Cost):避免在每次重繪時,重複執行極度耗時的運算(如:迴圈過濾上萬筆陣列資料、複雜的數學微積分計算)。
  2. 穩定物件參考以阻擋重繪 (Referential Equality):在 JavaScript 中,每次元件重繪都會產生全新的物件與陣列 ({} === {}false)。useMemo 能將物件的記憶體位址「鎖住」,配合 React.memo 阻擋下游子元件發生無意義的渲染。
  3. 穩定 Hook 依賴陣列 (Stable Dependencies):當物件或陣列必須作為 useEffect 的依賴項時,使用 useMemo 可以避免 Effect 因為記憶體位址改變而引發無窮迴圈或重複發送 API 請求。

⚠️與 DOM的關係 :若沒有用 React.memo,React 的 Virtual DOM 還是會重新計算 (Re-render),只是因為React 底層的 Diff 演算法比對後判定最終產出的 JSX 結構並無差異,所以不會更新回實際的DOM而已

🚨 效能陷阱與反模式 (Anti-Patterns)

1. 過度優化 (Premature Optimization) useMemo 本身是有成本的!React 需要分配記憶體來儲存結果,並且每次渲染都要跑一遍依賴陣列的比對邏輯。

2. 在 useMemo 內部執行副作用useMemo 的設計是為了「計算純粹的值」。它會在 React 的渲染階段 (Render Phase) 同步執行。

3. 忽略了 Primitive Values (基本型別) 不需要快取 JavaScript 的字串、數字、布林值是「傳值 (Pass by Value)」而非傳參考。

💻 實戰範例:三大應用場景一次展現(加密貨幣交易儀表板,處理大量的歷史交易數據,傳遞給底層圖表元件)

import { useState, useMemo, useEffect, memo } from 'react';

// 守門員:圖表元件必須用 React.memo 保護
const TransactionChart = memo(({ data, options }) => {
  console.log("📊 圖表重新渲染了!");
  return <div className="chart-container">渲染圖表...</div>;
});

export default function CryptoDashboard({ allTransactions, walletId }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [theme, setTheme] = useState('dark');

  // ⚡ 場景 1:降低渲染成本 (昂貴運算)
  // 如果不用 useMemo,每次打字搜尋 (searchTerm 改變) 或是切換主題 (theme 改變) 時,
  // 這裡都會重新跑一次數萬筆資料的 map 與 filter,導致使用者打字卡頓。
  const processedData = useMemo(() => {
    console.log("執行昂貴計算:過濾與格式化龐大交易數據...");
    return allTransactions
      .filter(tx => tx.symbol.includes(searchTerm.toUpperCase()))
      .map(tx => ({ ...tx, displayValue: `$${tx.amount.toFixed(2)}` }));
  }, [allTransactions, searchTerm]); // 只有交易庫或搜尋關鍵字改變時才重算

  // ⚡ 場景 2:穩定物件參考 (防止子元件重繪)
  // 如果不包 useMemo,每次元件重繪都會產生一個全新的 chartOptions 物件。
  // 這會導致底下的 TransactionChart 判定 props 改變而跟著重繪 (即使內容都是 dark)。
  const chartOptions = useMemo(() => {
    return { 
      theme: theme, 
      animations: true,
      gridColor: theme === 'dark' ? '#333' : '#EEE' 
    };
  }, [theme]); // 只有切換主題時,才產生新的設定檔物件

  // ⚡ 場景 3:穩定 useEffect 依賴 (防止重複 API Call)
  // 將 API 查詢參數包裝起來,確保 config 物件參考穩定。
  const fetchConfig = useMemo(() => {
    return { id: walletId, limit: 100, status: 'completed' };
  }, [walletId]);

  useEffect(() => {
    // 如果 fetchConfig 沒有用 useMemo 保護,這裡會因為每次 render 產生新物件
    // 而瘋狂重複觸發 API 請求,造成無窮迴圈的災難。
    console.log("發送 API 請求獲取錢包狀態...", fetchConfig);
  }, [fetchConfig]);

  return (
    <div>
      <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>
        切換主題:{theme}
      </button>
      <input 
        placeholder="搜尋幣種 (如 SOL, USDC)..." 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      
      {/* 因為 processedData 與 chartOptions 的記憶體位址都被穩定了,
          當我們純粹只是操作其他無關的 state 時,圖表完全不會發生重繪。 */}
      <TransactionChart data={processedData} options={chartOptions} />
    </div>
  );
}

4. useCallback:快取「函式參考」

在 React 中,每當元件重新渲染(即重新執行該 Component 函式)時,受限於 JavaScript 的特性,元件內部定義的所有函式都會被重新分配一塊全新的記憶體空間。 這意味著:oldFunc === newFunc 永遠為 false

💡 核心誤區: useCallback 不會讓函式的執行速度變快。相反地,由於它需要解析依賴陣列並進行比對,它在初次定義與每次比對時都會消耗微小的額外資源。它的真正價值在於穩定下游元件的渲染鏈

🎯 必須使用的兩大場景

  1. 配合 React.memo 優化子元件渲染: 當一個函式作為 Props 傳遞給被 React.memo 保護的子元件時,如果該函式沒有被 useCallback 鎖住位址,子元件會因為「Props 變了(位址不同)」而被迫重新渲染。
  2. 作為其他 Hook 的依賴項: 當你的函式被用於 useEffect 或另一個 useMemo 的依賴陣列中時,必須使用 useCallback 穩定它,否則會引發無限重繪或非預期的副作用執行。

🚨 常見陷阱與開發者準則

陷阱 A:無意義的過度封裝 (The "Just-in-case" Trap) 如果一個函式只是傳遞給原生的 HTML 標籤(如 <button onClick={...}>)或者沒有被 memo 的一般元件,使用 useCallback負優化。因為父元件重繪時,這些元件還是會重繪,只是多浪費了記憶體去快取那個函式。

陷阱 B:依賴地獄與陳舊閉包 如果 useCallback 的依賴陣列漏寫了某個 State,函式內部會永遠抓到該 State 的舊值。但如果依賴過多,函式會頻繁重建,導致 useCallback 失去意義。

💻 實戰範例:高效能清單與依賴穩定化

import { useState, useCallback, memo } from 'react';

// 1. 使用 memo 保護子元件:只有當 id 或 onRemove 的參考改變時才重繪
const ListItem = memo(({ id, task, onRemove }) => {
  console.log(`渲染項目:${id}`);
  return (
    <li>
      {task} <button onClick={() => onRemove(id)}>刪除</button>
    </li>
  );
});

export default function TaskManager() {
  const [tasks, setTasks] = useState([
    { id: 1, text: '完成前端架構筆記' },
    { id: 2, text: '優化 React 渲染效能' }
  ]);
  const [inputValue, setInputValue] = useState('');

  // 2. 正確使用 useCallback:
  // 透過 Functional Update (prevTasks => ...) 
  // 我們不需要把 tasks 放入依賴陣列。
  // 這確保了 handleRemove 的記憶體位址在 TaskManager 整個生命週期中「永遠不變」。
  const handleRemove = useCallback((id) => {
    setTasks(prevTasks => prevTasks.filter(task => task.id !== id));
  }, []); // 依賴陣列為空,位址絕對穩定

  return (
    <div>
      {/* 3. 測試點:在此輸入文字,TaskManager 會重繪 */}
      <input 
        value={inputValue} 
        onChange={(e) => setInputValue(e.target.value)} 
        placeholder="打字測試子元件是否重繪"
      />
      
      <ul>
        {tasks.map(task => (
          <ListItem 
            key={task.id} 
            id={task.id} 
            task={task.text} 
            onRemove={handleRemove} // 因為位址穩定,ListItem 不會因為 inputValue 改變而重繪
          />
        ))}
      </ul>
    </div>
  );
}

5. 進階防線:React.memo 與 arePropsEqual

預設情況下,React.memo 扮演的是「淺層比對 (Shallow Compare)」的守門員角色。它會將 prevPropsnextProps 的屬性一一攤開,透過 Object.is() 檢查記憶體位址是否相同。只要有任何一個屬性的位址改變(例如父元件傳來了新的 inline function 或新產生的物件),防線就會被突破,導致子元件重新渲染。

但在實務開發中,我們經常遇到無法控制父元件的狀況(例如:接收第三方套件的資料、維護 Legacy Code,而父元件的開發者忘記寫 useCallback)。這時,我們可以透過 React.memo 的第二個參數 arePropsEqual,在子元件端建立一道「主動防禦」的機制。

arePropsEqual(prevProps, nextProps) 是一個回傳布林值 (Boolean) 的函式:

核心運作機制

⚠️ :這裡的邏輯與早期 Class Component 的 shouldComponentUpdate完全相反的。shouldComponentUpdate 回傳 true 是要求更新,而 arePropsEqual 回傳 true 是要求不更新濫用可能導致陳舊資料 (Stale Closure) 的 Bug

🚨 雙面刃:使用 arePropsEqual 的代價與陷阱

雖然 arePropsEqual 能提供精準的渲染控制,但它在程式碼的可維護性上會帶來不小的挑戰,這也是為什麼它被稱為「最後防線」而非首選方案。

  1. 陳舊閉包 (Stale Closure) 的致命傷: 在範例中,我們為了效能「故意忽略」了 onPointClick 的比對。如果父元件的 handleChartClick 內部讀取了某個變動的 State (例如 count),因為子元件拒絕更新,當使用者點擊圖表時,執行的永遠是第一次渲染時被快取下來的舊函式。這會導致印出的 count 永遠是舊資料,產生極難追蹤的 Bug。
  2. 屬性擴充的維護成本: 當未來其他開發者為 <ComplexChart /> 新增了一個 prop(例如 theme="dark"),如果他忘記去更新 arePropsEqual 的比對邏輯,那麼在切換主題時,圖表將完全沒有反應。這種隱含的邏輯依賴會大幅增加團隊協作的成本。
  3. 效能倒退的風險: 如果 arePropsEqual 內部寫了非常深層的物件比對(Deep Equal,例如 JSON.stringify 或遞迴檢查),這個比對過程所耗費的 CPU 運算時間,可能會比讓 React 直接執行渲染 (Re-render) 還要久,完全得不償失。

💻 範例程式碼:自定義比對邏輯

import { useState, memo } from 'react';

// 1. 建立具有防禦性比對邏輯的子元件
const ComplexChart = memo(
  ({ data, onPointClick }) => {
    console.log("🟡 ComplexChart 執行了極度耗時的渲染");
    return (
      <div onClick={() => onPointClick(data.id)}>
        渲染圖表資料:{data.value}
      </div>
    );
  },
  // 2. 自訂 arePropsEqual 比對邏輯
  (prevProps, nextProps) => {
    // 採取防禦性策略:明確指定「只有當 data 的 id 或 value 改變時」,才允許重繪。
    // 我們故意忽略 onPointClick 的比對,無論父元件怎麼傳新的函式,我們都不管。
    const isDataEqual = 
      prevProps.data.id === nextProps.data.id &&
      prevProps.data.value === nextProps.data.value;

    return isDataEqual; // 如果資料一樣 (true),就跳過渲染
  }
);

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

  // ❌ 模擬寫得不好的父元件:每次 render 都產生新的記憶體位址
  const chartData = { id: 'A01', value: 100 }; 
  const handleChartClick = (id) => console.log(`點擊了 ${id}`);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc' }}>
      <button onClick={() => setCount(c => c + 1)}>
        無關的父元件狀態更新: {count}
      </button>
      
      {/* 雖然每次傳入的 chartData 和 handleChartClick 參考位址都不同,
          但 ComplexChart 內部的 arePropsEqual 成功擋下了無意義的重繪。 */}
      <ComplexChart data={chartData} onPointClick={handleChartClick} />
    </div>
  );
}

6. useEffectEvent (React 19+):分離響應式邏輯

在 React 18 以前,我們在撰寫 useEffect 時,經常會陷入一個被稱為**「依賴陣列地獄 (Dependency Hell)」**的死局:

「我需要在 Effect 裡面讀取最新的 State,但我絕對不希望這個 State 改變時,Effect 被重新執行!」


碎碎念:

文章目錄:📚Parker Chen 的前端技術碎碎念