理解 React 效能優化的基礎在於 JavaScript 的物件比較:
2 === 2 (True){ id: 1 } === { id: 1 } (False,不同記憶體位址)function() {} === function() {} (False,不同記憶體位址)
useMemo和useCallback的核心價值,是讓上述的False變成True,提供「穩定的記憶體位址」,以騙過 React 的依賴檢查,阻止不必要的重新渲染。
目錄:
useEffect 是 React 函數元件中最核心的「逃生艙 (Escape Hatch)」。它的存在是為了讓 React 這個純粹的 UI 渲染引擎,能夠與「外部系統」產生互動。
什麼是外部系統?
document.addEventListener, Chart.js 實體化)。setTimeout, setInterval)。💡 核心守則:
useEffect的目的不是用來處理使用者的操作事件(如點擊按鈕請用onClick),也不是用來做資料的轉換(如陣列過濾請直接寫在渲染邏輯或useMemo中)。它是專為**「同步狀態」**而生的。
useEffect 永遠保證在瀏覽器完成畫面渲染 (Paint) 之後才非同步執行,這確保了複雜的副作用不會阻塞使用者的視覺體驗。
它透過依賴陣列來決定何時該重新執行:
useEffect(() => {...}) (無陣列):每次元件 render 後都會執行(極度危險,容易引發無窮迴圈)。useEffect(() => {...}, []) (空陣列):只有在元件初次掛載 (Mount) 時執行一次。useEffect(() => {...}, [stateA, propB]):初次掛載執行,且當 stateA 或 propB 的值發生改變時(使用 Object.is 淺層比對),才會再次執行。陷阱 A:陳舊閉包 (Stale Closure) 當 Effect 內部使用了外部的 State 或 Props,卻沒有將其加入依賴陣列時,閉包會「鎖住」第一次渲染時的變數狀態。
setInterval 裡面印出的 count 永遠是 0。setCount(prev => prev + 1)),或誠實地將變數加入依賴陣列。陷阱 B:依賴物件引發的無窮迴圈 (Infinite Loop)
如果在 Effect 內部呼叫 setState,且依賴陣列中包含了一個「每次 render 都會重新產生的物件或陣列」。
{ id: userId },因為每次 render 都是新的記憶體位址,導致 Effect 瘋狂重跑。[userId],或是使用 useMemo 穩定該物件的記憶體位址。陷阱 C:競態條件 (Race Conditions) 當依賴改變導致多次發送非同步的 API 請求時,由於網路延遲不穩定,後發出的請求可能比先發出的請求更早回來,導致畫面顯示舊資料。
AbortController 取消前一次的請求。陷阱 D:濫用 Effect 處理衍生狀態 (Anti-Pattern)
為了根據 items 算出 totalPrice,而在 useEffect 裡面呼叫 setTotalPrice。這會導致元件強制渲染兩次(一次更新 items,一次更新 totalPrice)。
const totalPrice = items.reduce(...),如有耗能疑慮再套用 useMemo。範例 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>;
}
useLayoutEffect 的 API 簽名與 useEffect 完全相同,但執行時機有著本質上的差異:它是同步執行的。
如果你的 UI 需要根據實體節點的寬高、位置來決定最終樣貌,使用 useEffect 會導致使用者先看到「初始位置」,隨後才看到「正確位置」,產生肉眼可見的閃爍(Flickering)。
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 以追求「提早幾毫秒」發送請求。
onload。useEffect。⚠️Next.js (App Router) 環境提醒:
即使宣告為
"use client",組件依然會先在伺服器端執行一次 SSR 預渲染。由於伺服器環境缺乏window/document,useLayoutEffect無法執行且可能導致 Hydration 錯誤。在現代 SSR 架構下應謹慎檢查執行環境,或使用useIsomorphicLayoutEffect模式進行防禦。
useMemo 是 React 中最常被誤解,但也最常被用來進行效能優化的 Hook。它的本質是**「以記憶體空間換取 CPU 執行時間」**。它會在元件渲染期間 (During Render) 執行,並將回傳的結果快取起來。只要依賴陣列沒有改變,下次渲染時就會直接拿取快取值,跳過重新計算的過程。
在實務架構上,useMemo 解決了三個不同維度的效能問題:
{} === {} 為 false)。useMemo 能將物件的記憶體位址「鎖住」,配合 React.memo 阻擋下游子元件發生無意義的渲染。useEffect 的依賴項時,使用 useMemo 可以避免 Effect 因為記憶體位址改變而引發無窮迴圈或重複發送 API 請求。⚠️與 DOM的關係 :若沒有用
React.memo,React 的 Virtual DOM 還是會重新計算 (Re-render),只是因為React 底層的 Diff 演算法比對後判定最終產出的 JSX 結構並無差異,所以不會更新回實際的DOM而已
1. 過度優化 (Premature Optimization)
useMemo 本身是有成本的!React 需要分配記憶體來儲存結果,並且每次渲染都要跑一遍依賴陣列的比對邏輯。
a + b)、或是回傳 Boolean 值使用 useMemo。這不僅不會變快,反而因為額外的依賴檢查讓效能變慢。React.memo 保護的子元件 / 作為 Hook 依賴,否則不要使用 useMemo。2. 在 useMemo 內部執行副作用useMemo 的設計是為了「計算純粹的值」。它會在 React 的渲染階段 (Render Phase) 同步執行。
useMemo 裡面呼叫 fetch 打 API,或是嘗試操作 document.getElementById。useEffect 中處理。3. 忽略了 Primitive Values (基本型別) 不需要快取 JavaScript 的字串、數字、布林值是「傳值 (Pass by Value)」而非傳參考。
const isDarkMode = useMemo(() => theme === 'dark', [theme])isDarkMode 是一個布林值,直接宣告 const isDarkMode = theme === 'dark' 即可。就算傳給子元件,只要值是一樣的,React.memo 的淺層比對就會判定為不變。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>
);
}
在 React 中,每當元件重新渲染(即重新執行該 Component 函式)時,受限於 JavaScript 的特性,元件內部定義的所有函式都會被重新分配一塊全新的記憶體空間。 這意味著:oldFunc === newFunc 永遠為 false。
💡 核心誤區:
useCallback不會讓函式的執行速度變快。相反地,由於它需要解析依賴陣列並進行比對,它在初次定義與每次比對時都會消耗微小的額外資源。它的真正價值在於穩定下游元件的渲染鏈。
React.memo 優化子元件渲染:
當一個函式作為 Props 傳遞給被 React.memo 保護的子元件時,如果該函式沒有被 useCallback 鎖住位址,子元件會因為「Props 變了(位址不同)」而被迫重新渲染。useEffect 或另一個 useMemo 的依賴陣列中時,必須使用 useCallback 穩定它,否則會引發無限重繪或非預期的副作用執行。陷阱 A:無意義的過度封裝 (The "Just-in-case" Trap)
如果一個函式只是傳遞給原生的 HTML 標籤(如 <button onClick={...}>)或者沒有被 memo 的一般元件,使用 useCallback 是負優化。因為父元件重繪時,這些元件還是會重繪,只是多浪費了記憶體去快取那個函式。
陷阱 B:依賴地獄與陳舊閉包
如果 useCallback 的依賴陣列漏寫了某個 State,函式內部會永遠抓到該 State 的舊值。但如果依賴過多,函式會頻繁重建,導致 useCallback 失去意義。
setCount(c => c + 1)) 來移除對 State 本身的依賴,保持依賴陣列的精簡。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>
);
}
預設情況下,React.memo 扮演的是「淺層比對 (Shallow Compare)」的守門員角色。它會將 prevProps 與 nextProps 的屬性一一攤開,透過 Object.is() 檢查記憶體位址是否相同。只要有任何一個屬性的位址改變(例如父元件傳來了新的 inline function 或新產生的物件),防線就會被突破,導致子元件重新渲染。
但在實務開發中,我們經常遇到無法控制父元件的狀況(例如:接收第三方套件的資料、維護 Legacy Code,而父元件的開發者忘記寫 useCallback)。這時,我們可以透過 React.memo 的第二個參數 arePropsEqual,在子元件端建立一道「主動防禦」的機制。
arePropsEqual(prevProps, nextProps) 是一個回傳布林值 (Boolean) 的函式:
true:代表 props 實質上「相等」 👉 攔截並跳過此次渲染。false:代表 props 已經「改變」 👉 放行並執行重新渲染。⚠️ :這裡的邏輯與早期 Class Component 的
shouldComponentUpdate是完全相反的。shouldComponentUpdate回傳true是要求更新,而arePropsEqual回傳true是要求不更新。濫用可能導致陳舊資料 (Stale Closure) 的 Bug。
arePropsEqual 的代價與陷阱雖然 arePropsEqual 能提供精準的渲染控制,但它在程式碼的可維護性上會帶來不小的挑戰,這也是為什麼它被稱為「最後防線」而非首選方案。
onPointClick 的比對。如果父元件的 handleChartClick 內部讀取了某個變動的 State (例如 count),因為子元件拒絕更新,當使用者點擊圖表時,執行的永遠是第一次渲染時被快取下來的舊函式。這會導致印出的 count 永遠是舊資料,產生極難追蹤的 Bug。<ComplexChart /> 新增了一個 prop(例如 theme="dark"),如果他忘記去更新 arePropsEqual 的比對邏輯,那麼在切換主題時,圖表將完全沒有反應。這種隱含的邏輯依賴會大幅增加團隊協作的成本。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>
);
}
在 React 18 以前,我們在撰寫 useEffect 時,經常會陷入一個被稱為**「依賴陣列地獄 (Dependency Hell)」**的死局:
「我需要在 Effect 裡面讀取最新的 State,但我絕對不希望這個 State 改變時,Effect 被重新執行!」
碎碎念: