「好的元件」有哪些可判斷的特徵?何時該拆、何時不該拆?
為什麼資深工程師偏好「組合」勝過一直加 props?
受控與非受控元件的本質差異是什麼?公司的表單元件為什麼都設計成受控?
邏輯與畫面怎麼分離?Compound Components 解決什麼問題?
前四章講 React 的運作規則;這一章開始講設計—— 同樣的需求有很多種寫法都「能動」,差別在半年後好不好改。 元件設計的核心視角轉換只有一句話: 元件是一個 API,props 是它的介面,使用它的同事(包括未來的你)是它的用戶。
好元件的三個可檢驗特徵
「高內聚低耦合」這類口號難以操作,換成三個看得見的檢查點:
單一職責:能用一句不含「和」「以及」的話說出它做什麼。 「顯示使用者卡片」通過;「顯示使用者卡片以及管理追蹤狀態以及抓取推薦名單」該拆了。
介面誠實:看 props 型別就能正確使用,不需要打開實作。 隱藏的前提(「要先包在某個 Provider 裡」「兩個 props 只能擇一傳」) 要嘛消除、要嘛用型別表達出來(第 7 章會做到「擇一傳」的型別)。
行為可預測:同樣的 props 與狀態渲染出同樣的畫面, 沒有偷讀全域變數、沒有渲染中的副作用——這是第 1 章純函數模型的延伸。
至於「何時拆元件」,行數只是最粗糙的訊號。更可靠的三個拆分理由:
職責出現了邊界:一段 JSX 有自己的名字(「篩選列」「結果卡片」),抽出來後父元件讀起來像目錄。
狀態需要隔離:第 3 章講過,狀態放低影響範圍小。 高頻變動的輸入框抽成子元件,它的每一次按鍵就不會拖著整頁重算。
確實要復用。注意順序——復用是拆分的理由之一,不是唯一理由; 反過來,「還沒有第二個使用者」也不構成不拆的理由。
拆過頭同樣有成本:每多一層就多一次 props 轉手、多一個要命名的東西、 多一跳閱讀距離。拆分的收益是「每一塊變簡單」,代價是「塊與塊的接線變多」, 兩邊都要算。
Props 設計:從使用端往回設計
寫元件最容易犯的順序錯誤:先寫實作,props 是實作「剛好需要什麼就加什麼」的沉積物。 方向應該相反——先寫出你希望使用端長什麼樣子,再回頭實作它(跟先寫測試同一種思維)。
一個很快就會失控的真實模式,布林 props 的堆積:
// 每來一個需求就長一顆布林,排列組合無人能懂:
<Dialog isOpen showCloseButton hideFooter isCompact isDanger
centerTitle noPadding ... />
布林 props 的問題不只是醜:\(n\) 顆布林有 \(2^n\) 種組合, 其中大半是沒人測過、甚至語意矛盾的( 加 會怎樣?誰知道)。 兩個標準修法:
修法一:互斥的布林收斂成一個變體(variant)。 當幾顆布林其實在表達「同一個維度上的不同選項」, 用字面量聯集(literal union,第 6 章詳述)取代:
// size 只會是三者之一,不存在「同時 compact 又 large」的非法組合
interface DialogProps {
size?: 'compact' | 'default' | 'large';
tone?: 'neutral' | 'danger';
}
修法二:內容相關的設定改用組合(composition)。這是下一節的主題。
以領域語言命名,不以實作細節命名: 優於 。
回呼參數傳「領域資料」不傳整包內部狀態: 優於 ——洩漏越少,日後越好改。
相關的 props 一起變動時,考慮合成一個物件;獨立變動時保持分開。
預設值讓「最常見的用法」寫起來最短: 不帶任何 props 就該是最普通的對話框。
組合優於設定:children 與插槽
回到 的例子。需求持續演化:頁尾有時一顆按鈕、有時兩顆、 有時要放連結、有時整個不要……用「設定」的思路,你會不斷加 props (、、…), 元件變成一台越來越難操作的萬能機器。
「組合」的思路是:元件只負責結構與外框,把「洞」開出來讓使用端自己填。 React 給這個模式的原生支援就是 ,以及「接受 JSX 的 props」(俗稱插槽,slots):
interface DialogProps {
title: string;
footer?: React.ReactNode; // 插槽:整塊頁尾由使用端決定
children: React.ReactNode; // 主內容
}
function Dialog({ title, footer, children }: DialogProps) {
return (
<div className="dialog">
<header>{title}</header>
<main>{children}</main>
{footer !== undefined && <footer>{footer}</footer>}
</div>
);
}
// 使用端:頁尾要長怎樣是使用端的自由,Dialog 不需要知道
<Dialog title="刪除確認" footer={
<>
<Button tone="danger" onClick={confirm}>刪除</Button>
<Button onClick={close}>取消</Button>
</>
}>
確定要刪除這 3 個項目嗎?
</Dialog>
這樣設計之後,新需求不再需要修改 —— 它對「頁尾可能長什麼樣」保持開放,卻對自己的結構保持封閉。 公司 UI 元件庫裡 型別的 props 這麼多,就是這個原因。
組合還有一個常被忽略的妙用:解 prop drilling。 第 2 章說過中間層轉傳 props 的痛,除了 context,另一個解法是 在高處把元件組好,讓中間層只負責擺放:
// 中間層 Layout 完全不知道(也不需要知道)user 的存在
function Page() {
const user = useCurrentUser();
return <Layout sidebar={<UserPanel user={user} />} />;
}
function Layout({ sidebar }: { sidebar: React.ReactNode }) {
return <div className="layout"><aside>{sidebar}</aside><main>...</main></div>;
}
資料 從擁有者直接跳到使用者,中間層的介面乾乾淨淨。 遇到 prop drilling 時,先想「能不能在上面組好再傳下來」,再考慮 context。
受控與非受控:狀態主導權的設計決策
這組概念從表單來,但它其實是一個所有互動元件都要回答的通用問題: 「這個元件的狀態,由誰擁有?」
非受控(uncontrolled):元件自己管狀態,外界頂多給個初始值。 原生 就是:值存在 DOM 裡,React 不過問。
受控(controlled):狀態由外部擁有,元件只負責「顯示外部給的值+回報使用者的動作」。 :畫面上永遠顯示 , 使用者打字只會觸發 ,外部不更新狀態,畫面就不會變。
公司的表單元件幾乎清一色設計成受控,理由回到第 2 章的單一事實來源: 值住在 React 狀態裡,於是「即時驗證」「欄位聯動」(選了國家,城市清單跟著變) 「送出時收集所有值」「一鍵重設」全都是對狀態的普通操作; 值若散落在各個 DOM 節點裡,每個需求都要用 ref 去 DOM 裡撈。 代價是每個欄位都要接 / 的樣板碼—— 這正是 React Hook Form 這類函式庫要優化的點(它用非受控+ref 換效能, 表單很大時每次按鍵不會整張表單重新渲染——你看,又是第 3 章的取捨)。
設計自己的可互動元件(開關、分頁籤、日期選擇器)時,同一個決策會找上你。 通用建議:對外提供受控介面( + )為主, 因為受控元件可以被任何外部邏輯指揮,組合性最強; 行有餘力再補非受控模式()方便簡單場景。 成熟元件庫的「/ 雙介面」慣例就是這麼來的。
邏輯與畫面分離:hook 管邏輯,元件管長相
老 React 教材講「容器元件/展示元件」(container/presentational)分離; hooks 出現後,這個模式演化成更輕的形式: 把「這個功能怎麼運作」抽進自訂 hook,讓元件只剩「這個功能長什麼樣」。
// useUserSearch.ts —— 邏輯層:狀態、防抖、抓資料、競態防護
export function useUserSearch() {
const [keyword, setKeyword] = useState('');
const debounced = useDebounce(keyword, 300);
const { data: users, status } = useQuery(searchUsersQuery(debounced));
return { keyword, setKeyword, users: users ?? [], status };
}
// UserSearchPage.tsx —— 畫面層:讀起來只剩結構
function UserSearchPage() {
const { keyword, setKeyword, users, status } = useUserSearch();
return (
<>
<SearchInput value={keyword} onChange={setKeyword} />
{status === 'pending' ? <Spinner /> : <UserList users={users} />}
</>
);
}
收益具體可感:邏輯可以脫離畫面單獨測試(測 hook 不用管 CSS); 換畫面不動邏輯(桌面版、行動版共用同一個 hook); 以及閱讀的分層——追 bug 時,畫面錯找元件、行為錯找 hook,一半的程式碼可以直接跳過。 你在公司程式碼看到的 、 命名,多半就是這個模式。
Compound Components:一組元件共享隱式狀態
當一個元件的「內部結構」本身需要高度自由——最典型的是分頁籤(Tabs)—— 連插槽都不夠用了。設定式 API 會長成這樣:
// 設定式:結構被鎖死在 items 的形狀裡,想在籤上加個徽章都沒地方寫
<Tabs items={[
{ key: 'a', label: '總覽', content: <Overview /> },
{ key: 'b', label: '成員', content: <Members /> },
]} />
Compound components(複合元件)改成一組互相配合的元件, 外形交還給使用端,「目前選到哪個籤」這個共享狀態則由最外層元件用 context 隱式提供:
<Tabs defaultKey="a">
<Tabs.List>
<Tabs.Tab tabKey="a">總覽</Tabs.Tab>
<Tabs.Tab tabKey="b">成員 <Badge count={3} /></Tabs.Tab>
</Tabs.List>
<Tabs.Panel tabKey="a"><Overview /></Tabs.Panel>
<Tabs.Panel tabKey="b"><Members /></Tabs.Panel>
</Tabs>
// 實作骨架:Tabs 擁有狀態,透過 context 發給子元件
const TabsContext = createContext<{
active: string;
setActive: (key: string) => void;
} | null>(null);
function Tabs({ defaultKey, children }: TabsProps) {
const [active, setActive] = useState(defaultKey);
const value = useMemo(() => ({ active, setActive }), [active]);
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}
function Tab({ tabKey, children }: TabProps) {
const ctx = useTabsContext(); // 內部 hook,含 null 檢查
return (
<button
aria-selected={ctx.active === tabKey}
onClick={() => ctx.setActive(tabKey)}
>{children}</button>
);
}
你可以在這個骨架裡認出前面所有章節的零件: 狀態提升( 住在 )、context 跨層傳遞、 穩定 provider value、用 hook 封裝 context 存取。 Radix、Headless UI、Ant Design 等元件庫大量使用這個模式—— 讀懂它,那些元件庫的文件會突然變得眼熟。
讓不可能的狀態無法出現
元件設計的最後一塊拼圖與 TypeScript 銜接。看這個到處都是的寫法:
// 三顆獨立布林:2 x 2 x 2 = 8 種組合,合法的只有 4 種
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<User[] | null>(null);
// 「isLoading 且 isError」是什麼意思?忘記重設某一顆時就會出現。
三顆布林彼此其實不獨立——它們是同一台狀態機的碎片。 碎片化的代價:每條更新路徑都要記得維護所有碎片的一致性,漏一顆就進入非法狀態。 修法是把狀態合併成一個值,讓非法組合在型別上根本寫不出來:
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] } // data 只在成功時存在
| { status: 'error'; error: string }; // error 只在失敗時存在
const [state, setState] = useState<FetchState>({ status: 'idle' });
「載入中卻有錯誤」現在連寫都寫不出來; 而且 綁在 分支上, 「忘記檢查 loading 就用 data」會直接變成型別錯誤。 這個技術叫標記聯集(discriminated union),是第 6 章的重頭戲—— 設計章在這裡先給你動機:型別系統是元件設計的工具, 好的型別讓錯誤的用法無法通過編譯。
元件是 API:單一職責、介面誠實、行為可預測;拆分看職責邊界、狀態隔離與復用,不是行數。
props 從使用端往回設計;互斥布林收斂成 variant 聯集。
組合優於設定:children/插槽讓元件對變化開放、對修改封閉;在高處組好元件也能解 prop drilling。
受控/非受控是「狀態誰擁有」的決策;可重用元件以受控介面為主。
自訂 hook 裝邏輯、元件裝畫面;compound components 用 context 讓一組元件共享隱式狀態。
用單一狀態機取代一把布林,讓不可能的狀態無法表達——型別是設計工具。