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 自動推斷 items 與 renderItem 的型別,確保兩者保持一致。
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 T 讓 columns 必須是 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 為資料型別)保留了原本元件的型別資訊,同時增添 loading 與 data。
範例 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)以在編譯期捕捉錯誤。 |
| 回傳值型別不一致 | 在泛型函式內部忘記回傳正確型別,導致 void 或 any。 |
使用 as const 或 return value as T,確保回傳型別正確。 |
| React.memo + 泛型 | React.memo 會把元件的型別改成 ComponentType<any>,失去泛型資訊。 |
使用 React.memo<T>(Component as React.FC<T>) 或自行寫 memo 包裝函式。 |
| 過度抽象 | 把所有元件都寫成泛型會讓程式碼難以閱讀。 | 只在需要重用資料結構的地方使用,其餘保持具體型別即可。 |
最佳實踐
- 盡量讓 TypeScript 自動推斷:只在必要時手動指定
<T>,避免過度冗長。 - 使用
as const讓字面值保持字面型別:配合泛型的K extends keyof T,可以得到更精確的欄位推斷。 - 為泛型參數加上說明性註解:例如
TItem,TKey extends keyof TItem,有助於閱讀。 - 保持元件的 API 穩定:若要在未來擴充,考慮加入 預設泛型,減少升級時的破壞性變更。
- 測試型別:使用
tsd或type-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 或 錯誤邊界 的情況下,withLoading、withErrorBoundary 等 HOC 結合泛型,可在不改變原始元件型別的前提下,注入額外功能。
總結
- Generic Component 是在 React + TypeScript 中實現 型別安全、可重用 UI 的關鍵工具。
- 透過 泛型參數、約束 (
extends)、預設型別,我們可以在保持抽象的同時,仍得到完整的編譯期檢查。 - 本文提供了 5 個實務範例(列表、資料表、表單、HOC、Hook),示範了從簡單到進階的使用方式。
- 常見陷阱包括 泛型推斷失效、約束過寬、與 React.memo 的衝突,只要遵循 最佳實踐(明確指定泛型、加上適當約束、保持 API 穩定),即可避免這些問題。
- 在實際開發中,將泛型元件應用於 動態表格、通用表單、UI 元件庫、非同步資料處理 等場景,能顯著提升開發效率與程式碼品質。
掌握了這些概念與技巧,你就能在大型 React 專案中自如地編寫 型別安全、可維護且高度抽象 的元件,為團隊帶來更穩定的開發體驗。祝你在 TypeScript 與 React 的旅程中玩得開心,寫出更好的程式碼!