学完这篇,你能自己写出「传什么数据就推断出什么类型」的 React 组件,而不是靠 `any` 和类型断言硬撑。
第一步:把环境的地基打牢
用 Vite 起项目最省事:
npm create vite@latest ts-generic-demo -- --template react-ts
cd ts-generic-demo && npm i && npm run dev
打开 `tsconfig.json`,确认这三项是开着的,它们直接决定泛型推断好不好用:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
注意:`strict: false` 的项目里泛型基本等于装饰品,推断失败也不会报错,你会误以为是自己写错了。
第二步:写第一个泛型组件
从一个列表选择器开始。目标:传入 `User[]` 时,`onChange` 的参数自动是 `User`。
type SelectProps<T> = {
items: T[];
value: T | null;
onChange: (item: T) => void;
getKey: (item: T) => string | number;
getLabel: (item: T) => string;
};
export function Select<T>({ items, value, onChange, getKey, getLabel }: SelectProps<T>) {
return (
<ul role="listbox">
{items.map((item) => (
<li key={getKey(item)} role="option"
aria-selected={value === item}
onClick={() => onChange(item)}>
{getLabel(item)}
</li>
))}
</ul>
);
}
调用时可以不写类型参数,靠推断;也可以显式指定:
<Select items={users} value={cur} onChange={setCur}
getKey={(u) => u.id} getLabel={(u) => u.name} />
<Select<User> items={users} value={cur} onChange={setCur}
getKey={(u) => u.id} getLabel={(u) => u.name} />
注意:泛型组件不要用 `React.FC`。`FC` 本身不支持类型参数,套上去泛型会被擦成 `any`。普通函数声明就好。
第三步:用 extends 收紧边界
不加约束的 `T` 什么都能传,等于没约束。用 `keyof` 让调用方只能传真实存在的字段:
type TableProps<T, K extends keyof T> = {
rows: T[];
columns: K[];
renderCell: (row: T, col: K) => React.ReactNode;
};
function Table<T, K extends keyof T>({ rows, columns, renderCell }: TableProps<T, K>) {
return (
<table>
<tbody>
{rows.map((row, i) => (
<tr key={i}>{columns.map((c) => <td key={String(c)}>{renderCell(row, c)}</td>)}</tr>
))}
</tbody>
</table>
);
}
`columns={['id', 'name']}` 里写错一个字段名,编辑器立刻标红。
第四步:泛型 + 联合类型,把非法状态挡在编译期
组件同时要处理 loading / error / 成功三种状态时,用可辨识联合,别用一堆可选属性:
type State<T> =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T };
function Panel<T>({ state, render }: { state: State<T>; render: (d: T) => React.ReactNode }) {
if (state.status === 'loading') return <p>加载中…</p>;
if (state.status === 'error') return <p>{state.error.message}</p>;
return <>{render(state.data)}</>;
}
三个分支里 `state.data`、`state.error` 只在合法分支可见,写 `state.status === 'error'` 之外的访问会直接报错。
第五步:泛型 Hook 与 ref 的坑
自定义 Hook 直接写泛型即可:
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
});
const update = (next: T) => { setValue(next); localStorage.setItem(key, JSON.stringify(next)); };
return [value, update] as const;
}
注意:`.tsx` 文件里写泛型箭头函数,`<T>` 会被当成 JSX 标签。写成 `<T,>` 或 `<T extends unknown>` 绕过。
`forwardRef` 和 `memo` 都会丢掉泛型,常见做法是断言后重新标注签名:
const Select = memo(SelectImpl) as <T>(p: SelectProps<T>) => React.ReactElement;
React 19 里 `ref` 已是普通 prop,直接放进 `SelectProps<T>` 就不需要这层 hack。
小结
- 泛型组件的关键是让类型从 props 流到回调,而不是手动标注两遍。
- 用 `extends keyof T` 之类约束收窄入参,错误越早暴露越好。
- 状态建模优先用可辨识联合,别堆可选字段。
- `.tsx` 中箭头泛型写 `<T,>`;`FC`、`memo`、`forwardRef` 会吃掉泛型。
- `strict` 全开是前提,否则一切推断都不可信。