React 效能優化 是做什麼的?
針對 React 18/19 與 Next.js 的效能優化模式,改編自 Vercel Labs react-best-practices(MIT 授權,v1.0.0)。此技能依優先級整理規則,並提供決策樹指引,供活躍的程式碼審查與重構使用。
啟用時機
- 撰寫或審查 React/Next.js 效能相關程式碼時
- 診斷頁面載入緩慢、互動卡頓或客戶端 CPU 使用率過高時
- 審計 bundle 大小或 Lighthouse Core Web Vitals 衰退時
- 消除 Server Components / API 路由中的瀑布請求時
- 減少客戶端重新渲染時
- 優化長列表、動畫或 hydration 時
- 審查影響
app/、pages/、components/或資料層的 PR 中的優化選擇時
優先級索引
| 優先級 | 類別 | 前綴 | 適用時機 |
|---|---|---|---|
| 1 — 極重要 | 消除瀑布請求 | async- | 任何 await 後面緊接著獨立的 await 時 |
| 2 — 極重要 | Bundle 大小優化 | bundle- | 首次載入 JS、路由層級導入、第三方套件 |
| 3 — 高 | 伺服器端效能 | server- | RSC、Server Actions、API 路由、SSR |
| 4 — 中高 | 客戶端資料擷取 | client- | SWR / TanStack Query / hooks 中的原始 fetch |
| 5 — 中 | 重新渲染優化 | rerender- | 高頻狀態更新、父-子元件扇出 |
| 6 — 中 | 渲染效能 | rendering- | 長列表、動畫、hydration |
| 7 — 中低 | JavaScript 效能 | js- | 熱門迴圈、頻繁的記憶體分配 |
| 8 — 低 | 進階模式 | advanced- | Effect 與事件整合、穩定的 refs |
1. 消除瀑布請求(極重要)
「瀑布請求是頭號效能殺手」—— 每個連續的
await都會增加完整的網路延遲。
在 await 之前檢查低成本條件
在等待遠端資料之前,先檢查同步條件(props、環境變數、硬編碼標記)。
// 錯誤
async function Page({ id }: { id: string }) {
const flag = await getFlag("show-page");
if (!flag || !id) return null;
const data = await getData(id);
// ...
}
// 正確 — 先用低成本同步條件短路
async function Page({ id }: { id: string }) {
if (!id) return null;
const flag = await getFlag("show-page");
if (!flag) return null;
const data = await getData(id);
}
延後 await 直到真正使用
將 await 移入使用它的分支。
// 錯誤 — 在決定是否需要資料之前就 await
const user = await getUser(id);
if (mode === "guest") return renderGuest();
return renderUser(user);
// 正確
if (mode === "guest") return renderGuest();
const user = await getUser(id);
return renderUser(user);
對獨立任務使用 Promise.all
// 錯誤 — 連續執行
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
// 正確 — 並行執行
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
部分依賴 — 提早啟動,延後 await
// 正確 — 同時啟動所有 Promise,只在需要結果時才 await
const userP = getUser(id);
const postsP = getPosts(id);
const profile = await getProfile(id);
if (profile.private) return null;
const [user, posts] = await Promise.all([userP, postsP]);
使用 Suspense 進行串流
將 <Suspense> 邊界放置在靠近資料的位置,以便頁面能在較慢的子樹串流進來時,先渲染其能渲染的部分。權衡:內容送達時可能發生版面位移——預留空間(骨架或 min-height)。
Server Components:透過組合實現並行
// 錯誤 — 同一個元件內的兄弟 await 會順序執行
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// 正確 — 拆分為子元件,React 會並行執行它們
export default async function Page() {
return (
<View>
<UserSection />
<CartSection />
</View>
);
}
2. Bundle 大小優化(極重要)
直接導入,而非 barrel 檔案
Barrel index.ts 檔案會強迫 bundler 遍歷整個模組圖譜,即使 tree-shaking 會移除大部分內容。直接導入在許多實際應用程式中可節省 200-800ms 的首次載入 JS。
// 錯誤
import { Button, Card, Modal } from "@/components";
// 正確
import { Button } from "@/components/Button";
import { Card } from "@/components/Card";
import { Modal } from "@/components/Modal";
Next.js 13.5+ 有 Optimize Package Imports 可以針對列出的套件自動化此操作——請使用它;但對於未列出的套件,仍需要手動直接導入。
可靜態分析的路徑
// 錯誤 — 破壞了 bundler/追蹤分析
const mod = await import(`./pages/${name}`);
// 正確 — 每個分支明確指定
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
為重量級元件使用動態導入
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton />,
ssr: false, // 僅限客戶端使用時
});
延後第三方腳本
在 hydration 之後載入分析、日誌、支援小工具。使用 next/script 搭配 strategy="afterInteractive"(預設)或 "lazyOnload"。
條件式模組載入
if (user.role === "admin") {
const { AdminPanel } = await import("./admin/AdminPanel");
// ...
}
在滑鼠懸停/聚焦時預載
在滑鼠懸停時觸發 <link rel="preload"> 或 import(),以便在使用者點擊時 bundle 已存在於快取中。
3. 伺服器端效能(高)
像 API 路由一樣對 Server Actions 進行身份驗證
每個 "use server" 函式都是一個公開端點。在 action 內部進行身份驗證與授權——永遠不要依賴呼叫它的 Client Component 的閘道。
"use server";
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const targetId = String(formData.get("id"));
if (session.user.role !== "admin" && session.user.id !== targetId) {
throw new Error("Forbidden");
}
await db.user.delete({ where: { id: targetId } });
}
React.cache() 用於每個請求的去重
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
React.cache 在單個請求內進行去重。從三個 Server Components 呼叫 getUser("1") 在相同渲染中 = 一次資料庫查詢。
跨請求資料的 LRU 快取
對於不會因請求而改變的資料(配置、查找表),使用 LRU 快取或 unstable_cache 在 React 外部進行快取。
避免 RSC props 中的重複序列化
當一個 Server Component 將相同的資料渲染到多個 Client Components 時,資料會為每個消費者序列化一次。將 Client Component 提升並傳遞 children。
將靜態 I/O 提升至模組作用域
// 正確 — 在模組載入時執行一次
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
RSC/SSR 中不應有可變的模組層級狀態
伺服器上的模組狀態在所有請求之間共享——這會造成使用者之間的競爭條件。應使用請求作用域的儲存(headers()、cookies()、非同步上下文)來取代。
最小化傳遞給 Client Components 的資料
僅序列化 Client 需要的資料。在資料庫層級過濾欄位、分頁、投影。
使用每個項目的 Promise.all 並行化巢狀擷取
const users = await getUsers();
const enriched = await Promise.all(
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
);
使用 after() 處理非阻塞工作
Next.js 15 的 after() 在回應發送後執行工作——日誌記錄、快取預熱、分析。
import { after } from "next/server";
export async function GET() {
const data = await getData();
after(() => logAnalytics(data));
return Response.json(data);
}
4. 客戶端資料擷取(中高)
使用 SWR / TanStack Query 進行去重
多個元件呼叫 useUser(id) 應共享一個網路請求和一個快取條目。請使用 SWR 或 TanStack Query——永遠不要為了共享資料而自己實作 useEffect + fetch。
去重全域事件監聽器
// 錯誤 — 每個元件都添加自己的監聽器
useEffect(() => {
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// 正確 — 透過 hook + 全域 subject 共享單一監聽器
const useScroll = createScrollHook(); // 底層是單例 subject
為滾動使用被動監聽器
window.addEventListener("scroll", handler, { passive: true });
改善滾動流暢度;監聽器無法呼叫 preventDefault()。
localStorage:版本化 + 最小化
- 始終儲存
version欄位;在 schema 變更時更新版本號,並遷移或拋棄舊資料 - 保持 payload 小——
localStorage是同步的,會阻塞主執行緒
5. 重新渲染優化(中)
不要訂閱僅在回呼中使用的狀態
// 錯誤 — 每次 count 改變時都會重新渲染
const count = useStore((s) => s.count);
const handler = () => doSomething(count);
// 正確 — 在呼叫時讀取一次
const handler = () => {
const count = useStore.getState().count;
doSomething(count);
};
將昂貴的計算提取到 memoized 元件中
// 正確 — 子元件僅在 `items` 改變時重新渲染
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
return <Chart data={transform(items)} />;
});
提升預設的非基本型別 props
// 錯誤 — 每次渲染都建立新陣列,破壞 memo
<List items={items ?? []} />
// 正確
const EMPTY: Item[] = [];
<List items={items ?? EMPTY} />
在 effects 中使用基本型別依賴
// 錯誤 — 每次渲染都有新的物件標識
useEffect(() => {}, [{ id, name }]);
// 正確 — 基本型別
useEffect(() => {}, [id, name]);
訂閱衍生的布林值,而非原始值
// 錯誤 — 為任何購物車變更而重新渲染
const cart = useStore((s) => s.cart);
const hasItems = cart.length > 0;
// 正確 — 僅在空/非空狀態切換時重新渲染
const hasItems = useStore((s) => s.cart.length > 0);
在渲染期間推導值,絕不使用 useEffect
// 錯誤
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// 正確
const full = `${first} ${last}`;
為穩定的回呼使用函數式 setState
// 正確
const increment = useCallback(() => setCount((c) => c + 1), []);
為昂貴的值使用懶加載狀態初始化器
const [tree] = useState(() => parseTree(largeInput));
避免為簡單的基本型別使用 memo
useMemo(() => x + 1, [x]) 是開銷。Memo 在物件標識和昂貴計算上才值得使用。
拆分具有獨立依賴的 hooks
// 錯誤 — 如果任一來源改變,兩個 selector 都會重新執行
const { a, b } = useSomething(source1, source2);
// 正確
const a = useA(source1);
const b = useB(source2);
將互動邏輯移至事件處理函式中
事件處理函式僅在使用者操作時執行——useEffect 則會在依賴變更時重新執行。
為非緊急更新使用 startTransition
const [pending, startTransition] = useTransition();
startTransition(() => setFilters(newFilters));
為昂貴的渲染使用 useDeferredValue
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
為暫態高頻值使用 useRef
對於經常變更但不應觸發重新渲染的值(時間戳、最後按鍵、累加器)。
不要在元件內部定義元件
// 錯誤 — 每次 Outer 渲染時 Inner 都是一個新元件
function Outer() {
const Inner = () => <span />;
return <Inner />;
}
每次渲染都會產生一個新的 Inner 型別,這會破壞協調機制並卸載子元件。
6. 渲染效能(中)
對包裝層進行動畫,而非 SVG 本身
對 SVG 周圍的 <div> 包裝層進行變形(transform)是由 GPU 加速的;對 SVG 本身進行變形則會觸發繪製(paint)。
為長列表使用 content-visibility: auto
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
瀏覽器會跳過螢幕外的渲染——對於有數百行的列表來說是一大勝利。
提升靜態 JSX
const STATIC_HEADER = <h1>Title</h1>;
function Page() {
return <>{STATIC_HEADER}<Body /></>;
}
SVG:降低座標精度
d="M10.123456,20.654321" → d="M10.12,20.65"。每個數字都佔用位元組;視覺差異小於像素。
透過內聯腳本實現無閃爍的 Hydration
對於 hydration 之前需要的值(主題、語言環境),在 React 掛載之前,內聯一個 <script> 來設定 document.documentElement.dataset.*。
精確抑制預期的 Hydration 不匹配
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
僅對已知會不同的葉節點使用——絕不能用在包含其他 children 的樹上。
使用 <Activity> 進行顯示/隱藏,而非掛載/卸載
React 19 的 <Activity mode="visible|hidden"> 會保持樹的狀態和 effects 的掛載,但隱藏起來——對於頁籤和手風琴元件來說,比卸載/重新掛載更便宜。
條件渲染使用三元運算子而非 &&
// 錯誤 — `0` 會被渲染為文字節點
{count && <Badge>{count}</Badge>}
// 正確
{count > 0 ? <Badge>{count}</Badge> : null}
為載入狀態使用 useTransition
將 startTransition 與 action 配對使用;React 會在計算下一個狀態時,顯示前一個 UI 並將 isPending 設為真。
React DOM 資源提示
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com")
使用 lazy 進行程式碼分割
import { lazy } from "react";
const LazyComponent = lazy(() => import("./HeavyComponent"));
影像載入優化
import Image from "next/image";
<Image src="/large.jpg" width={800} height={600} priority />
使用 next/image 實現自動 WebP 轉換、延遲載入和尺寸最佳化。
為清單項目使用穩定的 keys
{items.map(item => <li key={item.id}>{item.name}</li>)}
避免使用索引作為 key,除非列表是靜態的且順序不會改變。
使用 shouldComponentUpdate 或 React.memo 進行跳過性渲染
const MemoizedList = React.memo(function List({ items }) {
return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
});
避免在渲染期間建立新的函式
// 錯誤 — 每次渲染都建立新的箭頭函式
<button onClick={() => doSomething(id)}>Click</button>
// 正確 — 使用 useCallback 或將函式定義在元件外部
const handleClick = useCallback(() => doSomething(id), [id]);
<button onClick={handleClick}>Click</button>
優化重新渲染的偵測
// 當只有部分資料改變時,使用 React.memo 避免不必要的重新渲染
const UserProfile = React.memo(function UserProfile({ user, onUpdate }) {
return (
<div>
<h2>{user.name}</h2>
<button onClick={() => onUpdate(user.id)}>Update</button>
</div>
);
});
使用 useCallback 和 useMemo 避免不必要的重新計算
const filteredList = useMemo(() => {
return items.filter(item => item.isActive).sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
在 useEffect 中清理副作用
useEffect(() => {
const subscription = dataSource.subscribe();
return () => subscription.unsubscribe(); // 清理
}, []);
使用 useRef 追蹤先前的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
在 SSR 期間避免瀏覽器專用 API
// 錯誤 — 在 SSR 期間使用 `window` 會導致錯誤
if (typeof window !== "undefined") {
// 僅在客戶端執行
const width = window.innerWidth;
}
使用 ErrorBoundary 處理渲染錯誤
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
優化大型表單的狀態管理
// 使用單一狀態物件而非多個 useState 呼叫
const [formState, setFormState] = useState({
username: '',
email: '',
password: ''
});
const handleChange = (e) => {
setFormState(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
避免在渲染期間讀取 localStorage
// 錯誤 — 同步讀取會阻塞主執行緒
const data = JSON.parse(localStorage.getItem('key'));
// 正確 — 使用 effect 或在事件處理函式中讀取
useEffect(() => {
const data = JSON.parse(localStorage.getItem('key'));
setData(data);
}, []);
此摘要提供了優化 React 應用程式效能的全面指南,涵蓋了從高層級架構到低層級實作細節的各個方面。遵循這些模式,您可以顯著改善應用程式的回應速度和使用者體驗。