高階元件(HOC)

高階元件(HOC)是 React 中用於復用元件邏輯的一種高階技巧。HOC 本身不是 React API 的一部分,它是一種基於 React 的組合特性而形成的設計模式。 具體而言,高階元件是一個把元件當作參數傳入的函式,並傳回值為新元件。

const EnhancedComponents = higherOrderComponent(WrappedComponent);

類似於 Python decorator

重複的程式碼抽出來共用,是一種很常見的優化方式。

概念上和 python 的 decorator 有些相似之處。

  • 包裝其他函式或元件:無論是 HOC 還是 Decorator,它們都是用來包裝其他函式或元件的工具。
  • 提供額外功能:HOCs 和 Decorators 都可以用來添加額外的功能或行為,例如日誌記錄、權限控制等。
import time
 
def calculate_time(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Execution time of {func.__name__}: {end_time - start_time} seconds")
        return result
    return wrapper
 
@calculate_time
def some_function():
    # 假設這個函式執行了一些任務
    time.sleep(2)
    print("Task completed")
 
some_function()

HOC 的比喻

這讓我想到跟我們以前玩遊戲的情境很像,像是洛克人 X 每一代穿上不同超帥的鎧甲,他不會改變洛克人的本質,他依然可以走路、跳躍、發射子彈,但是穿上鎧甲之後,他可以做空中衝刺、集氣火力更強、二段跳躍、增強防禦力、特殊武器能量不減...等等,多了很多加值功能。

也像是超級瑪利歐一樣,他可以透過獲得不同的道具,來「增加」許多特殊的技能,像是青蛙裝可以游泳、貍貓裝可以飛行、可以變身地藏王、丟斧頭、丟火球...等等。

該怎麼建立 HOC 呢?

該怎麼建立 HOC 呢?以下是一個簡易範例。

我們希望多個元件中使用相同邏輯,但又不想要修改元件本身,像是穿外套、穿制服、專裝備、穿 XXX 一樣。

const EnhancedComponent = higherOrderComponent(WrappedComponent);
 
function higherOrderComponent(WrappedComponent) {
  return function EnhancedComponent(props) {
    useEffect(() => {
      console.log("HOC 增強了元件!");
    }, []);
 
    return <WrappedComponent {...props} />;
  };
}
 
// 原始的函數元件
const WrappedComponent = () => {
  return <div>這是我的函數元件</div>;
};
  • higherOrderComponent(WrappedComponent):這個函式就是 HOC 的本體。
  • 它回傳一個新的函式元件 EnhancedComponent,這個元件會接收所有的 props,並把這些 props 原封不動地傳給 WrappedComponent。
  • EnhancedComponent 內部,我們可以加上任何想要的副作用或邏輯(例如這裡用 useEffect 印出一行 log),這就是「增強」的部分。
  • 最後,<WrappedComponent {...props} /> 會把包進來的元件渲染出來,並且保留原本的 props。

WrappedComponent 是一個最簡單的 React 函數元件,只會顯示一段文字。 EnhancedComponent 則是經過 HOC 包裝後的新元件,當它被渲染時,除了原本的內容,還會在 console 印出「HOC 增強了元件!」這行訊息。

之後你就可以像一般元件一樣使用 <EnhancedComponent />,它就會自動擁有 HOC 加上的功能。

這樣的設計讓 React 元件的邏輯可以高度重用,也讓程式碼更乾淨、維護更容易。

生活中常見的 HOC

因為這樣的設計模式帶來許多好處,因此生活中我們有很多常用的套件也使用了 HOC 這個設計模式。

Redux 的 connect()

它用於將 Redux 的狀態管理和 React 元件連接起來,使得元件可以存取 Redux 的狀態和 dispatch Redux 的動作。

import React from "react";
import { connect } from "react-redux";
 
// 原始的函數元件
const MyComponent = ({ data }) => {
  return <div>Redux 數據:{data}</div>;
};
 
// mapStateToProps 函數,用於將 Redux 狀態映射到元件的 props
const mapStateToProps = (state) => {
  return {
    data: state.someData,
  };
};
 
// 使用 connect 作為 HOC 包裹函數元件
const ConnectedComponent = connect(mapStateToProps)(MyComponent);
 
export default ConnectedComponent;

React.memo()

避免重新渲染的 HOC,透過它可以對元件重新渲染的時機做出控制。

React.memo 共接收兩個參數,第一個是要包住的元件,第二個是可以自訂比較 props 的方法,回傳 false 時會重新渲染元件。

const MyComponent = (props) => {
  /* render using props */
};
const areEqual = (prevProps, nextProps) => {
  // 自訂比較的 Function
};
export default React.memo(MyComponent, areEqual);

react-router 的 withRouter()

withRouter 是一個高階元件中(HOC), 其作用是將一個元件包進 Route 裡面, 然後 react-router 的三個物件 history, location, match 就會被放進這個元件的 props 屬性中,此時這個元件就具備了路由的屬性。

import { withRouter } from "react-router-dom";
 
const MyComponent = ({ history, match }) => (
  <div>
    <p>Current path: {match.path}</p>
    <button onClick={() => history.push("/another-path")}>
      Go to another path
    </button>
  </div>
);
 
export default withRouter(MyComponent);

react-i18next 的 withTranslation()

是 React i18next 提供的高階元件,它可以將國際化相關的功能注入到元件中,使得元件可以存取翻譯函數等。

import { withTranslation } from "react-i18next";
 
const MyComponent = ({ t }) => (
  <div>
    <p>{t("hello")}</p>
  </div>
);
 
export default withTranslation()(MyComponent);

其他

  1. Authentication HOCs: 可以用於驗證用戶是否已經登錄。這可以通過檢查用戶的驗證狀態,並根據需要重新導向到登錄頁面或顯示受保護的內容。
  2. Logging HOCs: 可以用於記錄元件的生命週期事件,如元件的渲染次數、請求數據的時間等。這有助於進行性能優化和調試。
  3. Theme HOCs: 可以用於應用主題(Theme)到元件,使得元件可以根據應用的主題風格進行呈現。
  4. Error Handling HOCs: 可以用於捕獲元件中的錯誤,並進行錯誤處理。這可以通過在錯誤發生時顯示一個錯誤消息或記錄錯誤信息。
  5. Loading HOCs: 可以用於處理數據加載的狀態,例如顯示載入指示器或處理異步數據的加載過程。
  6. Permission HOCs: 可以用於檢查用戶是否具有特定的權限,並根據權限顯示或隱藏元件。
  7. Analytics HOCs: 可以用於整合分析程式碼,以跟踪特定元件的使用情況,使用者互動等。
  8. Caching HOCs: 可以用於緩存數據,以提高應用性能。例如,在相同參數下的數據可以被緩存,避免重複的請求。

多個 HOC 的組合 - Composing

我們還可以組合多個 HOC。在這個例子中,withLogging HOC 負責輸出元件的掛載和卸載日誌,而 withPermission HOC 檢查用戶權限,只有當用戶擁有特定權限時才允許渲染元件。

在某些情況下,我們還可以使用 Hooks 模式來達到類似的結果。我們將在本章後面詳細討論這種模式,但暫時讓我們說一說, 使用 Hooks 可以減少元件樹的深度,而使用 HOC 模式,可能會導致元件樹的深度巢狀。

const EnhancedComponent = withPermission(withLogging(MyComponent), "admin");

適合使用 HOC 的時機

  1. 有一個通用的功能或行為,而這個功能不需要針對每個元件進行特定的定制或修改。
  2. 元件可以獨立工作,不需要添加的自定義邏輯。

使用 HOC 你需要知道的優缺點

優點

使用 HOC 模式的一個主要優勢是可以將要重複使用的邏輯放在一個地方,這樣可以降低程式碼重複造成錯誤的風險。保持程式碼 DRY(Don't Repeat Yourself) 有助於維護和管理程式碼,同時有效地實現關注點的分離,使得不同功能和關注點可以獨立處理。

缺點

命名衝突

function withStyles(Component) {
  return (props) => {
    const style = { padding: "0.2rem", margin: "1rem" };
    return <Component style={style} {...props} />;
  };
}
 
const Button = () => <button style={{ color: "red" }}>Click me!</button>;
const StyledButton = withStyles(Button);

在這個例子中,withStyles HOC 添加了一個名為 style 的 prop 到我們傳遞給它的元件中。然而,Button 元件已經有一個名為 style 的 prop,這將被覆蓋。

為了確保 HOC 能夠處理意外的名稱衝突,可以通過重命名或合併 props 來解決:

function withStyles(Component) {
  return (props) => {
    const { style, ...restProps } = props;
    const mergedStyle = {
      padding: "0.2rem",
      margin: "1rem",
      ...style, // 在這裡要特別處理,避免 style props 被覆蓋
    };
 
    return <Component style={mergedStyle} {...restProps} />;
  };
}
 
const Button = () => <button style={{ color: "red" }}>Click me!</button>;
const StyledButton = withStyles(Button);

巢狀過多的高階元件(HOC)

  • 可讀性下降:巢狀多層的高階元件會使元件程式碼變得複雜且難以理解,尤其是對於新加入專案的開發者來說,可能需要花更多的時間來理解整個元件結構。
  • 除錯困難:當出現問題時,巢狀多層的高階元件可能會增加偵錯的難度,因為需要逐層追蹤高階元件的邏輯和傳遞的屬性,增加了定位問題的複雜性。
  • 效能影響:每個高階元件都會產生一個新的元件實例,這可能會對效能產生一定的影響。 雖然 React 在處理元件樹時進行了最佳化,但過多的高階元件可能會導致元件樹過深,影響渲染效能。
  • 狀態管理變得複雜:狀態有可能會互相衝突影響,追溯狀態歸屬也會變得困難。
  • 增加測試的困難
    • 元件單獨測試沒問題,組合起來就有問題。
    • 由於元件樹巢狀比較深,你可能需要經過多層次的元件才能找到並定位到。
  • 難免增加耦合度(相依性),使得元件複用困難,增加維護成本。

元件變得複雜之後可讀性會下降,並且伴隨而來的是除錯困難

結論

  • 在適合的時機,可以使用 HOC 來降低程式碼重複、有效地實現關注點的分離。
  • 留意不要過度使用或不當使用,以免帶來更大的副作用。