React 設計模式:Compound Pattern(複合元件模式)
什麼是 Compound Pattern?
Compound Pattern(複合元件模式)是一種 React 設計模式,它將兩個或更多個元件組合在一起,形成一個擁有特定功能或能夠完成特定任務的組合元件。在這種模式中,通常會有一個父元件(Parent)和多個子元件(Children),這些元件相互依賴,單獨存在時往往沒有實際意義。
基本概念
複合元件模式的核心思想是通過元件組合來共享狀態和行為,而不是通過 props 顯式傳遞。這使得元件之間的關係更加隱含但更為緊密。
類比 HTML 元素
最典型的例子就是 HTML 中的 <select>
和 <option>
元素:
<select>
<option value="volvo">Volvo</option>
<option value="mercedes">Mercedes</option>
<option value="audi">Audi</option>
</select>
<select>
單獨存在時沒有實際意義<option>
沒有被<select>
包裹時也無法發揮作用- 兩者組合才能形成完整的功能
為什麼需要 Compound Pattern?
在 React 開發中,我們經常會遇到以下情況:
- 多個元件需要共享狀態:例如選項卡(Tabs)元件中,選項卡標題和內容需要同步
- 元件之間有緊密的邏輯關聯:如表單中的輸入框和錯誤提示
- 需要提供靈活的 API 同時保持內部一致性:允許使用者自由組合,但確保行為一致
Compound Pattern 正是為了解決這些問題而產生的設計模式。
實現方式
1. 使用 React.Children 和 React.cloneElement
這是最傳統的實現方式:
import React from "react";
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = React.useState(0);
return (
<div className="tabs">
{React.Children.map(children, (child, index) =>
React.cloneElement(child, {
isActive: index === activeTab,
onSelect: () => setActiveTab(index),
})
)}
</div>
);
};
const Tab = ({ isActive, onSelect, children }) => (
<button className={`tab ${isActive ? "active" : ""}`} onClick={onSelect}>
{children}
</button>
);
// 使用方式
const App = () => (
<Tabs>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tabs>
);
2. 使用 Context API
更現代的實現方式是結合 Context API:
import React, { createContext, useContext, useState } from "react";
const TabContext = createContext();
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState(0);
return (
<TabContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabContext.Provider>
);
};
const Tab = ({ index, children }) => {
const { activeTab, setActiveTab } = useContext(TabContext);
return (
<button
className={`tab ${index === activeTab ? "active" : ""}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
};
// 使用方式
const App = () => (
<Tabs>
<Tab index={0}>Tab 1</Tab>
<Tab index={1}>Tab 2</Tab>
<Tab index={2}>Tab 3</Tab>
</Tabs>
);
實際案例
1. 可展開/折疊的 Accordion 元件
import React, { createContext, useContext, useState } from "react";
const AccordionContext = createContext();
const Accordion = ({ children }) => {
const [activeIndex, setActiveIndex] = useState(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
};
const AccordionItem = ({ index, children }) => {
const { activeIndex, setActiveIndex } = useContext(AccordionContext);
const isOpen = index === activeIndex;
return (
<div className="accordion-item">
<div
className="accordion-header"
onClick={() => setActiveIndex(isOpen ? null : index)}
>
{children[0]}
</div>
{isOpen && <div className="accordion-content">{children[1]}</div>}
</div>
);
};
// 使用方式
const App = () => (
<Accordion>
<AccordionItem index={0}>
<h3>Section 1</h3>
<p>Content for section 1</p>
</AccordionItem>
<AccordionItem index={1}>
<h3>Section 2</h3>
<p>Content for section 2</p>
</AccordionItem>
</Accordion>
);
2. 表單字段元件
import React, { createContext, useContext, useState } from "react";
const FormFieldContext = createContext();
const FormField = ({ children }) => {
const [value, setValue] = useState("");
const [error, setError] = useState("");
return (
<FormFieldContext.Provider value={{ value, setValue, error, setError }}>
<div className="form-field">{children}</div>
</FormFieldContext.Provider>
);
};
const Label = ({ children }) => {
return <label className="form-label">{children}</label>;
};
const Input = () => {
const { value, setValue, error } = useContext(FormFieldContext);
return (
<>
<input
className={`form-input ${error ? "error" : ""}`}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{error && <span className="error-message">{error}</span>}
</>
);
};
const ErrorMessage = ({ message }) => {
const { setError } = useContext(FormFieldContext);
React.useEffect(() => {
setError(message);
}, [message, setError]);
return null;
};
// 使用方式
const App = () => (
<FormField>
<Label>Email Address</Label>
<Input />
<ErrorMessage message="Please enter a valid email" />
</FormField>
);
優點分析
-
隱藏內部狀態管理:複合元件能夠自行管理內部的狀態,使用者無需關心狀態如何共享
// 使用者不需要管理 activeTab 狀態 <Tabs> <Tab>First</Tab> <Tab>Second</Tab> </Tabs>
-
更直觀的 API:模仿原生 HTML 元素的組合方式,學習成本低
// 類似於 HTML 的 select + option <Select> <Option value="1">One</Option> <Option value="2">Two</Option> </Select>
-
更高的靈活性:允許使用者自由組合子元件,同時保持行為一致
<Modal> <Modal.Header> <CustomTitle /> </Modal.Header> <Modal.Body> <CustomContent /> </Modal.Body> <Modal.Footer> <CustomButtons /> </Modal.Footer> </Modal>
-
更好的關注點分離:每個子元件只關注自己的渲染邏輯,父元件管理共享狀態
缺點與注意事項
-
深度巢狀問題:
- 使用
React.Children.map
和React.cloneElement
時,只有直接子元件能獲得 props - 解決方案:使用 Context API 來共享狀態
- 使用
-
Props 合併問題:
React.cloneElement
進行的是淺合併(shallow merge)- 當 props 名稱衝突時,可能會意外覆蓋
// 如果父元件在 clone 子元件時,傳遞了一個新的 onClick prop, // 這個新的 onClick 會覆蓋掉子元件原本自己寫的 onClick。 React.cloneElement(child, { onClick: handleClick });
-
類型檢查困難:
- 難以確保子元件的類型和順序
- 解決方案:使用靜態屬性或額外的類型檢查
Tabs.Tab = Tab; // 靜態屬性 // 然後可以這樣使用 <Tabs> <Tabs.Tab>First</Tabs.Tab> </Tabs>;
-
過度設計風險:
- 對於簡單元件,使用 Compound Pattern 可能會增加不必要的複雜度
實際應用案例
Ant Design 中的 List 元件
import { List } from "antd";
<List
itemLayout="horizontal"
dataSource={data}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
title={<a href="https://ant.design">{item.title}</a>}
description={item.description}
/>
</List.Item>
)}
/>;
Ant Design 中的 Menu 元件 (v4.24.16)
import { Menu } from "antd";
<Menu mode="horizontal">
<Menu.Item key="mail">Navigation One</Menu.Item>
<Menu.SubMenu title="Navigation Two">
<Menu.Item key="setting:1">Option 1</Menu.Item>
<Menu.Item key="setting:2">Option 2</Menu.Item>
</Menu.SubMenu>
</Menu>;
最佳實踐建議
-
合理選擇實現方式:
- 簡單場景:
React.Children.map
+React.cloneElement
- 複雜場景:Context API
- 簡單場景:
-
提供靈活的組合方式:
// 允許這樣使用 <Card> <Card.Header /> <Card.Body /> <Card.Footer /> </Card> // 也允許這樣使用 <Card> <header>...</header> <main>...</main> </Card>
-
完善的文件和類型定義:
- 明確說明哪些子元件是必需的
- 使用 PropTypes 或 TypeScript 定義元件接口
-
處理邊界情況:
- 檢查子元件類型
- 提供默認行為
const TabList = ({ children }) => { const validChildren = React.Children.toArray(children).filter( (child) => child.type === Tab ); if (validChildren.length === 0) { return <div>No valid tabs provided</div>; } // ...其他邏輯 };
與其他模式的對比
與 Render Props 模式對比
// Render Props 實現 Tabs
<Tabs
render={({ activeTab, setActiveTab }) => (
<div>
<button onClick={() => setActiveTab(0)}>Tab 1</button>
{activeTab === 0 && <div>Content 1</div>}
</div>
)}
/>
// Compound 實現 Tabs
<Tabs>
<Tab>Tab 1</Tab>
<TabPanel>Content 1</TabPanel>
</Tabs>
與 HOC 模式對比
// HOC 實現表單字段
const EnhancedField = withFormField(Input);
// Compound 實現表單字段
<FormField>
<Label>Email</Label>
<Input />
</FormField>;
結論
Compound Pattern 是 React 中一種強大的設計模式,特別適合以下場景:
- 一組需要共享狀態的相關元件
- 想要提供類似原生 HTML 的組合 API
- 需要隱藏複雜的內部狀態管理
雖然它有一些限制和注意事項,但正確使用時可以大大提高元件的可重用性和開發者體驗。在現代 React 開發中,結合 Context API 的 Compound Pattern 實現方式尤為推薦,它提供了更好的靈活性和可維護性。
當你發現自己在創建多個緊密耦合的元件時,考慮使用 Compound Pattern 來組織它們,這將使你的程式碼更加清晰、更易於維護。