第 19 章

TypeScript + React:类型安全的组件设计

第19章:TypeScript + React:类型安全的组件设计

TypeScript 在 React 中的价值不在于消灭所有 any,而在于将组件接口表达为可机器验证的契约。

本章核心问题:如何用类型系统精确描述组件接口?泛型组件和多态组件如何实现? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

React.FC 的兴衰

早期 React + TypeScript 教程几乎都这样写:

const MyComponent: React.FC<Props> = ({ name }) => {
  return <div>{name}</div>;
};

React.FC(即 React.FunctionComponent)曾被认为是"正确"写法,但现在主流社区已经不推荐它了。原因有几个:

一、隐式包含 childrenReact.FC 在 React 17 及之前版本中自动给 Props 加上 children?: ReactNode,这让组件的接口不诚实——一个不应该接受子节点的组件,也会悄悄接受,且不报错。React 18 移除了这个行为,但历史包袱已经形成了混乱。

二、defaultProps 类型推断问题React.FCdefaultProps 的类型推断存在已知缺陷,导致默认值无法正确收窄类型。

三、没有任何额外价值。普通函数组件已经能完整表达所有语义,React.FC 只是加了层没有实际收益的包装。

现代写法是直接声明函数,让 TypeScript 自然推断返回值:

// 推荐:简洁、诚实、无多余约束
function MyComponent({ name }: { name: string }) {
  return <div>{name}</div>;
}

// 或使用独立 interface
interface MyComponentProps {
  name: string;
}

function MyComponent({ name }: MyComponentProps) {
  return <div>{name}</div>;
}

事件处理器类型

React 的合成事件系统有完整的 TypeScript 类型覆盖,关键是要知道用哪个类型。

// 鼠标事件:泛型参数是事件来源的 DOM 元素类型
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  e.preventDefault();
  console.log(e.currentTarget.value); // currentTarget 类型精确
}

// 输入变化:处理文本输入的标准写法
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  const value = e.target.value; // string,不需要 as string
}

// 表单提交
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const form = e.currentTarget;
  const data = new FormData(form);
}

// 键盘事件
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.key === 'Enter') {
    // ...
  }
}

一个实用技巧:当你不确定某个事件处理器的类型,可以先写内联函数,让 TypeScript 自动推断,再用 typeof 或直接查看推断结果。

泛型组件

当一个组件需要对不同数据类型保持类型安全时,泛型组件是必要工具。最典型的例子是列表组件:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyState?: React.ReactNode;
}

function List<T>({ items, renderItem, keyExtractor, emptyState }: ListProps<T>) {
  if (items.length === 0) {
    return <>{emptyState ?? <p>暂无数据</p>}</>;
  }
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// 使用时,TypeScript 自动推断 T 为 User
interface User {
  id: string;
  name: string;
}

<List<User>
  items={users}
  keyExtractor={(u) => u.id}
  renderItem={(u) => <span>{u.name}</span>}
/>

注意:在 .tsx 文件中,泛型箭头函数 <T>() => ... 会被解析器误认为 JSX 标签。解决方案是写 <T,><T extends unknown>,或直接用 function 声明。

为 Hooks 标注类型

useState 的类型推断

大多数情况下,TypeScript 能从初始值推断出 useState 的类型:

const [count, setCount] = useState(0);         // number
const [name, setName] = useState('');          // string
const [user, setUser] = useState<User | null>(null); // 需要显式标注

当初始值是 nullundefined 时,必须显式提供泛型参数,否则推断出 null 类型会让后续的 setUser(someUser) 报错。

useRef 的重载

useRef 有三个重载,对应不同的使用场景:

// 场景一:引用 DOM 元素(初始值为 null,只读 .current)
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current 类型为 HTMLInputElement | null
// 赋值 inputRef.current = xxx 会报错(只读)

// 场景二:可变的命令式值(初始值非 null,可读写 .current)
const timerRef = useRef<number | undefined>(undefined);
// timerRef.current 可以赋值

// 场景三:不提供初始值(返回 MutableRefObject)
const valueRef = useRef<string>();

选错重载会导致奇怪的类型错误。记住:引用 DOM 元素始终传 null 作为初始值。

ReactNode、ReactElement 与 JSX.Element 的区别

这三个类型经常被混用,但它们有精确的语义差异:

实际工程中的选择原则:

// children prop:始终用 ReactNode,因为子节点可以是任何可渲染值
interface Props {
  children: React.ReactNode;
}

// render prop 或 renderItem:如果要求返回的必须是元素(不能是字符串),用 ReactElement
interface Props {
  renderHeader: () => React.ReactElement;
}

// 组件函数返回值:让 TypeScript 推断,无需手动标注
// 如果必须标注,用 JSX.Element(最常见约定)
function MyComponent(): JSX.Element {
  return <div />;
}

Level 2 · 它是怎么运行的(3-5年经验)

as prop:多态组件的类型安全实现

多态组件允许消费方指定渲染的根元素类型,同时保持对应元素属性的类型安全。这在设计系统中非常常见:

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>>;

// 使用:
interface TextOwnProps {
  size?: 'sm' | 'md' | 'lg';
  weight?: 'normal' | 'bold';
}

type TextProps<C extends React.ElementType> = PolymorphicComponentProps<C, TextOwnProps>;

function Text<C extends React.ElementType = 'span'>({
  as,
  size = 'md',
  weight = 'normal',
  children,
  ...rest
}: TextProps<C>) {
  const Component = as ?? 'span';
  return (
    <Component className={`text-${size} font-${weight}`} {...rest}>
      {children}
    </Component>
  );
}

// 渲染为 a 标签时,href 变为合法且有类型约束的 prop
<Text as="a" href="https://example.com" size="lg">链接文字</Text>

// 渲染为 h1 时,href 不存在,传入会报错
<Text as="h1" size="lg">标题文字</Text>

这个模式的实现较为复杂,但一旦封装好,消费方使用起来非常流畅,且类型完全安全。


Level 3 · 规范怎么定义的(资深)

Props 类型设计:必选、可选与变体

Props 是组件的公共接口。设计良好的 Props 类型能让消费方一眼读懂组件契约,也能在编译期拦截大量错误。

必选与可选的权衡

interface ButtonProps {
  children: React.ReactNode;      // 必选:按钮必须有内容
  onClick: () => void;            // 必选:按钮的核心行为
  disabled?: boolean;             // 可选:默认 false
  variant?: 'primary' | 'ghost'; // 可选:默认 primary
  className?: string;             // 可选:外部样式覆盖
}

可选 Props 要给默认值,而不是在组件内到处写 props.variant ?? 'primary'。在函数签名里解构并指定默认值是更惯用的做法:

function Button({
  children,
  onClick,
  disabled = false,
  variant = 'primary',
  className,
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant} ${className ?? ''}`}
    >
      {children}
    </button>
  );
}

可辨识联合:变体组件的正确建模

当一个组件有多种"形态",且不同形态下 Props 的结构完全不同时,可辨识联合(Discriminated Union)是最精确的类型建模方式。

假设有一个 Alert 组件,error 类型需要传入错误对象,confirm 类型需要传入确认回调,而 info 类型两者都不需要:

type AlertProps =
  | {
      type: 'info';
      message: string;
    }
  | {
      type: 'error';
      message: string;
      error: Error;
      onRetry?: () => void;
    }
  | {
      type: 'confirm';
      message: string;
      onConfirm: () => void;
      onCancel: () => void;
    };

function Alert(props: AlertProps) {
  // TypeScript 在每个分支里都能精确收窄类型
  if (props.type === 'error') {
    console.error(props.error); // 类型安全,props.error 一定存在
  }
  if (props.type === 'confirm') {
    // props.onConfirm 一定存在,props.error 不存在
  }
  // ...
}

这种模式的好处是:消费方在传入 type="error" 时,TypeScript 会强制要求传入 error 字段;传入 type="info" 时,传入 error 字段则会报错。这是用类型系统替代运行时防御性检查的典型案例。

React 19 新特性的类型支持

React 19 引入了若干新 API,TypeScript 类型定义也随之更新。

use Hook 的类型

import { use } from 'react';

// use 接受 Promise 或 Context
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // 类型推断为 User,非 Promise<User>
  return <div>{user.name}</div>;
}

Server Actions 的类型

// Server Action 是异步函数,接受 FormData 或自定义参数
async function submitForm(formData: FormData): Promise<{ success: boolean }> {
  'use server';
  // ...
  return { success: true };
}

// 在客户端组件中使用
<form action={submitForm}>
  <input name="email" type="email" />
  <button type="submit">提交</button>
</form>

useOptimistic 的类型推断

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,  // 初始状态,类型为 Message[]
  (state: Message[], newMessage: Message) => [...state, newMessage]
  // 返回值类型自动推断为 Message[]
);

Level 4 · 边界与陷阱(所有人)

常见类型陷阱与避坑指南

陷阱一:事件处理器中 e.targete.currentTarget 的区别e.target 是实际触发事件的元素,类型是 EventTarget(非常宽泛);e.currentTarget 是绑定事件的元素,类型是精确的 DOM 元素类型。处理输入值时,应当用 e.currentTarget.value(e.target as HTMLInputElement).value

陷阱二:条件渲染的类型收窄condition && <Component />condition 是数字 0 时,React 会渲染 0 而非不渲染。类型安全的写法是 condition ? <Component /> : null 或将条件转为布尔值 !!count && <Component />

陷阱三:Object.keys 的类型Object.keys(obj) 返回 string[] 而非 (keyof typeof obj)[],这是 TypeScript 刻意的设计(对象可能有运行时额外属性)。需要用 (Object.keys(obj) as (keyof typeof obj)[]) 进行断言。

陷阱四:组件 ref 的类型。对于自定义组件,useRef<MyComponent>(null) 中的泛型参数应当是实例类型,但函数组件没有实例。正确做法是用 useImperativeHandle 暴露命令式 API,并用 React.useRef<{ focus: () => void }>(null) 来引用。

TypeScript 在 React 中的价值不在于消灭所有 any,而在于将组件接口表达为可机器验证的契约。当你能用类型系统准确描述一个组件"能接受什么、不能接受什么"时,整个团队的协作效率和代码可维护性都会显著提升。

本章评分
4.6  / 5  (10 评分)

💬 留言讨论