第 13 章:組合 vs 繼承

13.1 React 更傾向於組合而非繼承

在 React 的生態系統中,組合(Composition) 被視為構建使用者介面和重用程式碼的基石,其重要性遠超傳統物件導向程式設計中的 繼承(Inheritance)。Facebook 的官方文件以及廣大的 React 社群都明確表示,在 React 元件的開發實踐中,組合幾乎能滿足所有需求,而需要使用繼承的場景則極為罕見。

這種設計哲學的轉變並非偶然,而是基於以下幾個核心考量:

  • 極致的彈性與可重用性 (Ultimate Flexibility & Reusability): 組合允許開發者像搭建樂高積木一樣,將小型、獨立且功能單一的元件(Building Blocks)拼裝成更大型、更複雜的 UI 結構。這種模式的彈性體現在:

    • 自由混搭:你可以輕易地替換或重新排列元件的某一部分,而不用擔心影響其他不相關的部分。
    • 行為客製化:透過傳遞不同的 props (包括資料、函式甚至其他 React 元素),可以輕鬆改變元件的行為和外觀,而無需修改元件本身的原始碼。
    • 關注點分離 (Separation of Concerns):每個元件只需專注於自身的核心職責。例如,一個 Button 元件只關心按鈕的樣式和點擊行為,一個 Avatar 元件只關心顯示使用者頭像。它們可以被用在應用程式的任何地方。
  • 清晰的資料流與明確的契約 (Clear Data Flow & Explicit Contracts): 在組合模型中,元件之間的互動主要透過 props 進行。父元件將資料和函式作為 props 傳遞給子元件,形成了一個自上而下的單向資料流。這種方式使得:

    • Props 即 API:每個元件的 props 列表就像是它的公開 API,清晰地定義了該元件接受什麼輸入以及可以執行什麼操作。
    • 可追溯性:資料的來源和去向一目了然,方便追蹤和偵錯。相較之下,繼承可能會透過 this 關鍵字隱晦地共享或覆寫父類別的屬性和方法,使得資料流變得模糊。
  • 避免深層繼承鏈的固有弊病 (Avoiding Pitfalls of Deep Inheritance Chains): 傳統的類別繼承,尤其是在多層繼承的情況下,容易引入一系列問題:

    • 脆弱的基底類別問題 (Fragile Base Class Problem):基底類別的微小改動都可能無預期地破壞所有子類別的行為。
    • 命名衝突與意外覆寫:子類別可能會不小心覆寫父類別的方法或屬性,或者父類別新增的方法與子類別現有方法衝突。
    • "猩猩/香蕉問題" (Gorilla/Banana Problem):你想要一個香蕉,但得到的卻是一個拿著香蕉的猩猩,以及猩猩所處的整片叢林。即你可能只需要父類別的一小部分功能,卻被迫繼承了整個龐大的父類別。 組合則透過顯式地傳入所需的功能或資料,避免了這些問題。
  • 與 React 的聲明式編程範式契合 (Alignment with React's Declarative Paradigm): React 鼓勵開發者以聲明式的方式描述 UI 的最終狀態,而不是命令式地一步步操作 DOM。組合正是這種聲明式思想的體現:你聲明你的 UI 由哪些更小的部分組成的,而不是詳細描述 如何 去構建它。這也與函數式編程中函式組合的思想不謀而合,許多 React 元件(尤其是函式元件)可以被看作是接受 props 並返回 UI 描述的純函式。

雖然 JavaScript 語言本身支援類別和原型繼承,但在 React 元件的世界裡,你會發現組合通常是更簡潔、更安全、更強大的選擇。它使得元件更易於理解、測試、重用和維護。

13.2 使用組合的方式

React 提供了多種靈活的方式來實現元件組合。核心思想是將元件作為可配置的"容器"或"行為包裝器",透過 props 傳遞內容和邏輯。

1. 包含關係 (Containment) - props.children 與自訂 Props

這是最基本也是最常見的組合方式。一個元件在其 JSX 輸出中可以包含其他由父層傳遞進來的子元件。

a. 通用容器與 props.children

許多元件被設計為通用容器,它們事先不知道其子元件的具體內容。這時,特殊的 props.children 就派上了用場。父元件放置在容器元件標籤內部的任何 JSX 內容,都會作為 props.children 傳遞給容器元件。

// FancyBorder.js - 一個為其子內容添加花俏邊框的容器
function FancyBorder(props) {
  return (
    <div className={'FancyBorder FancyBorder-' + props.color} style={{border: `2px solid ${props.color || 'gray'}`, padding: '10px', margin: '5px'}}>
      {/* props.children 會渲染父元件傳遞進來的所有內容 */}
      {props.children}
    </div>
  );
}

// Usage:
function WelcomeDialog() {
  return (
    <FancyBorder color="blue">
      {/* 以下內容會作為 props.children 傳遞給 FancyBorder */}
      <h1 className="Dialog-title">
        歡迎光臨!
      </h1>
      <p className="Dialog-message">
        感謝您抽出時間瀏覽我們的內容。
      </p>
      <button onClick={() => alert('互動一下!')}>點我</button>
    </FancyBorder>
  );
}

// Modal.js - 一個更複雜的模態框容器範例
function Modal({ isOpen, onClose, title, children }) {
  if (!isOpen) {
    return null;
  }

  return (
    <div className="modal-overlay" style={{position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center'}}>
      <div className="modal-content" style={{background: 'white', padding: '20px', borderRadius: '5px'}}>
        <div className="modal-header">
          <h2>{title || '提示'}</h2>
          <button onClick={onClose} style={{float: 'right'}}>X</button>
        </div>
        <div className="modal-body">
          {children} {/* Modal 的主體內容由父元件決定 */}
        </div>
      </div>
    </div>
  );
}

// Usage of Modal:
function AppWithModal() {
  const [isModalOpen, setIsModalOpen] = React.useState(false);
  return (
    <div>
      <button onClick={() => setIsModalOpen(true)}>打開登入框</button>
      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="使用者登入">
        <form>
          <label>用戶名: <input type="text" /></label><br />
          <label>密碼: <input type="password" /></label><br />
          <button type="submit">登入</button>
        </form>
      </Modal>
    </div>
  );
}

b. 特定插槽 (Named Slots) - 使用自訂 Props 傳遞 React 元素

有時,一個元件可能有多個"插槽"來放置不同的子內容。在這種情況下,可以不依賴 props.children,而是約定使用不同名稱的 props 來傳遞 React 元素。

// SplitPane.js - 將螢幕分割成左右兩欄
function SplitPane(props) {
  return (
    <div className="SplitPane" style={{display: 'flex'}}>
      <div className="SplitPane-left" style={{flex: 1, marginRight: '5px', border: '1px solid lightgray', padding: '10px'}}>
        {props.leftContent} {/* 左側插槽 */}
      </div>
      <div className="SplitPane-right" style={{flex: 1, marginLeft: '5px', border: '1px solid lightgray', padding: '10px'}}>
        {props.rightContent} {/* 右側插槽 */}
      </div>
    </div>
  );
}

// DashboardLayout.js - 具有 header, sidebar, main content 的佈局
function DashboardLayout(props) {
  return (
    <div className="dashboard-layout">
      <header className="dashboard-header" style={{background: '#f0f0f0', padding: '10px', marginBottom: '10px'}}>
        {props.header}
      </header>
      <div className="dashboard-body" style={{display: 'flex'}}>
        <aside className="dashboard-sidebar" style={{width: '200px', marginRight: '10px', background: '#e0e0e0', padding: '10px'}}>
          {props.sidebar}
        </aside>
        <main className="dashboard-main-content" style={{flex: 1, background: '#fafafa', padding: '10px'}}>
          {props.main}
        </main>
      </div>
    </div>
  );
}

// Usage:
function MyApp() {
  const contactList = <ul><li>聯絡人1</li><li>聯絡人2</li></ul>;
  const chatWindow = <div>聊天內容...</div>;

  const appHeader = <h1>我的應用程式</h1>;
  const appSidebar = <nav><ul><li>選單1</li><li>選單2</li></ul></nav>;
  const appMainContent = <p>這是主要內容區域。</p>;

  return (
    <div>
      <SplitPane leftContent={contactList} rightContent={chatWindow} />
      <hr style={{margin: '20px 0'}}/>
      <DashboardLayout header={appHeader} sidebar={appSidebar} main={appMainContent} />
    </div>
  );
}

這種方式使得元件的結構更加語義化,父元件可以精確控制每個插槽的內容。

2. 特殊化 (Specialization) - 通用元件的客製化實例

有時候,我們會將一個元件視為另一個更通用元件的"特殊版本"。例如,ErrorDialogSuccessDialog 都可能是通用 Dialog 元件的特殊化實例。在 React 中,這依然是透過組合來實現的:更"特定"的元件在其內部渲染更"通用"的元件,並透過 props 來配置其外觀和行為。

// Generic Dialog.js
function Dialog(props) {
  let dialogTypeClass = '';
  if (props.type === 'error') dialogTypeClass = 'Dialog-error';
  else if (props.type === 'success') dialogTypeClass = 'Dialog-success';

  const dialogStyle = {
    border: `2px solid ${props.type === 'error' ? 'red' : (props.type === 'success' ? 'green' : 'gray')}`,
    padding: '15px',
    margin: '10px',
    borderRadius: '5px',
    backgroundColor: props.type === 'error' ? '#ffe0e0' : (props.type === 'success' ? '#e0ffe0' : '#f9f9f9'),
  };

  return (
    <div className={`Dialog ${dialogTypeClass}`} style={dialogStyle}>
      <h2 className="Dialog-title" style={{marginTop: 0}}>{props.title}</h2>
      <p className="Dialog-message">{props.message}</p>
      {props.children} {/* 允許 Dialog 內部再包含其他內容 */}
    </div>
  );
}

// Specialization: WelcomeDialog
function WelcomeDialog() {
  // WelcomeDialog 是一個 Dialog,但有預設的 title 和 message
  return (
    <Dialog
      title="歡迎"
      message="感謝您的到來!"
    />
  );
}

// Specialization: ErrorDialog
function ErrorDialog(props) {
  return (
    <Dialog
      type="error" // 特殊化:指定類型為 error
      title={props.title || "發生錯誤"}
      message={props.message}
    >
      {props.details && <pre style={{background: '#ffcccc', padding: '5px'}}>{props.details}</pre>}
    </Dialog>
  );
}

// Specialization: ConfirmationDialog with specific children and logic
function ConfirmationDialog({ title, message, onConfirm, onCancel }) {
  return (
    <Dialog title={title || "請確認"} message={message}>
      <div style={{marginTop: '10px'}}>
        <button onClick={onConfirm} style={{marginRight: '5px', background: 'green', color: 'white'}}>確認</button>
        <button onClick={onCancel} style={{background: 'red', color: 'white'}}>取消</button>
      </div>
    </Dialog>
  );
}

// Usage:
function AppWithSpecialDialogs() {
  return (
    <div>
      <WelcomeDialog />
      <ErrorDialog message="無法載入使用者資料。" details="Network timeout on API call." />
      <ConfirmationDialog 
        message="您確定要刪除這篇文章嗎?此操作無法復原。"
        onConfirm={() => alert('已確認刪除!')}
        onCancel={() => alert('已取消操作。')}
      />
    </div>
  );
}

在這個例子中,WelcomeDialogErrorDialog 都是 Dialog 的"特殊化"版本。它們內部渲染了 Dialog 元件,並傳遞了特定的 props 來改變其外觀(如 type)和內容。ConfirmationDialog 則更進一步,不僅配置了 Dialog,還在其 children 位置加入了確認和取消按鈕,並處理了相應的邏輯。

這種模式的優點是,通用元件 (Dialog) 的邏輯是封裝和可重用的,而特殊化元件則在此基礎上進行客製化,保持了程式碼的 DRY (Don't Repeat Yourself)。

3. 透過 Props 傳遞渲染邏輯 (Render Props & Function-as-Child)

這是一種更進階的組合模式,父元件不直接渲染子內容,而是將渲染子內容的權力交給父元件的使用者。父元件提供一些狀態或功能,然後呼叫一個由父元件使用者提供的函式 prop (通常稱為 render prop,或者直接將函式作為 children prop 傳遞,即 Function-as-Child),並將內部狀態或功能作為參數傳給這個函式,由這個函式來決定最終渲染什麼。

這種模式的核心思想是將元件的行為 (stateful logic) 與其渲染關注點 (rendering concern) 分離

a. Render Prop 範例 (render Prop)

// MouseTracker.js - 追蹤滑鼠位置並透過 render prop 提供位置資訊
class MouseTracker extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '50vh', border: '1px dashed blue', position: 'relative' }} onMouseMove={this.handleMouseMove}>
        {/* 調用父元件傳入的 render prop 函式,並將 state 作為參數傳遞 */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

// Cat.js - 一個簡單的圖片元件,會根據傳入的 mouse object 移動
function Cat(props) {
  const { mouse } = props;
  if (!mouse) return null;
  return (
    <img src="https://placekitten.com/50/50" alt="Cat" style={{ position: 'absolute', left: mouse.x - 25, top: mouse.y - 25 }} />
  );
}

// Usage of MouseTracker with a render prop:
function AppWithMouseTracker() {
  return (
    <div>
      <h1>將滑鼠移到藍色虛線框內!</h1>
      {/* MouseTracker 將其 state (滑鼠位置) 傳遞給 render prop 提供的函式 */}
      <MouseTracker render={mouseCoords => (
        <div>
          <p>當前滑鼠位置: ({mouseCoords.x}, {mouseCoords.y})</p>
          <Cat mouse={mouseCoords} /> {/* Cat 元件使用 MouseTracker 提供的滑鼠位置 */}
        </div>
      )}/>
    </div>
  );
}

AppWithMouseTracker 中,MouseTracker 元件並不關心具體要渲染什麼內容來顯示滑鼠位置,它只負責追蹤位置並將這個位置資訊 mouseCoords 傳遞給 render prop 指定的函式。真正決定如何渲染的是傳遞給 render prop 的那個匿名函式。

b. Function-as-Child (將函式作為 props.children)

Render Props 模式的一種常見變體是將渲染函式作為 props.children 傳遞。

// DataFetcher.js - 模擬從 API 獲取資料
class DataFetcher extends React.Component {
  constructor(props) {
    super(props);
    this.state = { data: null, loading: true, error: null };
  }

  componentDidMount() {
    this.setState({ loading: true, error: null });
    fetch(this.props.url)
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
      .then(data => this.setState({ data, loading: false }))
      .catch(error => this.setState({ error: error.message, loading: false }));
  }

  render() {
    // 將 state 作為參數,調用 props.children (它是一個函式)
    return this.props.children(this.state);
  }
}

// Usage with function-as-child:
function UserProfile({ userId }) {
  return (
    <DataFetcher url={`https://jsonplaceholder.typicode.com/users/${userId}`}>
      {/* props.children 是一個函式,接收 DataFetcher 的 state */}
      {({ data, loading, error }) => {
        if (loading) return <p>正在載入使用者資料...</p>;
        if (error) return <p>錯誤: {error}</p>;
        if (!data) return <p>找不到使用者資料。</p>;

        return (
          <div>
            <h1>{data.name}</h1>
            <p>Email: {data.email}</p>
            <p>Phone: {data.phone}</p>
          </div>
        );
      }}
    </DataFetcher>
  );
}

function AppWithDataFetcher() {
  return <UserProfile userId={1} />;
}

DataFetcher 封裝了資料獲取的邏輯(包括載入中和錯誤狀態),然後透過 props.children(this.state) 將這些狀態暴露出來,讓使用者(如 UserProfile)可以完全自訂如何根據這些狀態來渲染 UI。

Render Props 和 Function-as-Child 模式非常強大,它們允許你在不建立額外元件層級或使用繼承的情況下,共享有狀態的邏輯。

4. 高階元件 (Higher-Order Components - HOCs)

高階元件 (HOC) 是一種進階的 React 技術,用於重用元件邏輯。HOC 本身不是 React API 的一部分,而是一種由 React 自身的組合性質演化出來的模式。

簡單來說,高階元件是一個函式,它接收一個元件作為參數,並返回一個新的、增強過的元件。

// 這是一個 HOC 的簽名
const EnhancedComponent = higherOrderComponent(WrappedComponent);

HOC 可以用來:

  • 注入 props
  • 抽象化 state 和生命週期方法
  • 進行渲染劫持(不常用且需謹慎)
  • ...以及其他橫切關注點 (cross-cutting concerns) 的邏輯。
// withLoadingIndicator.js - 一個 HOC,為被包裝的元件添加載入指示器
function withLoadingIndicator(WrappedComponent, loadingMessage = "正在載入...") {
  // 返回一個新的函式元件 (或類別元件)
  return function ComponentWithLoadingIndicator(props) {
    const { isLoading, ...passThroughProps } = props;

    if (isLoading) {
      return <p>{loadingMessage}</p>;
    }

    // 如果沒有在載入,則渲染被包裝的元件,並傳遞其餘的 props
    return <WrappedComponent {...passThroughProps} />;
  };
}

// MyDataDisplay.js - 一個簡單的資料顯示元件
function MyDataDisplay({ data }) {
  return (
    <ul>
      {data.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

// 使用 HOC 包裝 MyDataDisplay
const MyDataDisplayWithLoading = withLoadingIndicator(MyDataDisplay, "資料努力載入中,請稍候...");

// AppWithHOC.js
class AppWithHOC extends React.Component {
  constructor(props) {
    super(props);
    this.state = { items: [], loading: true };
  }

  componentDidMount() {
    setTimeout(() => { // 模擬 API 呼叫
      this.setState({
        items: [{id: 1, name: "項目A"}, {id: 2, name: "項目B"}],
        loading: false
      });
    }, 2000);
  }

  render() {
    return (
      <div>
        <h1>使用 HOC 的資料列表</h1>
        {/* isLoading prop 會被 HOC 攔截並處理 */}
        <MyDataDisplayWithLoading isLoading={this.state.loading} data={this.state.items} />
      </div>
    );
  }
}

在上面的例子中,withLoadingIndicator 是一個 HOC。它接收 MyDataDisplay 元件,並返回一個新的元件 MyDataDisplayWithLoading。這個新元件會檢查 isLoading prop:如果為 true,則顯示載入訊息;否則,渲染原始的 MyDataDisplay 元件。

HOC 的注意事項:

  • 不要在 render 方法中使用 HOC:這會導致每次渲染都建立一個新的元件,使得子樹被卸載/重新掛載,並丟失 state。
  • 靜態方法需要手動複製:HOC 返回的是一個新元件,它不會自動繼承原始元件的靜態方法。
  • Refs 不會被透傳:如果需要存取被包裝元件的 ref,需要使用 React.forwardRef
  • Props 命名衝突:HOC 注入的 prop 可能會與被包裝元件自身的 prop 衝突。

雖然 React Hooks (特別是自訂 Hooks) 的出現,使得許多 HOC 的使用場景可以被更簡潔地替代,但 HOC 仍然是理解 React 組合模式和重用邏輯的一個重要概念。

組合的這些方式——包含、特殊化、Render Props 和 HOCs——共同構成了 React 強大而靈活的元件模型,使得開發者能夠以聲明式和模組化的方式建構複雜的 UI。

13.3 為什麼繼承在 React 元件中不常用且通常不被推薦

儘管 JavaScript 是一門支援類別繼承的語言,並且 React 元件可以用類別來定義,但在 React 的實踐中,依賴繼承來重用元件間的程式碼是非常罕見的,並且通常被認為是一種反模式 (anti-pattern)。React 團隊和社群更推崇使用組合。

以下是繼承在 React 元件中不受青睞的主要原因:

  • Props 和組合提供了更優越的客製化和重用機制: React 的核心是透過 props 將資料和行為從父元件傳遞到子元件。這種機制本身就非常強大和靈活。你可以傳遞:

    • 簡單資料類型 (字串、數字、布林值)
    • 複雜資料結構 (物件、陣列)
    • 函式 (用於回呼和行為注入)
    • 其他的 React 元素或元件 (實現內容的嵌入和替換) 組合模式(如上一節所述)幾乎可以涵蓋所有需要在元件間共享程式碼或行為的場景,而且通常比繼承更清晰、更直接。
  • 脆弱的基底類別問題 (Fragile Base Class Problem) 在 UI 元件中尤為突出: UI 元件的內部結構和渲染邏輯(render 方法)往往比較複雜且容易變動。如果一個子元件繼承自一個基底元件,它可能會隱性地依賴於基底元件 render 方法的內部結構、特定的 DOM 結構、CSS 類別名稱,甚至是生命週期方法的執行順序或內部狀態的實現細節。

    • 範例:假設一個 BaseModal 元件在其 render 方法中定義了一個帶有特定 CSS 類別 base-modal-header 的標頭。一個繼承它的 CustomModal 可能會透過 CSS 或 JavaScript 來選取或修改這個標頭。如果未來 BaseModal 的維護者決定重構標頭,改變了 CSS 類別或 DOM 結構,那麼 CustomModal 就會無預期地壞掉,即使 BaseModal 的公開 API(props)沒有改變。 組合則透過明確的 props 介面來降低這種耦合。如果 BaseModal 想讓子元件客製化標頭,它應該提供一個如 renderHeader 的 prop,而不是讓子元件去猜測或依賴其內部實現。
  • Props 的來源和作用變得模糊不清 (Props Obscurity): 當使用繼承時,子元件會自動繼承父類別的所有 props(以及父類別繼承來的 props)。在一個深層的繼承鏈中,很難一眼看出傳遞給最底層元件的某個 prop 到底是從哪一層繼承下來的,以及它最初的用途是什麼。這會導致:

    • Prop 命名衝突:子類別可能無意中定義了與父類別中某個 prop 同名的 prop,導致行為混亂。
    • 難以理解元件的 API:要完全理解一個子類別元件的行為,你可能需要追溯其所有祖先類別的 props 和方法。 組合則讓每個元件的 props 都清晰可見,直接在其使用處聲明。
  • 建構函式鍊的複雜性與 super(props) 的陷阱: 在 JavaScript 的類別繼承中,子類別的建構函式必須呼叫 super(props),並且在使用 this 之前呼叫。雖然這是一個標準的 JS 機制,但在 React 元件的上下文中,多層繼承會導致建構函式鍊變長,容易遺漏 super(props) 或在錯誤的地方使用 this,尤其是在處理 props 和 state 初始化時。

    // 繼承鏈中的建構函式
    class Grandparent extends React.Component {
      constructor(props) { super(props); /* ... */ }
    }
    class Parent extends Grandparent {
      constructor(props) { super(props); /* ... */ } // 容易忘記或出錯
    }
    class Child extends Parent {
      constructor(props) { super(props); this.state = { childState: props.initialChildState }; }
    }
    
  • 難以在元件間共享非 UI 的邏輯 (Difficulty Sharing Non-UI Logic): 繼承主要關聯的是類別的層級結構。如果你想在多個不一定有父子層級關係的元件之間共享某些行為或狀態邏輯(例如,資料訂閱、事件處理、格式化函式),繼承並不是一個好的選擇。

    • 更好的方案:React 提供了 Hooks (如 useState, useEffect, 以及自訂 Hooks) 作為共享有狀態邏輯的主要方式。對於更通用的工具函式,可以直接提取到獨立的 JavaScript 模組中,然後在需要的元件中導入使用。這些都與組合的思想更為一致。
  • 違反 React 的"元件即函式"心智模型: 隨著 Hooks 的普及,函式元件已成為主流。將元件視為"接收 props,返回 UI 描述的函式"的心智模型非常簡潔和強大。繼承則天然地與類別模型綁定,使得這種心智模型變得不那麼純粹。

  • "Is-A" vs "Has-A"/"Uses-A" 關係的思考: 物件導向設計中常討論「Is-A」(繼承)和「Has-A」(組合)的關係。在 React 元件中,更多時候是「一個元件擁有一個子元件」(SplitPane 一個 leftContentrightContent),或者「一個元件使用了某種行為」(MyDataDisplayWithLoading 使用withLoadingIndicator 提供的載入邏輯)。很少有情況是「一個元件嚴格地另一個元件的一種特殊類型,並且需要繼承其所有非私有實現」。

一個(不推薦的)繼承範例及其組合式替代方案:

假設我們想建立不同主題的按鈕。

// === 不推薦的繼承方式 ===
class BaseStyledButton extends React.Component {
  constructor(props) {
    super(props);
    // 基底樣式,可能還會有基底行為
    this.baseStyle = {
      padding: '10px 15px',
      margin: '5px',
      border: '1px solid #ccc',
      borderRadius: '4px',
      cursor: 'pointer',
    };
    this.logClick = this.logClick.bind(this);
  }

  logClick() {
    console.log(`Button '${this.props.children}' clicked.`);
  }

  // 基底渲染邏輯,子類別可能想覆寫或擴展它
  renderContent() {
    return this.props.children;
  }

  render() {
    // 如果子類別想改變 onClick 行為,或 style 計算方式,就會很棘手
    return (
      <button 
        style={{ ...this.baseStyle, ...this.props.style }} 
        onClick={() => { this.logClick(); if (this.props.onClick) this.props.onClick(); }}
      >
        {this.renderContent()}
      </button>
    );
  }
}

class PrimaryThemeButton extends BaseStyledButton {
  constructor(props) {
    super(props);
    // 特殊化樣式
    this.themeStyle = {
      backgroundColor: '#007bff',
      color: 'white',
      borderColor: '#007bff',
    };
  }

  // 覆寫或擴展樣式
  render() {
    // 必須小心地與父類的 render 邏輯結合,很容易出錯
    // 而且如果 BaseStyledButton 的 render 變了,這裡可能也得跟著變
    return (
      <button 
        style={{ ...this.baseStyle, ...this.themeStyle, ...this.props.style }}
        onClick={() => { this.logClick(); if (this.props.onClick) this.props.onClick(); }}
      >
        {this.renderContent()} {this.props.isPrimary && <span role="img" aria-label="primary"></span>}
      </button>
    );
  }
}

// === 推薦的組合方式 ===
function Button({ children, onClick, style, variant, icon, ...restProps }) {
  const baseStyle = {
    padding: '10px 15px',
    margin: '5px',
    border: '1px solid #ccc',
    borderRadius: '4px',
    cursor: 'pointer',
  };

  let variantStyle = {};
  if (variant === 'primary') {
    variantStyle = {
      backgroundColor: '#007bff',
      color: 'white',
      borderColor: '#007bff',
    };
  } else if (variant === 'secondary') {
    variantStyle = {
      backgroundColor: '#6c757d',
      color: 'white',
      borderColor: '#6c757d',
    };
  }
  // 可以添加更多 variant

  const handleClick = (event) => {
    console.log(`Button '${children}' clicked.`);
    if (onClick) {
      onClick(event);
    }
  };

  return (
    <button 
      style={{ ...baseStyle, ...variantStyle, ...style }} 
      onClick={handleClick}
      {...restProps} // 允許傳遞其他標準 button 屬性
    >
      {icon && <span style={{ marginRight: '5px' }}>{icon}</span>}
      {children}
    </button>
  );
}

// 使用組合方式
function AppWithButtons() {
  return (
    <div>
      <p>繼承方式的按鈕 (不推薦):</p>
      <BaseStyledButton onClick={() => console.log('Base clicked')}>基本按鈕</BaseStyledButton>
      <PrimaryThemeButton onClick={() => console.log('Primary Theme clicked')} isPrimary>主要主題按鈕</PrimaryThemeButton>

      <hr />

      <p>組合方式的按鈕 (推薦):</p>
      <Button onClick={() => console.log('Default clicked')}>預設按鈕</Button>
      <Button variant="primary" onClick={() => console.log('Primary clicked')} icon="⭐">
        主要按鈕
      </Button>
      <Button variant="secondary" style={{fontWeight: 'bold'}} onClick={() => console.log('Secondary clicked')}>
        次要按鈕 (自訂樣式)
      </Button>
      <Button onClick={() => console.log('Icon button clicked')} icon="🚀">
        帶圖示的按鈕
      </Button>
    </div>
  );
}

在組合的例子中,Button 元件透過 props (variant, icon, style, children) 接收所有客製化選項。它不依賴任何繼承來的隱性行為。如果需要新的按鈕類型,通常是透過增加新的 variant 邏輯或組合其他元件,而不是建立新的子類別。這種方式更為清晰、可預測且易於擴展。

總而言之,React 鼓勵使用明確的、函數式的組合方式來建立元件。這使得元件之間的關係更清晰,程式碼整體更易於理解、測試、維護和重用。雖然在極其罕見的、非 UI 相關的純 JavaScript 類別邏輯重用場景下,繼承可能有其用武之地,但在 React 元件的設計中,你幾乎總能找到一個更優雅、更符合 React 哲學的組合方案。在考慮使用繼承之前,不妨先問問自己:"這個問題是否可以用組合(包含、特殊化、Render Props、HOCs 或 Hooks)更好地解決?"答案通常是肯定的。

results matching ""

    No results matching ""