React 設計模式:Compound Pattern(複合元件模式)

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 開發中,我們經常會遇到以下情況:

  1. 多個元件需要共享狀態:例如選項卡(Tabs)元件中,選項卡標題和內容需要同步
  2. 元件之間有緊密的邏輯關聯:如表單中的輸入框和錯誤提示
  3. 需要提供靈活的 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>
);

優點分析

  1. 隱藏內部狀態管理:複合元件能夠自行管理內部的狀態,使用者無需關心狀態如何共享

    // 使用者不需要管理 activeTab 狀態
    <Tabs>
      <Tab>First</Tab>
      <Tab>Second</Tab>
    </Tabs>
  2. 更直觀的 API:模仿原生 HTML 元素的組合方式,學習成本低

    // 類似於 HTML 的 select + option
    <Select>
      <Option value="1">One</Option>
      <Option value="2">Two</Option>
    </Select>
  3. 更高的靈活性:允許使用者自由組合子元件,同時保持行為一致

    <Modal>
      <Modal.Header>
        <CustomTitle />
      </Modal.Header>
      <Modal.Body>
        <CustomContent />
      </Modal.Body>
      <Modal.Footer>
        <CustomButtons />
      </Modal.Footer>
    </Modal>
  4. 更好的關注點分離:每個子元件只關注自己的渲染邏輯,父元件管理共享狀態

缺點與注意事項

  1. 深度巢狀問題

    • 使用 React.Children.mapReact.cloneElement 時,只有直接子元件能獲得 props
    • 解決方案:使用 Context API 來共享狀態
  2. Props 合併問題

    • React.cloneElement 進行的是淺合併(shallow merge)
    • 當 props 名稱衝突時,可能會意外覆蓋
    // 如果父元件在 clone 子元件時,傳遞了一個新的 onClick prop,
    // 這個新的 onClick 會覆蓋掉子元件原本自己寫的 onClick。
    React.cloneElement(child, { onClick: handleClick });
  3. 類型檢查困難

    • 難以確保子元件的類型和順序
    • 解決方案:使用靜態屬性或額外的類型檢查
    Tabs.Tab = Tab; // 靜態屬性
    // 然後可以這樣使用
    <Tabs>
      <Tabs.Tab>First</Tabs.Tab>
    </Tabs>;
  4. 過度設計風險

    • 對於簡單元件,使用 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>;

最佳實踐建議

  1. 合理選擇實現方式

    • 簡單場景:React.Children.map + React.cloneElement
    • 複雜場景:Context API
  2. 提供靈活的組合方式

    // 允許這樣使用
    <Card>
      <Card.Header />
      <Card.Body />
      <Card.Footer />
    </Card>
     
    // 也允許這樣使用
    <Card>
      <header>...</header>
      <main>...</main>
    </Card>
  3. 完善的文件和類型定義

    • 明確說明哪些子元件是必需的
    • 使用 PropTypes 或 TypeScript 定義元件接口
  4. 處理邊界情況

    • 檢查子元件類型
    • 提供默認行為
    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 中一種強大的設計模式,特別適合以下場景:

  1. 一組需要共享狀態的相關元件
  2. 想要提供類似原生 HTML 的組合 API
  3. 需要隱藏複雜的內部狀態管理

雖然它有一些限制和注意事項,但正確使用時可以大大提高元件的可重用性和開發者體驗。在現代 React 開發中,結合 Context API 的 Compound Pattern 實現方式尤為推薦,它提供了更好的靈活性和可維護性。

當你發現自己在創建多個緊密耦合的元件時,考慮使用 Compound Pattern 來組織它們,這將使你的程式碼更加清晰、更易於維護。