《TypeScript编程实战》11.1 组件 props 与泛型组件

本节把 React 组件的 props 从随手写的对象类型升级成一份可演进的接口契约:先讲 children 的三种类型与取舍,再讲可选属性在严格模式下的行为、事件处理器的泛型签名,最后进入泛型组件的完整写法,让列表、表格、选择器这类复用组件在适配任意数据类型时仍然保留推导。文中给出真实编译错误与修复路径,读完能设计出不依赖 any 的组件 API。

本节目标:把 React 组件的 props 从「随手写的对象类型」升级成一份可演进的接口契约。你会掌握 children 的三种类型与取舍、可选属性在 exactOptionalPropertyTypes 下的行为、事件处理器的泛型签名,以及泛型组件的完整写法——让列表、表格、选择器这类复用组件在适配任意数据类型时仍然保留推导。读完本节,你应该能独立设计一个不依赖 any、调用方拿到错误提示就能自愈的组件 API。

11.1 组件 props 与泛型组件

先看一段几乎每个 React 项目里都能找到的代码:

type Props = { items: any[]; onSelect: (item: any) => void };

export function List({ items, onSelect }: Props) {
  return (
    <ul>
      {items.map((it) => (
        <li key={it.id} onClick={() => onSelect(it)}>
          {it.name}
        </li>
      ))}
    </ul>
  );
}

这段代码能跑,ESLint 也可能一声不吭,但它把组件的契约整个交给了运行时:it.id 是否存在、it.name 是字符串还是对象、onSelect 到底收到什么,编译器一概不知道。本节要做的,就是把这些信息一条条还给编译器。

11.1.1 先定接口,再写组件

props 类型本质上就是函数的入参类型,没有任何魔法。把它显式写出来,三件事立刻变得可检查:

检查项显式声明 props 后仍是 any 时
必填属性漏传编译期报错运行时读到 undefined
属性名拼错编译期报错(多余属性检查)静默失效,值永远不生效
回调签名不匹配编译期报错运行期才抛异常

把上面的例子改写成显式契约:

export type ListItem = { id: string; name: string; disabled?: boolean };

export type ListProps = {
  items: readonly ListItem[];
  onSelect: (item: ListItem) => void;
};

export function List({ items, onSelect }: ListProps) {
  return (
    <ul>
      {items.map((it) => (
        <li key={it.id} onClick={() => !it.disabled && onSelect(it)}>
          {it.name}
        </li>
      ))}
    </ul>
  );
}

items 用 readonly ListItem[] 而不是 ListItem[],是刻意的:组件只读遍历,没有任何理由要求调用方交出可变数组。这一个小改动让 const items = [...] as const 之类的只读数据也能直接传进来,是「让类型适配真实数据」而不是反过来。

11.1.2 children 的三种类型

children 是最容易被随手写成 any 或 React.ReactNode 的地方。三者语义差别很大:

类型含义适用场景
ReactNode一切可渲染内容(字符串、数字、元素、数组、null、undefined)容器类组件的默认选择
ReactElement单个已创建的元素对象需要读取 props 或做克隆时
JSX.Element同上,ReactElement 的一个子集,不接受 null少见,通常只在老代码里

结论很直接:容器组件一律用 ReactNode。用 ReactElement 会让 <Card>{null}</Card> 和条件渲染 {show && <A />} 直接报错——后者在 show 为 false 时求值为 false,而 false 不是合法的 ReactElement。

type CardProps = { title: string; children: React.ReactNode };

export function Card({ title, children }: CardProps) {
  return (
    <section className="card">
      <h3>{title}</h3>
      <div className="card-body">{children}</div>
    </section>
  );
}

如果组件只接受「一个函数」,那就是 render props,类型上是一个函数签名而不是节点:

type DataProps<T> = {
  items: readonly T[];
  children: (item: T, index: number) => React.ReactNode;
};

children 与 items 共用同一个 T,调用方在 JSX 里写渲染函数时参数会自动带上类型。若同时想支持节点与函数,就写成 React.ReactNode | ((item: T) => React.ReactNode)——但那样每个调用点都得先做类型判断,实践中不推荐。

11.1.3 可选属性、默认值与 exactOptionalPropertyTypes

可选属性在开启 exactOptionalPropertyTypes 后语义收紧:size?: 'sm' | 'lg' 意味着要么不传,要么传 'sm' | 'lg',不允许显式传 undefined。这与 JSX 里常见的 size={maybeUndefined} 写法冲突:

// tsconfig: "exactOptionalPropertyTypes": true
type BadgeProps = { size?: 'sm' | 'lg' };
declare const size: 'sm' | 'lg' | undefined;

// TS2379: Argument of type '{ size: "sm" | "lg" | undefined; }' is not
// assignable to parameter of type 'BadgeProps' with 'exactOptionalPropertyTypes: true'.
const el = <Badge size={size} />;

修复有两条路:把类型改成 size?: 'sm' | 'lg' | undefined,或在调用侧收敛成 size={size ?? 'sm'}。选一种团队内统一即可。

默认值不要写进类型,用解构默认值表达:

type BadgeProps = { size?: 'sm' | 'lg'; tone?: 'solid' | 'outline' };

export function Badge({ size = 'sm', tone = 'solid' }: BadgeProps) {
  return <span className={`badge badge-${size} badge-${tone}`}>…</span>;
}

一个反例是把默认值塞进类型:size: 'sm' | 'lg'(去掉问号)再加 Badge.defaultProps。defaultProps 在函数组件上已被废弃,且在类型层面它无法表达「调用方可以省略」这一事实——类型会强迫每个调用点都传值。

11.1.4 事件处理器的类型

事件回调是 props 里第二容易被写成 any 的地方。核心结论:不要手写事件对象的形状,直接从 React 里取。

import type { ChangeEvent, FormEvent, MouseEvent } from 'react';

type SearchProps = {
  onQueryChange: (query: string) => void;
  onSubmit: (e: FormEvent<HTMLFormElement>) => void;
  onItemClick: (e: MouseEvent<HTMLLIElement>, id: string) => void;
};

export function Search({ onQueryChange, onSubmit, onItemClick }: SearchProps) {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    onQueryChange(e.target.value); // e.target.value: string
  };
  return (
    <form onSubmit={onSubmit}>
      <input onChange={handleChange} />
      <ul>
        <li onClick={(e) => onItemClick(e, '1')}>第一项</li>
      </ul>
    </form>
  );
}

MouseEvent 与 ChangeEvent 都带一个泛型参数,表示事件挂载的 DOM 元素。把 MouseEvent<HTMLButtonElement> 传给 onClick: MouseEventHandler<HTMLLIElement> 会报 TS2322: Type 'MouseEvent<HTMLButtonElement>' is not assignable to type 'MouseEvent<HTMLLIElement>'。这看起来啰嗦,但正是它在阻止你从「li 的点击事件」里读 e.currentTarget.disabled 这种在运行时为 undefined 的属性。

与原生事件的区别要记住:React 的合成事件是 React.MouseEvent,不是全局的 MouseEvent。写成 (e: MouseEvent) => void 而不 import React 的类型时,拿到的是 DOM 原生事件类型,赋值给 onClick 会报类型不兼容。习惯写法是 import type { MouseEvent } from 'react',并保证这个 import 覆盖全局同名类型。

自定义组件的回调则应该完全脱离 DOM 概念,传领域值而不是事件:

// 好:调用方不需要知道内部是不是 li
type ItemProps = { item: ListItem; onSelect: (item: ListItem) => void };

11.1.5 泛型组件:把 any 换成 T

列表组件是最典型的泛型组件:它需要对任意数据类型做同一件事,同时把「这是什么类型」原样传给调用方。目标形态是这样:

import type { ReactNode } from 'react';

export type SelectProps<T> = {
  options: readonly T[];
  value: T | null;
  onChange: (value: T) => void;
  getKey: (option: T) => string;
  renderOption: (option: T) => ReactNode;
  placeholder?: string;
};

export function Select<T>({
  options,
  value,
  onChange,
  getKey,
  renderOption,
  placeholder = '请选择',
}: SelectProps<T>) {
  return (
    <div className="select">
      {value === null && <span className="placeholder">{placeholder}</span>}
      <ul>
        {options.map((option) => (
          <li key={getKey(option)} onClick={() => onChange(option)}>
            {renderOption(option)}
          </li>
        ))}
      </ul>
    </div>
  );
}

关键点是 T 出现在 options 这个入参位置上,所以调用方不需要手写类型参数,编译器能从实参反推:

type City = { code: string; label: string };
const cities: City[] = [{ code: 'SHA', label: '上海' }];

// T 被推导为 City,v.code 有提示,v.foo 报 TS2339
<Select
  options={cities}
  value={null}
  onChange={(v) => console.log(v.code)}
  getKey={(c) => c.code}
  renderOption={(c) => c.label}
/>;

陷阱一:T 只出现在回调参数位置。 如果把 options 换成 load: () => Promise<T[]>,或者 T 只出现在 renderOption 里,编译器找不到推导依据,T 会退化成 unknown。此时必须显式传类型参数:

<Select<City> options={cities} value={null} onChange={() => {}} getKey={(c) => c.code} renderOption={(c) => c.label} />;

显式类型参数在 JSX 里写作 <Select<City> ... />,TS 2.9 起支持 ,项目仍须配置 JSX 转译方式。

陷阱二:tsx 文件里的泛型箭头函数。 这是新手最容易卡住的一处:

// TS17008: JSX element 'T' has no corresponding closing tag.
const Select = <T>(props: SelectProps<T>) => <div />;

// 修复一:加一个逗号,告诉解析器这不是 JSX 标签
const Select = <T,>(props: SelectProps<T>) => <div />;

// 修复二:用 extends 消歧义
const Select = <T extends unknown>(props: SelectProps<T>) => <div />;

原因是 .tsx 里 <T> 首先被当作 JSX 标签的起始。最省事的做法是直接用 function 声明——函数声明与 JSX 没有歧义,本节的示例都采用这种写法。

陷阱三:泛型与 React.memo / forwardRef 组合会丢掉泛型。 这两者返回的都是非泛型包装组件:

export const Select = memo(function Select<T>(props: SelectProps<T>) {
  /* … */
});
// 调用处:TS2558: Expected 0 type arguments, but got 1.
<Select<City> options={cities} value={null} onChange={() => {}} getKey={(c) => c.code} renderOption={(c) => c.label} />;

需要泛型 + forwardRef 时,得把内部函数声明成泛型并做一次受控断言,且把断言收敛在一个文件里:

import { forwardRef, type ForwardedRef, type ReactNode } from 'react';

function SelectInner<T>(props: SelectProps<T>, ref: ForwardedRef<HTMLDivElement>) {
  return <div ref={ref} className="select" />;
}

// 断言只允许出现在这里,调用方拿到的是泛型签名
export const Select = forwardRef(SelectInner) as <T>(
  props: SelectProps<T> & { ref?: ForwardedRef<HTMLDivElement> },
) => ReactNode;

React 19 起 ref 可以直接作为普通 prop 传递,forwardRef 不再是必需,泛型组件因此少了一层包装——这是升级到 React 19 后能顺手删掉的一类断言。

11.1.6 扩展原生元素与 as 属性

包装原生元素时不要手写一遍 HTML 属性,直接继承:

import type { ComponentPropsWithoutRef } from 'react';

type ButtonProps = ComponentPropsWithoutRef<'button'> & {
  variant?: 'primary' | 'ghost';
  loading?: boolean;
};

export function Button({ variant = 'primary', loading, children, ...rest }: ButtonProps) {
  return (
    <button {...rest} data-variant={variant} disabled={rest.disabled || loading}>
      {children}
    </button>
  );
}

ComponentPropsWithoutRef<'button'> 一次性带上了 onClick、type、disabled、aria-* 等全部属性。用 ComponentProps<'button'> 也可以,但它会带上 ref,在 forwardRef 场景下容易和外部 ref 冲突,所以包装函数组件时用 WithoutRef 更稳。

需要替换某个继承来的属性时用 Omit:

// 把原生 onChange 换成领域语义的 onChange
type InputProps = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
  onChange: (value: string) => void;
};

as 多态组件是泛型组件的进阶形态。它用两个类型参数分别描述「渲染成什么标签」和「标签自己的属性」:

type BoxProps<C extends React.ElementType> = {
  as?: C;
  children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<C>, 'as' | 'children'>;

export function Box<C extends React.ElementType = 'div'>({ as, children, ...rest }: BoxProps<C>) {
  const Tag = (as ?? 'div') as React.ElementType;
  return <Tag {...rest}>{children}</Tag>;
}

它带来的收益很直观——<Box as="a" href="/docs"> 里 href 会被检查,写成 hraf 直接报 TS2322: Property 'hraf' does not exist on type ...。代价是 Box 内部的 Tag 必然需要一次断言,且 IDE 的 props 提示会慢一些。只在真正需要多态的基础组件上用它,业务组件不要为了「看起来灵活」而引入。

11.1.7 常见错误信息对照

错误码与信息真实原因修复
TS2739: Type '{}' is missing the following properties from type 'X': a, b必填 props 没传补属性,或把属性改成可选并给默认值
TS2322: Type 'string' is not assignable to type '"sm" | "lg"'字面量联合被 widen用 as const,或显式标注变量类型
TS2339: Property 'foo' does not exist on type 'T'泛型 T 未加约束加 T extends { foo: string }
TS17008: JSX element 'T' has no corresponding closing tag..tsx 里泛型箭头函数写成 <T,> 或改用 function
TS2558: Expected 0 type arguments, but got 1.组件被 memo/forwardRef 抹掉了泛型用受控断言恢复泛型签名
TS2604: JSX element type 'X' does not have any construct or call signatures.值不是组件(比如把 hook 当组件用)检查 import 与调用形态

最值得单独说的是 TS2339:它通常不是「类型写错了」,而是约束缺失。给 T 加最小约束(只要组件内部真正用到的那几个字段)比给整个 props 加 any 好得多。

11.1.8 与本书其它章节的衔接

props 类型一旦确定,组件内部的取值与状态就要跟上,这属于 Hooks 类型的话题,见 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook 。如果 props 是从接口拿到的数据,那么它的来源类型与运行时校验见 《TypeScript编程实战》14.1 TanStack Query 类型推导 与 《TypeScript编程实战》13.1 React Hook Form + Zod 。表单提交后 props 如何跨组件传递,见 《TypeScript编程实战》11.3 Context 与状态管理(Zustand / RTK) 。

站内既有专题对 React 与类型系统做过单点深挖,可作延伸阅读:React + TypeScript 实战 、TypeScript 泛型 API 设计与性能 、前端 TypeScript 高级类型 。

小结

本节把组件 props 拆成了四个层次。契约层:props 就是函数入参,显式声明它换来三件事——漏传、拼错、签名不符都能在编译期发现;容器组件的 children 用 ReactNode,不要用 ReactElement。边界层:exactOptionalPropertyTypes 让「可选」不再等价于「可以是 undefined」,需要时显式写 | undefined;默认值用解构表达式而不是 defaultProps。事件层:事件对象一律从 React 取,泛型参数就是事件挂载的元素,写错会立刻报错而不是运行时 undefined。泛型层:泛型组件的成立条件是 T 出现在入参位置,T 只出现在回调里时必须显式传类型参数;.tsx 里的泛型箭头函数要写 <T,>,而最省事的做法是用 function 声明。

最容易犯的错有三个:一是把 any 当成「先跑起来」的权宜之计,结果类型信息再也补不回来;二是给 T 加过重的约束(比如要求 T extends { id: string; name: string; createdAt: Date }),把组件绑死在一种数据上;三是在 memo / forwardRef 后面发现泛型丢了,随手加 as any 掩盖。正解是给 T 最小约束、必要时用一次收敛在单文件内的断言。

props 只描述了组件的对外接口,组件内部的状态、副作用与派生计算同样需要类型支撑。接下来 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook 会讲清 useState 的推导边界、useRef 的三种形态、useReducer 的判别联合 action,以及如何写出一个能被推导、也能对外隐藏实现细节的自定义 Hook。

阅读导航:上一节:10.3 心跳、重连与广播 · 下一节:11.2 Hooks 类型与自定义 Hook 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes