很多团队在 React 项目里用上了 TypeScript,但大多数时候只是给 props 标个类型、给 useState 加个泛型参数,然后就到此为止了。实际上,TypeScript 的类型系统远不止于此——泛型组件、多态组件和类型收窄这三项能力,能把你的组件抽象水平和代码安全性拉高一个档次。这篇文章会从实际场景出发,逐一拆解它们的用法和取舍。
泛型组件:让组件真正“通用”起来
先看一个很常见的场景:一个列表组件。如果不用泛型,你大概会这样写:
interface ListProps {
items: any[];
renderItem: (item: any) => React.ReactNode;
}
any 一出现,类型安全就形同虚设。调用方传错字段、renderItem 里访问不存在的属性,编译器都不会报错。
泛型组件解决的就是这个问题。在函数组件中,泛型参数的写法非常自然:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
);
}
使用时,TypeScript 会根据 items 自动推断出 T:
<List
items={users}
renderItem={(user) => <span>{user.name}</span>} // user 被推断为 User
keyExtractor={(user) => user.id}
/>
这里有一个容易踩的坑:在 .tsx 文件中,箭头函数泛型的写法 <T>(props: Props<T>) => ... 会被解析器误认为 JSX 标签。解决办法是在泛型参数后加一个逗号:<T,>,或者改用 function 声明。后者更清晰,推荐优先使用。
泛型组件真正发挥威力的地方,是配合自定义 Hook 或表单库使用时。比如一个 Select<T> 组件,选项的值类型、onChange 回调的参数类型全部由 T 统一约束,调用方几乎不需要手写类型标注,推断结果却完全准确。
多态组件:一个组件,多种语义
多态组件(Polymorphic Component)解决的是另一个维度的复用问题:同一个组件,根据传入的 as prop 渲染成不同的 HTML 元素,同时保持对应的属性类型。
典型例子是设计系统中的 Text 或 Box 组件:
<Text as="h1">标题</Text>
<Text as="a" href="/about">链接</Text>
如果只是简单地把 as 声明为 string,那 href 这类属性就无法被检查。要实现真正的多态,需要借助泛型约束:
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProps<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
function Text<C extends React.ElementType = 'span'>({
as,
children,
...rest
}: PolymorphicComponentProps<C>) {
const Component = as || 'span';
return <Component {...rest}>{children}</Component>;
}
这段类型定义看起来复杂,但拆开看逻辑很清晰:ComponentPropsWithoutRef<C> 拿到目标元素的所有原生属性,再通过 Omit 去掉已经被我们自己定义的属性,避免冲突。最终效果是——<Text as="a" href="..."> 中 href 有类型提示,而 <Text as="span" href="..."> 会直接报错。
多态组件的代价是类型体操的可读性下降。我的建议是:只在设计系统的基础组件上使用,业务组件不要轻易引入。而且最好把这套类型工具封装到一个 polymorphic.ts 文件里,避免在业务代码中重复展开。
类型收窄:让联合类型真正可用
React 组件经常需要处理联合类型的状态。比如一个异步请求:
type State<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
如果在渲染时直接访问 state.data,TypeScript 会报错,因为 data 只在 success 分支存在。这时候就需要类型收窄。
最直接的方式是用 status 做判别:
if (state.status === 'success') {
return <div>{state.data.name}</div>; // 这里 data 可访问
}
但实际项目中,很多人会把状态拆成多个 useState——loading、data、error 各一个。这样写虽然简单,却无法在类型层面表达“这些状态互斥”这一事实,容易出现 loading 为 true 同时 data 有值的矛盾情况。用可辨识联合(Discriminated Union)配合类型收窄,能从编译期杜绝这类 bug。
另一个实用技巧是自定义类型守卫:
function isSuccess<T>(state: State<T>): state is { status: 'success'; data: T } {
return state.status === 'success';
}
配合数组的 filter 使用时特别有用:
const results = states.filter(isSuccess).map(s => s.data);
如果不加类型守卫,filter 之后的元素类型仍然是完整的 State<T>,s.data 依然不可访问。加上守卫后,类型被正确收窄。
三者的组合与取舍
这三项能力并不是孤立的。一个典型的数据表格组件,可能同时需要:泛型来约束行数据类型、多态来支持渲染成 table 或 div、类型收窄来处理排序和筛选状态。组合使用时,类型定义的复杂度会显著上升。
我的经验是遵循一个原则:类型复杂度应该和组件的复用范围成正比。只在一个页面用的组件,老老实实写具体类型就好;要跨多个模块复用的基础组件,才值得投入精力做泛型和多态。类型体操本身不是目的,让调用方写起来安全、读起来清晰,才是。
最后提醒一点:TypeScript 的版本更新会带来更简洁的写法。比如 4.7 引入的实例化表达式、5.0 的 const 类型参数,都在逐步降低泛型组件的书写成本。保持对语言特性的关注,比死记硬背某一种模式更有价值。
未经允许不得转载:任鹏个人博客 » 在 React 项目中充分发挥 TypeScript 的能力:泛型组件、多态组件与类型收窄


朋友圈点赞图在线生成源码