陌上人如玉
公子世无双

10.2 TypeScript 与 React

10.2 TypeScript 与 React

一句话:React + TS 的核心是「把 props、事件、children、ref 的类型写准」——基础组件要 extends React.XxxHTMLAttributes<T> 才能透传原生属性,容器组件用泛型让类型跟着数据走。

一、Props 类型

// ✅ 方式一:interface(推荐——报错信息友好、可扩展)
interface UserCardProps {
  name: string;
  age?: number;                        // 可选
  tags: string[];
  onClick?: () => void;
  children?: React.ReactNode;
}

function UserCard({ name, age = 0, tags, onClick, children }: UserCardProps) {
  return (
    <div onClick={onClick}>
      {name} / {age} / {tags.length} / {children}
    </div>
  );
}
// ✅ 方式二:type(适合「联合、交叉、映射」等复杂类型)
type ButtonProps = {
  variant: 'primary' | 'secondary';
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

// ✅ 方式三:互斥 props(用判别联合)
type AlertProps =
  | { type: 'link'; href: string }
  | { type: 'button'; onClick: () => void };
场景 推荐
普通组件 props interface
联合/交叉/条件类型 type
互斥 props type + 判别联合
需要声明合并(扩展第三方类型) interface
// ✅ 小技巧:用「类型断言」保证「每个分支都被处理」
function assertNever(x: never): never {
  throw new Error(`未处理的分支: ${JSON.stringify(x)}`);
}

function Alert(props: AlertProps) {
  switch (props.type) {
    case 'link':
      return <a href={props.href}>链接</a>;
    case 'button':
      return <button onClick={props.onClick}>按钮</button>;
    default:
      return assertNever(props);        // ⭐ 新增分支时这里会编译报错
  }
}

二、children 的类型

// 三个常见选项(按「允许什么」从宽到严)
type A = { children: React.ReactNode };        // ⭐ 最常用(任何可渲染内容)
type B = { children: React.ReactElement };     // 只允许「单个元素」
type C = { children: React.ReactElement[] };   // 只允许「元素数组」
type D = { children: (x: Data) => React.ReactNode };   // 函数(render props)
类型 允许的内容 适合
React.ReactNode 元素/字符串/数字/null/undefined/数组/Fragment 大多数场景
React.ReactElement 单个元素(不含文本、数组) 「必须传一个元素」的容器
React.ReactElement[] 元素数组 列表类容器
React.JSX.Element 单个 JSX 元素(React 19 的新命名) 同 ReactElement
// ⚠️ React.ReactNode 的坑:它包含 undefined,所以「不传 children」也合法
interface Props { children: React.ReactNode }
<Comp />              // ✅ 合法(children 是 undefined)
// 如果「必须传」→ 用 React.ReactElement(不包含 undefined)

三、事件类型

// ✅ 常见的元素事件类型(用 React.XxxEventHandler<T> 或 React.XxxEvent<T>)
interface Props {
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
  onFocus: (e: React.FocusEvent<HTMLElement>) => void;
  onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
  onScroll: (e: React.UIEvent<HTMLDivElement>) => void;
  onPointerDown: (e: React.PointerEvent<HTMLElement>) => void;
  onDragOver: (e: React.DragEvent<HTMLElement>) => void;
  onAnimationEnd: (e: React.AnimationEvent<HTMLElement>) => void;
}
// ✅ 两个「EventHandler」写法(更简洁,不用写参数类型)
interface Props {
  onClick: React.MouseEventHandler<HTMLButtonElement>;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
}

// ⚠️ 常见错误:直接用「原生事件类型」
onClick: (e: MouseEvent) => void;                 // ❌ 那是 DOM 的 MouseEvent,不是 React 的
onClick: (e: React.MouseEvent) => void;            // ✅
// ✅ 与原生事件的转换
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.nativeEvent);                     // 原生事件(MouseEvent)
};

// ✅ 需要「读 DOM 属性」时的类型
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
  const value = e.currentTarget.value;             // e.currentTarget 有正确的类型
};
// ⚠️ 用 e.target 时类型是 EventTarget(更宽泛)→ 需要断言或用 currentTarget
常用事件 类型
点击 React.MouseEvent<T>
输入变化 React.ChangeEvent<T>
键盘 React.KeyboardEvent<T>
表单提交 React.FormEvent<T>
焦点 React.FocusEvent<T>
滚动 React.UIEvent<T>
指针 React.PointerEvent<T>
拖拽 React.DragEvent<T>
剪贴板 React.ClipboardEvent<T>

四、基础组件继承原生属性

// ✅ 关键:extends React.XxxHTMLAttributes<T> 才能透传原生属性
import { forwardRef, type ButtonHTMLAttributes } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = 'primary', size = 'md', className, children, ...rest },
  ref
) {
  return (
    <button
      ref={ref}
      className={clsx('btn', `btn--${variant}`, `btn--${size}`, className)}   {/* ⭐ 合并外部 className */}
      {...rest}                                                              {/* ⭐ 透传其余原生属性 */}
    >
      {children}
    </button>
  );
});

// 使用:onClick / disabled / type / aria-* 等都有类型且有提示
<Button variant="primary" onClick={handleClick} disabled type="submit" aria-label="提交">
  确定
</Button>;
// ⚠️ 三种「原生属性」类型的区别(基础组件常常搞混)
// ① HTMLAttributes:不含「各种元素特有的事件/属性」(如 value、href)
type A = React.HTMLAttributes<HTMLDivElement>;

// ② XxxHTMLAttributes:具体元素的属性(推荐!)
type B = React.ButtonHTMLAttributes<HTMLButtonElement>;
type C = React.InputHTMLAttributes<HTMLInputElement>;
type D = React.AnchorHTMLAttributes<HTMLAnchorElement>;

// ③ AllHTMLAttributes:所有 HTML 元素的属性(很宽泛,少用)
type E = React.AllHTMLAttributes<HTMLElement>;
类型 包含 适合
HTMLAttributes<T> 通用属性(不含元素特有) 纯布局容器(div 类)
ButtonHTMLAttributes<T> button 的全部属性(含 disabled、type) button 封装
InputHTMLAttributes<T> input 的全部属性(含 value、onChange 类型) input 封装
AnchorHTMLAttributes<T> a 的属性(含 href、target) 链接封装
AllHTMLAttributes<T> 所有元素的属性 极少用
// ✅ 更「万能」的写法:让组件能渲染成「任意元素」(多态组件)
type PolymorphicProps<E extends React.ElementType, P = {}> = P & {
  as?: E;
} & Omit<React.ComponentPropsWithoutRef<E>, keyof P>;

function Text<E extends React.ElementType = 'span'>({
  as,
  children,
  ...rest
}: PolymorphicProps<E, { color?: string }>) {
  const Tag = as ?? 'span';
  return <Tag {...rest}>{children}</Tag>;
}

// 使用:自动推导出「对应元素的属性」
<Text as="a" href="/x">链接</Text>;        // ✅ href 有类型
<Text as="button" disabled>按钮</Text>;    // ✅ disabled 有类型

五、泛型组件

// ✅ 泛型组件:类型跟着「数据」走(容器/列表类组件常用)
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyText?: string;
}

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

// ⭐ 使用时 T 会自动推导
<List
  items={users}                                  // T = User
  renderItem={(u) => u.name}                     // ✅ u 有 User 类型
  keyExtractor={(u) => u.id}                     // ✅
/>;
// ✅ 带约束的泛型(要求 T 必须有某个字段)
interface SelectProps<T extends { id: string; label: string }> {
  options: T[];
  value: string | null;
  onChange: (option: T) => void;
}

function Select<T extends { id: string; label: string }>({ options, value, onChange }: SelectProps<T>) {
  return (
    <select value={value ?? ''} onChange={(e) => onChange(options.find((o) => o.id === e.target.value)!)}>
      {options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
    </select>
  );
}
// ✅ 泛型 + forwardRef(`<T,>` 的逗号是为了「避免被当成 JSX」)
const Select = <T extends { id: string },>(
  props: SelectProps<T>,
  ref: React.Ref<HTMLSelectElement>
) => { /* ... */ };

export const SelectWithRef = forwardRef(Select) as <T extends { id: string }>(
  props: SelectProps<T> & { ref?: React.Ref<HTMLSelectElement> }
) => React.ReactElement;

六、常用工具类型

// ⭐ 从「已有组件」提取 props 类型(封装组件时最常用)
type ButtonProps = React.ComponentProps<typeof Button>;
type ButtonProps2 = React.ComponentPropsWithoutRef<typeof Button>;   // 不含 ref
type ButtonProps3 = React.ComponentPropsWithRef<typeof Button>;      // 含 ref

// ⭐ 带 children 的 props
type Props = React.PropsWithChildren<{ title: string }>;
// 等价于 { title: string; children?: React.ReactNode }

// ⭐ 把「组件」当类型用(多态组件的 as 属性)
type AsProps = { as: React.ElementType };

// ⭐ 提取「事件的参数类型」
type SubmitHandler = React.ComponentProps<'form'>['onSubmit'];

// ⭐ 提取「自定义 Hook 的返回值」
type UseFormReturn = ReturnType<typeof useForm>;

// ⭐ 从「已有类型」里挑选/排除
type FormProps = Pick<UserCardProps, 'name' | 'age'>;
type FormProps2 = Omit<UserCardProps, 'children'>;

// ⭐ 让「某一组属性必填」
type RequiredName = Required<Pick<UserCardProps, 'name'>> & UserCardProps;
工具类型 作用
React.ComponentProps<typeof C> 提取组件的 props(含 ref)
React.ComponentPropsWithoutRef<typeof C> 提取 props(不含 ref)
React.ComponentPropsWithRef<typeof C> 提取 props(含 ref,显式)
React.PropsWithChildren<P> 给 P 加上可选 children
React.ElementType 「能当标签用的东西」(字符串或组件)
ReturnType<typeof fn> 提取函数返回值类型
Pick / Omit / Required / Partial 内置的工具类型

七、ref 的类型

// ✅ React 18:forwardRef + 两个泛型参数
import { forwardRef, useRef } from 'react';

interface InputProps {
  label: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(function Input({ label }, ref) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
});

const ref = useRef<HTMLInputElement>(null);       // ⭐ 类型:HTMLInputElement | null
<Input label="用户名" ref={ref} />;
ref.current?.focus();                             // ✅ 需要可选链(可能是 null)
// ✅ React 19:ref 作为普通 prop
interface InputProps {
  label: string;
  ref?: React.Ref<HTMLInputElement>;              // ⭐ 显式声明
}

function Input({ label, ref }: InputProps) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}
// ✅ 暴露「命令式接口」时的 ref 类型
export interface FormHandle {
  validate: () => boolean;
  reset: () => void;
}

const Form = forwardRef<FormHandle, Props>(function Form(props, ref) {
  useImperativeHandle(ref, () => ({ validate, reset }), []);
  return <form>{props.children}</form>;
});

const formRef = useRef<FormHandle>(null);
<Form ref={formRef} />;
formRef.current?.validate();
场景 ref 类型
DOM 元素 useRef<HTMLInputElement>(null) → HTMLInputElement \| null
只读 DOM useRef<HTMLDivElement>(null)
自定义实例 useRef<MyInstance \| null>(null)
命令式接口 useRef<FormHandle>(null)
组件接收 ref React.Ref<T>
// ⚠️ useRef 的三种签名差异(容易踩坑)
const a = useRef<HTMLDivElement>(null);          // RefObject<HTMLDivElement | null>(只读 current)
const b = useRef<number>(0);                     // MutableRefObject<number>(可写 current)
const c = useRef<number | null>(null);           // 可写
// ✅ 规律:传「null 字面量」→ current 只读(用于 DOM ref);
//    传其它初始值 → current 可写(用于「可变的普通值」)

八、常见坑

// ① 事件类型写成「原生事件」
const handle = (e: MouseEvent) => {};                 // ❌ 这是 DOM MouseEvent
const handle2 = (e: React.MouseEvent) => {};          // ✅

// ② 基础组件没有 extends HTMLAttributes → 原生属性没有类型
interface Props { onClick?: () => void }              // ⚠️ 没有 disabled/type/aria-*
interface Props2 extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant: 'a' }  // ✅

// ③ 忘了「展开 rest」→ 原生属性没被透传
<button className={className}>{children}</button>     // ❌ 丢了 onClick/disabled
<button className={className} {...rest}>{children}</button>   // ✅

// ④ 忘了「合并 className」(覆盖外部传入的样式)
<button className="btn" {...rest} />                  // ⚠️ rest 里的 className 会覆盖
// ✅ 用 clsx 合并

// ⑤ 用 React.FC(不推荐:隐式 children、泛型难用)
const Comp: React.FC<Props> = ({ children }) => <div>{children}</div>;   // ⚠️
function Comp2({ children }: Props) { return <div>{children}</div>; }     // ✅

// ⑥ 泛型组件写成 React.FC → 泛型失效
// ⑦ 用 e.target.value 而不是 e.currentTarget.value(类型更宽泛)
// ⑧ useRef<HTMLDivElement>() 不传 null → current 类型不含 null(可能误用)
// ⑨ 把「可选 props」写成必填(下游会报错)
// ⑩ 用 any 抹掉类型(丢失检查)
const props: any = ...;                               // ❌

// ⑪ 用「字符串」传给枚举式 props(拼写错误无法发现)
<Alert type="succes" />                                // ✅ 会编译报错(联合类型的好处)
// ⑫ 忘记「导出 props 类型」(下游无法复用)
export interface ButtonProps { ... }                   // ✅
// ⑬ 用「interface 定义联合类型」(写不出来)→ 用 type
// ⑭ 在「跨包」使用 props 类型时没加 export type
// ⑮ 用 `React.JSX.Element` 与 `React.ReactElement` 混用(React 19 起前者更标准)
// ⑤ 的正确写法(不推荐 React.FC 的原因)
// ❌ React.FC 的问题:
// · 隐式包含 children(即使你的组件不支持)
// · 泛型组件用 React.FC 会丢失泛型
// · 返回值被固定为 ReactElement(无法返回 string 等)
// ✅ 直接用「函数 + 显式 props 类型」
function Button({ variant, ...rest }: ButtonProps) { ... }

九、面试延伸

  1. 「React 组件的 props 类型怎么写?」
    三种方式:① interface——大多数场景(报错信息友好、可扩展、支持声明合并);② type——联合/交叉/条件类型(如「互斥 props」用判别联合);③ 需要「透传原生属性」时用 interface Props extends React.XxxHTMLAttributes<T>。规范上建议「导出 props 类型」(下游可复用)、不用 React.FC(隐式 children、泛型难用)、枚举式 props 用联合类型(拼写错误能被编译发现)。

  2. 「基础组件(Button/Input)的 props 类型有什么讲究?」
    关键是 extends React.XxxHTMLAttributes<T>(而不是只用 HTMLAttributes)——因为「元素特有属性」(如 button 的 disabled/type、input 的 value/onChange、a 的 href)不在通用的 HTMLAttributes 里。同时组件内部要 {...rest} 透传、并用 clsx 合并外部的 className(否则要么丢属性、要么覆盖外部样式)。这样封装出来的组件既有类型提示,又保持了原生元素的能力。

  3. 「children 的类型该怎么选?」
    四个选项:① React.ReactNode(最常用)——允许元素、字符串、数字、null/undefined、数组;② React.ReactElement——只允许「单个元素」(不含 undefined,所以「必须传 children」时用它);③ React.ReactElement[]——只允许元素数组(列表容器);④ 函数类型——render props((data) => ReactNode)。注意 ReactNode 包含 undefined,所以「不传 children」也是合法的。

  4. 「React 的事件类型怎么写?」
    用 React.XxxEvent<T> 或更简洁的 React.XxxEventHandler<T>:如 React.MouseEvent<HTMLButtonElement>、React.ChangeEvent<HTMLInputElement>、React.KeyboardEvent<T>、React.FormEvent<HTMLFormElement>。注意不要写成原生事件类型(MouseEvent 是浏览器的,不是 React 的)。读值时优先用 e.currentTarget(它有正确类型;e.target 的类型更宽泛,需要断言)。

  5. 「什么是泛型组件?有什么用?」
    泛型组件指「类型参数由使用方推导」的组件(如 function List<T>({ items, renderItem }: ListProps<T>))。它解决的问题是「容器组件的类型要跟着数据走」——如果没有泛型,renderItem 的 item 参数只能写成 any,丢失类型检查。典型场景:列表/表格(items + renderItem)、选择器(options + onChange)、表单项(value + onChange)。还可以给泛型加约束(T extends { id: string })。

  6. 「怎么从一个已有组件提取 props 类型?」
    用 React.ComponentProps<typeof Component>(含 ref)、React.ComponentPropsWithoutRef<typeof Component>(不含 ref)、React.ComponentPropsWithRef<typeof Component>(显式含 ref)。这在「二次封装组件」时特别有用——比如你做了一个 MyButton 包装 antd 的 Button,可以用 React.ComponentProps<typeof AntButton> 拿到它的全部 props 类型,再 Omit 掉你要覆盖的字段。相关工具还有 PropsWithChildren、ElementType、ReturnType。

  7. 「useRef 的类型为什么有两种行为?」
    因为 TypeScript 对 useRef 有重载:① 传 null 字面量时,返回 RefObject<T>(current 是只读的)——这是为「DOM ref」设计的(你不应该手动给 DOM ref 赋值);② 传其它初始值时,返回 MutableRefObject<T>(current 可写)——这是为「保存可变值」设计的。所以「存定时器 ID / 计数器」要用 useRef<number>(0)(可写),而「取 DOM」用 useRef<HTMLDivElement>(null)(只读 current,由 React 赋值)。

  8. 「用 TypeScript 写 React 有哪些最佳实践?」
    八条:① 导出组件的 props 类型(便于复用与测试);② 不用 React.FC(隐式 children、泛型问题);③ 基础组件 extends XxxHTMLAttributes 并透传 ...rest、用 clsx 合并 className;④ 事件用 React.XxxEvent(不是原生类型);⑤ 枚举式 props 用联合类型(拼写错误能编译发现);⑥ 用判别联合表达「互斥 props」,并用 assertNever 保证「穷尽处理」;⑦ 容器组件用泛型(类型跟着数据);⑧ 避免 any(用 unknown + 类型收窄代替)。

一句话速记

props 类型用 interface(复杂类型用 type),基础组件必须 extends React.XxxHTMLAttributes<T>(否则 disabled/value/href 这类「元素特有属性」没有类型),并 {...rest} 透传 + clsx 合并 className;children 首选 React.ReactNode(含 undefined,要「必填」就用 React.ReactElement);事件用 React.MouseEvent<T> / React.ChangeEvent<T>(不要写原生 MouseEvent,取值优先 e.currentTarget);泛型组件让类型跟着 items/options 走(列表、选择器、表单);提取已有组件的 props 用 React.ComponentProps<typeof C>(不含 ref 用 WithoutRef);useRef 传 null → current 只读(DOM ref)、传其它值 → 可写(存可变值);规范上导出 props 类型、不用 React.FC、枚举 props 用联合类型、互斥 props 用判别联合 + assertNever、避免 any。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.2 TypeScript 与 React

评论 抢沙发

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

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫