在 React 项目中充分发挥 TypeScript 的能力:泛型组件、多态组件与类型收窄

很多团队在 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 元素,同时保持对应的属性类型。

典型例子是设计系统中的 TextBox 组件:

<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——loadingdataerror 各一个。这样写虽然简单,却无法在类型层面表达“这些状态互斥”这一事实,容易出现 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 依然不可访问。加上守卫后,类型被正确收窄。

三者的组合与取舍

这三项能力并不是孤立的。一个典型的数据表格组件,可能同时需要:泛型来约束行数据类型、多态来支持渲染成 tablediv、类型收窄来处理排序和筛选状态。组合使用时,类型定义的复杂度会显著上升。

我的经验是遵循一个原则:类型复杂度应该和组件的复用范围成正比。只在一个页面用的组件,老老实实写具体类型就好;要跨多个模块复用的基础组件,才值得投入精力做泛型和多态。类型体操本身不是目的,让调用方写起来安全、读起来清晰,才是。

最后提醒一点:TypeScript 的版本更新会带来更简洁的写法。比如 4.7 引入的实例化表达式、5.0 的 const 类型参数,都在逐步降低泛型组件的书写成本。保持对语言特性的关注,比死记硬背某一种模式更有价值。

未经允许不得转载:任鹏个人博客 » 在 React 项目中充分发挥 TypeScript 的能力:泛型组件、多态组件与类型收窄

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏