FE Day 13 元件設計原則:組合、抽象與邊界
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第十三篇,正式進入「核心」章節的中段。Day 5 到 Day 12 我們把元件、props、state、事件、渲染模型、樣式、表單、資料取得都學會了,能寫出會動的 React 元件。但「會動」跟「好維護」是兩件事——同樣的預約系統,一個人寫的版本三個月後自己看不懂,另一個人寫的版本三個月後還能輕鬆加新功能。差別就在「元件設計」。今天要回答五個核心問題:什麼是組合(composition)?什麼時候該抽 Hook?什麼時候該抽元件?容器型與展示型怎麼分?React 19 的 memoization 工具怎麼用?讀完之後,你應該能寫出「好維護、好測試、好組合」的元件,並在遇到重複邏輯時正確判斷「該抽到哪」。整篇閱讀時間約四十鐘,動手做約三十五分鐘。
引言
後端工程師第一次寫 React,常會陷入「把元件寫成大函式」的陷阱:把所有邏輯塞在一個元件裡,檔案越長越長,最後變成 500 行的怪物。後端寫過 OOP 的人會本能地想「抽 class」,但在 React 函式元件的世界裡,「抽 class」這條路被 Hooks 取代了。新的設計哲學是「組合」(composition)——把元件想成樂高積木,每塊負責一件事,可以自由組合。
組合這個觀念的歷史可以追溯到函式程式設計:寫小函式、用大函式組合小函式。React 把同樣的觀念搬到 UI 元件:寫小元件、用大元件組合小元件。React 19 的設計團隊特別強調這一點——官方文件把「composition vs inheritance」放在元件章節的第一頁,意思是「組合」是 React 的核心哲學,不是選項。
今天的目標有四個:第一,理解「容器型 vs 展示型元件」的差別,學會分層;第二,掌握「複合元件」(compound components)的模式,學會設計彈性的 API;第三,理解「render props / children as function」的彈性設計;第四,認識 React 19 的三個 memoization 工具(React.memo、useMemo、useCallback)的正確使用時機。最後我們會用一個完整的「預約篩選面板」把這些觀念串起來,展示「小而專注的元件」如何組合出強大且好維護的 UI。
單一職責原則:把大元件拆成小元件
單一職責原則(Single Responsibility Principle)講的是「一個元件只做一件事」。這個觀念聽起來抽象,實際上很具體:當一個元件同時負責「抓資料」、「管理 state」、「渲染 UI」、「處理事件」,它就有 4 個職責,應該拆成 4 個元件(或 Hook)。
判斷「該不該拆」有一個簡單的原則:「這個元件的 props 變了,需要重新 render 嗎?如果是獨立的,那它就應該是獨立元件。」例如一個「預約卡片」元件,它內部有「標題」、「狀態標籤」、「操作按鈕」、「時間資訊」四個區塊。如果未來標題要加 hover 效果,狀態標籤要根據使用者角色變色——這兩個變化是獨立的,應該拆成 Header、StatusBadge、ActionButtons、TimeInfo 四個子元件。
// src/features/booking/BookingCard.tsx
// 拆分後的 BookingCard:把每個區塊抽成獨立元件
import type { Booking } from "../../types/booking";
import { BookingHeader } from "./BookingHeader";
import { BookingStatusBadge } from "./BookingStatusBadge";
import { BookingTimeInfo } from "./BookingTimeInfo";
import { BookingActions } from "./BookingActions";
type BookingCardProps = {
booking: Booking;
onCancel?: (id: number) => void;
canEdit?: boolean;
};
export function BookingCard({ booking, onCancel, canEdit }: BookingCardProps) {
return (
<article className="rounded-lg border p-4 shadow-sm">
<BookingHeader customerName={booking.customerName} id={booking.id} />
<BookingStatusBadge status={booking.status} />
<BookingTimeInfo startAt={booking.startAt} duration={booking.duration} />
{canEdit && onCancel && (
<BookingActions onCancel={() => onCancel(booking.id)} />
)}
</article>
);
}
這支 BookingCard 把每個區塊拆成獨立元件,主元件只剩「組裝」這件事。帶來的好處有四個:第一,每個子元件可以單獨測試,不需要 render 整張卡片;第二,狀態變化只觸發對應子元件的 re-render(例如狀態從「待確認」變「已確認」只有 StatusBadge 重繪);第三,未來改某個區塊的 UI 不會影響其他區塊;第四,storybook 可以逐個展示每個子元件。
另一個拆分的訊號是「程式碼長度」。當一個元件超過 200 行,它幾乎一定有重複邏輯或太多職責。Day 16 會展開專案結構與檔案組織,到時候會談「feature folder」的拆分方式:每個功能(例如 booking)有自己的資料夾,內含 components、hooks、types、utils 子目錄。
容器型 vs 展示型元件
React 元件最常見的分類是「容器型」(Container)與「展示型」(Presentational)。前者負責「資料邏輯」(抓 API、管 state、處理事件),後者負責「視覺呈現」(純接收 props、純渲染)。這個分類來自 Redux 時代的最佳實踐,雖然 React 19 之後 Hooks 模糊了邊界,但概念仍然好用。
// src/features/booking/BookingFilterPanel.tsx
// 容器型元件:管資料邏輯
import { useState } from "react";
import type { Booking } from "../../types/booking";
import { BookingFilterView } from "./BookingFilterView";
type Props = { bookings: Booking[] };
export function BookingFilterPanel({ bookings }: Props) {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<string>("all");
const filtered = bookings.filter((b) => {
if (keyword && !b.customerName.includes(keyword)) return false;
if (status !== "all" && b.status !== status) return false;
return true;
});
// 把邏輯(state、計算)與展示(JSX)分離
return (
<BookingFilterView
keyword={keyword}
status={status}
filtered={filtered}
total={bookings.length}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
/>
);
}
// src/features/booking/BookingFilterView.tsx
// 展示型元件:純 props,純 JSX
import type { Booking } from "../../types/booking";
type ViewProps = {
keyword: string;
status: string;
filtered: Booking[];
total: number;
onKeywordChange: (value: string) => void;
onStatusChange: (value: string) => void;
};
export function BookingFilterView({
keyword, status, filtered, total,
onKeywordChange, onStatusChange,
}: ViewProps) {
return (
<div className="space-y-4">
<input
type="search"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder="搜尋姓名或電話"
className="w-full rounded border px-3 py-2"
/>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="rounded border px-3 py-2"
>
<option value="all">全部</option>
<option value="pending">待確認</option>
<option value="confirmed">已確認</option>
<option value="cancelled">已取消</option>
<option value="completed">已完成</option>
</select>
<p className="text-sm text-slate-500">
共 {filtered.length} 筆(總共 {total} 筆)
</p>
<ul className="divide-y rounded border">
{filtered.map((b) => (
<li key={b.id} className="p-3">
<p className="font-medium">{b.customerName}</p>
<p className="text-sm text-slate-600">{b.startAt}</p>
</li>
))}
</ul>
</div>
);
}
這組範例展示容器/展示的典型分工:BookingFilterPanel 管 state、計算過濾邏輯、把 props 傳下去;BookingFilterView 只負責「拿到 props 就渲染」。好處有三個:第一,展示型元件可以在 Storybook 展示,不用準備真實資料;第二,展示型元件容易測試,只要傳 props 就好;第三,未來換資料來源(例如從 API 改成 WebSocket)只改容器,展示型元件完全不受影響。
要注意的是,「容器/展示」的界線不是死的。一個中型元件常常同時有資料邏輯和視覺,這時候不必硬拆。等邏輯長到 100 行以上、視覺變複雜到需要拆檔時再拆就好。過度拆分(每個 5 行的元件)會讓程式碼難以追蹤,反而是另一種災難。
複合元件:讓 API 更語意化
複合元件(compound components)是一種「把多個相關的元件綁在一起」的設計模式。最經典的案例是 HTML 的 <select><option>:select 跟 option 是父子元件,但開發者不會單獨用 option(沒有 select 的 option 沒有意義)。React 的 Tabs、Menu、Accordion 都是這種模式。
複合元件的實現有多種方式,最簡單的是「父元件 export 多個子元件」。呼叫端寫起來語意化、組合靈活:
// src/components/Tabs.tsx
// 複合元件:Tabs + Tabs.List + Tabs.Tab + Tabs.Panel
import { useState } from "react";
import type { ReactNode } from "react";
type TabsContext = {
activeId: string;
setActiveId: (id: string) => void;
};
// 用 Context 讓子元件共享 activeId
import { createContext, useContext } from "react";
const TabsContext = createContext<TabsContext | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs 子元件必須放在 Tabs 內");
return ctx;
}
// 主元件:管理 state、提供 Context
function TabsRoot({
defaultId,
children,
}: {
defaultId: string;
children: ReactNode;
}) {
const [activeId, setActiveId] = useState(defaultId);
return (
<TabsContext.Provider value={{ activeId, setActiveId }}>
{children}
</TabsContext.Provider>
);
}
// 子元件:透過 Context 知道當前 active
function TabsList({ children }: { children: ReactNode }) {
return <div role="tablist" className="flex border-b">{children}</div>;
}
function TabsTab({ id, children }: { id: string; children: ReactNode }) {
const { activeId, setActiveId } = useTabs();
const isActive = activeId === id;
return (
<button
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setActiveId(id)}
className={[
"px-4 py-2 text-sm",
isActive ? "border-b-2 border-slate-900 font-semibold" : "text-slate-500",
].join(" ")}
>
{children}
</button>
);
}
function TabsPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeId } = useTabs();
if (activeId !== id) return null;
return <div role="tabpanel" className="p-4">{children}</div>;
}
// 組合:把多個子元件掛到 Tabs 主元件上
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
});
這組 Tabs 展示了完整的複合元件模式。呼叫端寫起來非常語意化:
// 用法
<Tabs defaultId="info">
<Tabs.List>
<Tabs.Tab id="info">資訊</Tabs.Tab>
<Tabs.Tab id="stats">統計</Tabs.Tab>
<Tabs.Tab id="settings">設定</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="info">預約總覽</Tabs.Panel>
<Tabs.Panel id="stats">12 筆待確認</Tabs.Panel>
<Tabs.Panel id="settings">通知偏好</Tabs.Panel>
</Tabs>
這種寫法跟 HTML 原生的 <select><option> 一致,使用者一看就懂。背後的實作關鍵有兩個:第一,用 Context 把 activeId 從主元件傳到子元件,子元件不需要父元件層層傳 props;第二,用 Object.assign 把多個子元件「掛」到主元件上,讓使用者可以用 Tabs.Tab 這樣的命名空間寫法。
實務上 Radix UI、shadcn/ui、Headless UI 等主流元件庫幾乎都採複合元件模式。學會這個模式,你就能看懂所有主流元件庫的原始碼,也能自己設計語意化的 API。Day 32 設計系統章節會大量用到這個觀念。
Children as function:彈性的渲染策略
另一個元件設計的彈性模式是「children as function」(或稱 render props)。這個模式讓父元件把「決定渲染什麼」的權力交給呼叫端,而不是寫死。
// src/components/DataLoader.tsx
// Children as function:把渲染權交給呼叫端
import type { ReactNode } from "react";
type DataLoaderProps<T> = {
data: T | null;
loading: boolean;
error: Error | null;
children: (state: {
data: T | null;
loading: boolean;
error: Error | null;
}) => ReactNode;
};
export function DataLoader<T>({ data, loading, error, children }: DataLoaderProps<T>) {
return <>{children({ data, loading, error })}</>;
}
呼叫端可以根據 loading、error、data 三種狀態決定怎麼渲染:
// 用法:把渲染邏輯留給呼叫端
<DataLoader<Booking[]> data={bookings} loading={isLoading} error={error}>
{({ data, loading, error }) => {
if (loading) return <p>載入中…</p>;
if (error) return <p className="text-red-600">載入失敗:{error.message}</p>;
if (!data || data.length === 0) return <p>目前沒有預約</p>;
return <ul>{data.map((b) => <li key={b.id}>{b.customerName}</li>)}</ul>;
}}
</DataLoader>
這個模式的優點是「父元件只負責資料,子元件自己決定怎麼顯示」。缺點是呼叫端要寫較多程式碼——所以實務上常用在「資料載入層」這種需要給呼叫端完全自由的情境。一般 UI 元件用 props 就夠了,不需要 render props。React 19 的 useActionState 與 Suspense 也減少了 render props 的使用場景。
另一個延伸是「slots」模式:用多個具名 children(不只 children 這一個 prop)來組成元件。例如 Modal 元件需要 title、body、footer 三個區域,可以這樣設計:
// src/components/Modal.tsx
// Slots 模式:用多個 props 接收 JSX
import type { ReactNode } from "react";
type ModalProps = {
title: ReactNode;
body: ReactNode;
footer?: ReactNode;
onClose: () => void;
};
export function Modal({ title, body, footer, onClose }: ModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
<button type="button" onClick={onClose} aria-label="關閉" className="text-slate-500 hover:text-slate-900">
×
</button>
</div>
<div className="mb-4">{body}</div>
{footer && <div className="flex justify-end gap-2">{footer}</div>}
</div>
</div>
);
}
這個 Modal 展示 slots 模式:title、body、footer 都是 ReactNode 類型,呼叫端傳什麼就顯示什麼:
// 用 Modal:title、body、footer 三個 slots
<Modal
title=<span>確認刪除</span>
body=<p>這筆預約刪除後無法復原,確定嗎?</p>
footer=<>
<button type="button" onClick={onClose}>取消</button>
<button type="button" onClick={handleDelete}>確定刪除</button>
</>
/>
Slots 模式讓父元件保持視覺一致性,但把內容決策權交給呼叫端。這比 children-as-function 更直觀(不需要寫函式),適合用在「視覺框架固定、內容客製化」的情境——例如 Modal、Drawer、Card、Section 都是這種模式的好應用。
完整實作:可重用的預約面板
把今天學的觀念整合起來:寫一個「可重用預約面板」元件,展示容器/展示、複合元件、單一職責的綜合運用。
// src/features/booking/BookingPanel.tsx
// 容器:管資料邏輯
import { useMemo, useState } from "react";
import type { Booking } from "../../types/booking";
import { BookingPanelView } from "./BookingPanelView";
type Props = { bookings: Booking[]; defaultStatus?: string };
export function BookingPanel({ bookings, defaultStatus = "all" }: Props) {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState(defaultStatus);
// 用 useMemo 記住過濾結果(避免每次 render 重算)
const filtered = useMemo(() => {
return bookings.filter((b) => {
if (keyword && !b.customerName.includes(keyword)) return false;
if (status !== "all" && b.status !== status) return false;
return true;
});
}, [bookings, keyword, status]);
return (
<BookingPanelView
keyword={keyword}
status={status}
filtered={filtered}
total={bookings.length}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
/>
);
}
// src/features/booking/BookingPanelView.tsx
// 展示:純 props、純 JSX
import type { Booking } from "../../types/booking";
type ViewProps = {
keyword: string;
status: string;
filtered: Booking[];
total: number;
onKeywordChange: (v: string) => void;
onStatusChange: (v: string) => void;
};
export function BookingPanelView({
keyword, status, filtered, total,
onKeywordChange, onStatusChange,
}: ViewProps) {
return (
<section className="space-y-4">
<header className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<input
type="search"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder="搜尋姓名或電話"
className="rounded border px-3 py-2 md:w-72"
/>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="rounded border px-3 py-2"
>
<option value="all">全部</option>
<option value="pending">待確認</option>
<option value="confirmed">已確認</option>
<option value="cancelled">已取消</option>
<option value="completed">已完成</option>
</select>
</header>
<p className="text-sm text-slate-500">
共 {filtered.length} 筆(總共 {total} 筆)
</p>
<ul className="divide-y rounded border">
{filtered.map((b) => (
<li key={b.id} className="p-3">
<p className="font-medium">{b.customerName}</p>
<p className="text-sm text-slate-600">{b.startAt}</p>
</li>
))}
</ul>
</section>
);
}
這組元件展示了今天學的所有觀念:第一,BookingPanel 是容器,BookingPanelView 是展示,兩者職責分離;第二,用 useMemo 記住 filtered,避免每次 render 重算(這是 React 19 的重要最佳化工具);第三,把 keyword 與 status 拆成獨立 state,UI 改變時只有對應子元件 re-render;第四,呼叫端只需傳 bookings 與 defaultStatus,整個面板就能運作。整套程式碼約 80 行,比 500 行的單檔怪物好維護太多。
元件 vs 自訂 Hook:邏輯該放哪
談元件設計不能跳過「邏輯該放元件還是 Hook」。這個問題沒有標準答案,但有一套判斷原則:「邏輯有沒有產生 UI?」。如果有,元件;如果沒有,Hook。例如「篩選 bookings」這個邏輯——它計算新的陣列、不產生 UI,就應該抽成 Hook(如 useBookingFilter);「渲染篩選後的清單」是 UI 工作,留在元件裡。
判斷流程可以整理成三個問題:第一,這段邏輯有回傳 JSX 嗎?有 → 元件;沒有 → Hook 候選人。第二,這段邏輯會被多個元件共用嗎?是 → 抽 Hook;否則留在原元件。第三,這段邏輯需要有自己的 state 或 effect 嗎?是 → 抽 Hook;否則可以是純函式。當三個問題的答案明確指向 Hook,就抽 Hook;指向元件,就抽元件;都模糊時先留在原處,等重複出現再決定。
React 19 對這個問題的官方建議是「先試 Hook」。原因是 Hook 比元件更易組合——Hook 回傳的值可以傳給任何元件;元件的回傳值(JSX)就只能用在 render 階段。所以當一段邏輯「可能產生 JSX、也可能純資料」時,先試 Hook,把 JSX 的部分留給呼叫端的元件去組合。今天的 BookingPanelView 之所以不是 Hook,是因為它「純粹是 JSX」——沒邏輯、沒 state、沒 effect,這就是展示型元件的典型形狀。
另一個常見的設計決定是「Hook 要不要回傳一個完整的物件」。例如 useBookingFilter 可以回傳 { keyword, status, setKeyword, setStatus, filtered },也可以回傳多個值 [keyword, status, setKeyword, setStatus, filtered]。React 19 的社群偏好回傳物件(語意化、可擴充),但物件解構會失去 React 的 fast-refresh 支援。實務上小型 Hook 用 array 比較方便,大型 Hook 用 object 比較語意化。
常見錯誤與踩雷
元件設計最常踩的雷有四個。第一個是「過早抽象」。看到兩段相似的程式碼就急著抽共用元件,結果抽象錯了,後續改需求的時候反而要拆掉重來。建議「三振原則」:看到重複三次以上再考慮抽象,第二次出現先接受重複。
第二個是「props drilling 太深」。當 A 元件傳 props 給 B、B 再傳給 C、C 再傳給 D,越來越深時,維護成本直線上升。修法是用 Context(Day 11)把這層提升跳過。但 Context 不是萬靈丹——它會讓元件「隱性依賴」於某個 Provider,測試時要包 Provider 才能跑。
第三個是「忘了 React.memo 的合理使用情境」。React.memo 是「讓 props 沒變就不 re-render」的工具,但「props 沒變」這個判斷對**物件、陣列、函式**會失敗——因為每次 render 都會建立新的 reference。所以「React.memo(MyComponent) + 每次 render 都傳新的 array prop」會讓 memo 完全失效。修法是搭配 useMemo(記住 array)與 useCallback(記住函式)。但這也要看情境:當元件本身很便宜(render 不到 1ms),加 memo 反而是浪費——React 還要花時間「判斷 props 沒變」。實務原則:「先不加 memo,等真的有效能問題再加」。
第四個是「元件命名不一致」。同樣性質的元件,有的叫 XList、有的叫 XItems、有的叫 Xs——維護時找不到。建議團隊約定命名規範:「展示清單用 List、單一條目用 Item、表單欄位用 Field、按鈕用 Button」。TypeScript 可以搭配 type-level 的命名約束來強化這個慣例。
效能與實務提醒
React 19 的 memoization 工具用錯反而會拖慢效能。第一個提醒是「useMemo 的成本」。useMemo 本身有記憶與比對的成本,只有「計算昂貴」或「傳給 memo 子元件的 reference 必須穩定」這兩種情境才值得用。純字串相加、陣列 filter 在小型資料下完全不需要 useMemo。
第二個是「React.memo 的對等比較」。React.memo 預設用 Object.is 比較 props,但對於物件型 props 它會逐層比較(shallow equal)。當 props 內含物件或函式,必須搭配 useMemo/useCallback 才能真的穩定。第三個是「useCallback 的常見誤用」。把每個事件處理函式都包 useCallback 是過度最佳化——只有當這個函式會傳給 memo 子元件時才需要 useCallback,否則不必。
另一個延伸主題是「狀態的拆分」。當一個元件管太多 state,每次某個 state 變動都會讓整個元件 re-render。正確的拆分原則是「依變動頻率拆」:每 1 秒變動的 state 跟每分鐘變動的 state 分開放;表單欄位之間通常獨立變動也應該拆。今天寫的 BookingPanel 就是好例子:keyword(每次按鍵變)與 status(每次選擇變)拆成兩個 useState,UI 上對應的 input 與 select 子元件只訂閱自己的 state。
最後一個提醒是「元件的單元測試」。當元件拆得當(容器/展示分離、單一職責),測試就簡單:展示型元件傳 props 就好、容器型元件可以 mock 子元件。Day 15 的 Vitest 章節會用今天這些元件當範例,把測試寫法一次講清楚。
小結
今天我們把元件設計的觀念拆開來看:單一職責原則、容器/展示分離、複合元件(Tabs + Tabs.List + Tabs.Tab + Tabs.Panel)、render props 與 slots 模式。最後用一個完整的 BookingPanel + BookingPanelView 把所有觀念串起來。明天 Day 14 進入資料取得:fetch、useEffect 怎麼打 API、TanStack Query 怎麼做快取、loading 與 error 狀態怎麼設計。Day 14 結束時,你的前端就能跟真實的後端 API 串接了。
結語
明天,我們會把資料取得一次學完:原生 fetch + useEffect 的基本寫法、TanStack Query(或 SWR)怎麼處理快取與重試、loading / error / empty 三種狀態怎麼設計、stale time 與 gc time 的差別。Day 14 是「前端從純 UI 跨到資料驅動」的關鍵轉折。接著 Day 15 進入測試——把今天寫的元件用 Vitest 與 React Testing Library 跑起來,驗證重構沒有破壞既有行為。
延伸資源
- React 官方〈Thinking in React〉:<code>https://react.dev/learn/thinking-in-react</code>
- React 官方〈Passing Props to a Component〉:<code>https://react.dev/learn/passing-props-to-a-component</code>
- React 官方〈Reusing Logic with Custom Hooks〉:<code>https://react.dev/learn/reusing-logic-with-custom-hooks</code>
- Kent C. Dodds〈Advanced React Patterns〉:<code>https://kentcdodds.com/blog/advanced-react-patterns</code>
- shadcn/ui 元件原始碼(複合元件最佳範例集):<code>https://ui.shadcn.com/</code>
留言
張貼留言