本文 AI 產出,尚未審核

TypeScript × React:深入了解 Generic Component 型別


簡介

在 React 開發中,我們常會為了重用 UI 而建立 可重用的元件,例如表格、表單、清單等。當這些元件需要接受不同結構的資料時,僅靠 any 或是硬寫的介面會失去 型別安全,導致編譯器無法在編譯階段捕捉錯誤,進而增加除錯成本。

Generic Component(泛型元件)正是解決這個問題的關鍵。透過 TypeScript 的泛型,我們可以在 保持元件高度抽象 的同時,仍然保有 完整的型別推斷。這不僅提升開發效率,也讓程式碼更具可維護性,特別適合大型或多人協作的 React 專案。

本文將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你掌握在 React + TypeScript 中使用泛型元件的技巧,讓你在實務開發中得心應手。


核心概念

1. 為什麼需要泛型元件?

  • 資料結構多樣:同一個 UI 可能顯示不同欄位的資料(例如使用者列表 vs. 商品列表)。
  • 避免 any:使用 any 會失去編譯期檢查,容易在執行時產生 undefined 錯誤。
  • 提升可讀性:透過泛型,我可以在使用元件時直接看到期待的屬性名稱與型別。

2. 基本語法

在 TypeScript 中,泛型使用尖括號 <T> 表示。對於 React 元件,我們可以把泛型寫在函式或是 React.FC 的型別宣告上:

type Props<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T>({ items, renderItem }: Props<T>) {
  return (
    <ul>
      {items.map((item, idx) => (
        <li key={idx}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

此範例中,List 會根據呼叫時傳入的 T 自動推斷 itemsrenderItem 的型別,確保兩者保持一致。

3. 泛型與預設型別

有時候我們想要給予一個 預設的泛型,讓使用者在不指定時仍能取得合理的型別資訊:

function Card<T = { title: string }>(props: {
  data: T;
  render: (data: T) => React.ReactNode;
}) {
  return <div>{props.render(props.data)}</div>;
}

若呼叫者未提供泛型,T 會預設為 { title: string },仍然能得到型別提示。

4. 多泛型參數

有些情況需要同時傳遞兩個以上的型別,例如「資料」與「事件」:

type TableProps<T, K extends keyof T> = {
  data: T[];
  columns: K[];
  onRowClick?: (row: T) => void;
};

function Table<T, K extends keyof T>({ data, columns, onRowClick }: TableProps<T, K>) {
  return (
    <table>
      <thead>
        <tr>{columns.map(col => <th key={String(col)}>{String(col)}</th>)}</tr>
      </thead>
      <tbody>
        {data.map((row, i) => (
          <tr key={i} onClick={() => onRowClick?.(row)}>
            {columns.map(col => (
              <td key={String(col)}>{String(row[col])}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

K extends keyof T 限制了 columns 必須是 T 中的屬性名稱,這樣就能在編譯期避免寫錯欄位名。


程式碼範例

以下提供 5 個實務上常見 的泛型元件範例,逐一說明使用情境與重點。

範例 1:通用列表 List(前文已示)

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

export function List<T>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

/* 使用方式 */
type User = { id: number; name: string };
const users: User[] = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];

<List items={users} renderItem={u => <strong>{u.name}</strong>} />;

重點renderItem 取得的 item 型別會自動推斷為 User,若寫錯屬性會直接在 IDE 中報錯。


範例 2:可自訂欄位的資料表 Table

type TableProps<T, K extends keyof T> = {
  data: T[];
  columns: K[];
  /** 可選的列點擊事件,傳回完整資料列 */
  onRowClick?: (row: T) => void;
};

export function Table<T, K extends keyof T>({ data, columns, onRowClick }: TableProps<T, K>) {
  return (
    <table>
      <thead>
        <tr>{columns.map(col => <th key={String(col)}>{String(col)}</th>)}</tr>
      </thead>
      <tbody>
        {data.map((row, i) => (
          <tr key={i} onClick={() => onRowClick?.(row)}>
            {columns.map(col => (
              <td key={String(col)}>{String(row[col])}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

/* 使用方式 */
type Product = { id: number; name: string; price: number };
const products: Product[] = [
  { id: 1, name: "筆記本", price: 1200 },
  { id: 2, name: "滑鼠", price: 300 },
];

<Table
  data={products}
  columns={["name", "price"]}   // 只顯示 name 與 price 欄位
  onRowClick={row => alert(`選到 ${row.name}`)}
/>;

技巧K extends keyof Tcolumns 必須是 Product 的屬性,若寫成 "color" 會立刻得到編譯錯誤。


範例 3:表單欄位與驗證規則的泛型 Form

type Validator<T> = (value: T) => string | null;

type FormField<T> = {
  value: T;
  onChange: (v: T) => void;
  validator?: Validator<T>;
};

type FormProps<S> = {
  /** 表單的資料結構 */
  fields: { [K in keyof S]: FormField<S[K]> };
  /** 提交時會回傳完整表單資料 */
  onSubmit: (data: S) => void;
};

export function GenericForm<S>({ fields, onSubmit }: FormProps<S>) {
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const data = {} as S;
    for (const key in fields) {
      data[key] = fields[key].value;
    }
    onSubmit(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      {Object.entries(fields).map(([key, field]) => (
        <div key={key}>
          <label>{key}</label>
          <input
            value={field.value as any}
            onChange={e => field.onChange(e.target.value as any)}
          />
          {field.validator && field.validator(field.value) && (
            <span className="error">{field.validator(field.value)}</span>
          )}
        </div>
      ))}
      <button type="submit">送出</button>
    </form>
  );
}

/* 使用方式 */
type LoginForm = { username: string; password: string };
const loginFields = {
  username: {
    value: "",
    onChange: (v: string) => console.log(v),
    validator: (v) => (v.length < 3 ? "最少 3 個字元" : null),
  },
  password: {
    value: "",
    onChange: (v: string) => console.log(v),
  },
};

<GenericForm<LoginForm>
  fields={loginFields}
  onSubmit={data => console.log("送出資料", data)}
/>;

說明S 代表整個表單的資料形狀,fields 會自動映射每個欄位的型別,確保 onSubmit 收到的 data 完全符合 S


範例 4:高階元件 withLoading(HOC)

type WithLoadingProps<T> = {
  /** 原始元件的 props */
  data: T;
  /** 是否顯示 loading */
  loading: boolean;
};

export function withLoading<P, T>(Component: React.ComponentType<P & { data: T }>) {
  return function WrappedComponent({ loading, data, ...rest }: WithLoadingProps<T> & P) {
    if (loading) return <div>載入中…</div>;
    return <Component data={data} {...(rest as P)} />;
  };
}

/* 範例元件 */
type UserCardProps = { data: { id: number; name: string } };
function UserCard({ data }: UserCardProps) {
  return <div>{data.name}</div>;
}

/* 包裝後使用 */
const UserCardWithLoading = withLoading<UserCardProps, { id: number; name: string }>(UserCard);

<UserCardWithLoading loading={false} data={{ id: 1, name: "Alice" }} />;

關鍵withLoading 透過兩層泛型(P 為原始 props,T 為資料型別)保留了原本元件的型別資訊,同時增添 loadingdata


範例 5:自訂 Hook useAsync<T>

import { useState, useEffect } from "react";

type AsyncState<T> = {
  loading: boolean;
  data: T | null;
  error: Error | null;
};

export function useAsync<T>(asyncFn: () => Promise<T>, deps: any[] = []): AsyncState<T> {
  const [state, setState] = useState<AsyncState<T>>({
    loading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    let cancelled = false;
    setState({ loading: true, data: null, error: null });

    asyncFn()
      .then(res => {
        if (!cancelled) setState({ loading: false, data: res, error: null });
      })
      .catch(err => {
        if (!cancelled) setState({ loading: false, data: null, error: err });
      });

    return () => {
      cancelled = true;
    };
  }, deps);

  return state;
}

/* 使用方式 */
type Post = { id: number; title: string };
function PostList() {
  const { loading, data, error } = useAsync<Post[]>(() => fetch("/posts").then(r => r.json()));

  if (loading) return <p>讀取中…</p>;
  if (error) return <p>錯誤:{error.message}</p>;

  return (
    <ul>
      {data!.map(p => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

說明useAsync<T> 讓任何非同步函式都能得到 型別安全data,避免在取值時手動斷言 (as)。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記在 JSX 中指定泛型 React 直接使用元件時,TS 有時無法推斷泛型,會退化成 any 在使用元件時 明確寫出 <Component<YourType> …>,或利用 as 斷言。
泛型約束過寬 使用 extends unknown 或未限制 keyof,會讓錯誤在執行時才顯現。 加上適當的約束(如 K extends keyof T)以在編譯期捕捉錯誤。
回傳值型別不一致 在泛型函式內部忘記回傳正確型別,導致 voidany 使用 as constreturn value as T,確保回傳型別正確。
React.memo + 泛型 React.memo 會把元件的型別改成 ComponentType<any>,失去泛型資訊。 使用 React.memo<T>(Component as React.FC<T>) 或自行寫 memo 包裝函式。
過度抽象 把所有元件都寫成泛型會讓程式碼難以閱讀。 只在需要重用資料結構的地方使用,其餘保持具體型別即可。

最佳實踐

  1. 盡量讓 TypeScript 自動推斷:只在必要時手動指定 <T>,避免過度冗長。
  2. 使用 as const 讓字面值保持字面型別:配合泛型的 K extends keyof T,可以得到更精確的欄位推斷。
  3. 為泛型參數加上說明性註解:例如 TItem, TKey extends keyof TItem,有助於閱讀。
  4. 保持元件的 API 穩定:若要在未來擴充,考慮加入 預設泛型,減少升級時的破壞性變更。
  5. 測試型別:使用 tsdtype-tests 來驗證泛型元件的型別行為,確保不會因重構而失效。

實際應用場景

1. 動態表格管理系統

在企業內部的 報表平台,使用者可以自行選擇要顯示的欄位、排序方式,甚至自訂欄位的渲染方式。透過 Table<T, K>,後端回傳的資料結構(例如 Order, Customer)可以直接套用同一個表格元件,僅需提供欄位陣列與渲染函式,即可完成高度客製化的 UI。

2. 多樣化表單 (CRUD)

許多管理系統的 Create / Update 表單結構相似,只是欄位型別不同。使用 GenericForm<S>,開發者只需要定義一次表單 UI,然後傳入不同的 S(例如 ProductForm, EmployeeForm),即可一次解決多種表單的驗證、提交與型別安全。

3. 共享 UI 元件庫

大型公司往往會有 Design System,裡面包含許多通用元件(Button、Modal、Card)。若這些元件需要根據不同業務的資料渲染內容,使用泛型能讓 UI 庫保持 純粹、可重用,同時不犧牲型別安全。

4. 非同步資料抓取與顯示

useAsync<T> 可以在 資料儀表板即時搜尋 等場景下,統一管理 loading / error 狀態,且保證取得的資料型別正確,減少手動斷言的風險。

5. 高階元件 (HOC) 結合 Context

在需要 權限控制全局 Loading錯誤邊界 的情況下,withLoadingwithErrorBoundary 等 HOC 結合泛型,可在不改變原始元件型別的前提下,注入額外功能。


總結

  • Generic Component 是在 React + TypeScript 中實現 型別安全、可重用 UI 的關鍵工具。
  • 透過 泛型參數、約束 (extends)、預設型別,我們可以在保持抽象的同時,仍得到完整的編譯期檢查。
  • 本文提供了 5 個實務範例(列表、資料表、表單、HOC、Hook),示範了從簡單到進階的使用方式。
  • 常見陷阱包括 泛型推斷失效、約束過寬、與 React.memo 的衝突,只要遵循 最佳實踐(明確指定泛型、加上適當約束、保持 API 穩定),即可避免這些問題。
  • 在實際開發中,將泛型元件應用於 動態表格、通用表單、UI 元件庫、非同步資料處理 等場景,能顯著提升開發效率與程式碼品質。

掌握了這些概念與技巧,你就能在大型 React 專案中自如地編寫 型別安全、可維護且高度抽象 的元件,為團隊帶來更穩定的開發體驗。祝你在 TypeScript 與 React 的旅程中玩得開心,寫出更好的程式碼!