TypeScript × React 实战

掌握泛型组件、事件类型、forwardRef 类型标注与 Children 类型处理,在 React 项目中充分发挥 TypeScript 的能力。

TypeScript × React 实战

概述

React 与 TypeScript 的结合是现代前端开发的标准实践。TypeScript 为 React 组件提供了 props 类型检查、事件类型推断、ref 类型安全等能力。但 React 的某些模式(泛型组件、forwardRef、children)在类型标注上有独特的挑战。

面试定位:TypeScript × React 的类型问题是前端面试的高频考点。面试官期望候选人能流畅地标注组件 props、事件处理器和 ref,并了解 React.FC 的利弊。


组件 Props 类型

基础标注

// Interface 定义 Props
interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
  onClick: () => void;
}

function Button({ label, variant = "primary", disabled, onClick }: ButtonProps) {
  return (
    <button className={variant} disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
}

React.FC 的利弊

// React.FC 写法
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};

React.FC 的问题(React 18 之前):

  • 曾经隐式包含 children prop,即使组件不接受 children
  • React 18 已移除此行为

当前推荐:直接标注参数类型,不使用 React.FC

// 推荐写法
function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

Children 类型

// 接受任何有效的 React 子元素
interface LayoutProps {
  children: React.ReactNode;
}

// 只接受单个 React 元素
interface WrapperProps {
  children: React.ReactElement;
}

// 渲染函数模式
interface DataListProps<T> {
  items: T[];
  children: (item: T, index: number) => React.ReactNode;
}

事件类型

常用事件类型

function Form() {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(e.target.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  };

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log(e.clientX, e.clientY);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") { /* ... */ }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} onKeyDown={handleKeyDown} />
      <button onClick={handleClick}>Submit</button>
    </form>
  );
}

事件处理器类型 vs 事件类型

// 事件对象类型
type ClickEvent = React.MouseEvent<HTMLButtonElement>;

// 事件处理器类型(包含事件参数的函数签名)
type ClickHandler = React.MouseEventHandler<HTMLButtonElement>;

// 两种写法等价
const handler1: ClickHandler = (e) => { /* ... */ };
const handler2 = (e: ClickEvent) => { /* ... */ };

泛型组件

基础泛型组件

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

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// 使用:T 自动推断
<List
  items={[{ id: "1", name: "Alice" }]}
  renderItem={(user) => <span>{user.name}</span>}  // user 类型自动推断
  keyExtractor={(user) => user.id}
/>

泛型 Select 组件

interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getValue,
}: SelectProps<T>) {
  return (
    <select
      value={getValue(value)}
      onChange={(e) => {
        const selected = options.find(
          (opt) => getValue(opt) === e.target.value
        );
        if (selected) onChange(selected);
      }}
    >
      {options.map((opt) => (
        <option key={getValue(opt)} value={getValue(opt)}>
          {getLabel(opt)}
        </option>
      ))}
    </select>
  );
}

泛型组件与 forwardRef 的结合

泛型组件无法直接与 forwardRef 结合(forwardRef 不保留泛型参数)。常见解决方案:

// 方案一:类型断言
const List = forwardRef(<T,>(
  props: ListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) => {
  return <ul ref={ref}>{/* ... */}</ul>;
}) as <T>(props: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) => React.ReactElement;

// 方案二:使用 React 19 的 ref prop(推荐)
// React 19 中 ref 是普通 prop,不需要 forwardRef
function List<T>({ items, ref }: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) {
  return <ul ref={ref}>{/* ... */}</ul>;
}

forwardRef 类型

基础用法

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...rest }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...rest} />
        {error && <span className="error">{error}</span>}
      </div>
    );
  }
);

Input.displayName = "Input";

useImperativeHandle

interface ModalHandle {
  open: () => void;
  close: () => void;
}

interface ModalProps {
  title: string;
  children: React.ReactNode;
}

const Modal = forwardRef<ModalHandle, ModalProps>(({ title, children }, ref) => {
  const [isOpen, setIsOpen] = useState(false);

  useImperativeHandle(ref, () => ({
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
  }));

  if (!isOpen) return null;
  return (
    <div className="modal">
      <h2>{title}</h2>
      {children}
    </div>
  );
});

// 使用
const modalRef = useRef<ModalHandle>(null);
modalRef.current?.open();

ComponentProps 与类型提取

从组件提取 Props 类型

import { ComponentProps, ComponentPropsWithRef } from "react";

// 从 HTML 元素提取
type ButtonNativeProps = ComponentProps<"button">;
type InputNativeProps = ComponentProps<"input">;

// 从自定义组件提取
type MyButtonProps = ComponentProps<typeof Button>;

// 包含 ref 的版本
type InputWithRef = ComponentPropsWithRef<"input">;

扩展 HTML 元素 Props

interface CustomButtonProps extends ComponentProps<"button"> {
  variant: "primary" | "secondary";
  isLoading?: boolean;
}

function CustomButton({ variant, isLoading, children, ...rest }: CustomButtonProps) {
  return (
    <button className={variant} disabled={isLoading} {...rest}>
      {isLoading ? "Loading..." : children}
    </button>
  );
}

多态组件(Polymorphic Component)

type PolymorphicProps<E extends React.ElementType, P = {}> = P &
  Omit<ComponentProps<E>, keyof P | "as"> & {
    as?: E;
  };

function Box<E extends React.ElementType = "div">({
  as,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || "div";
  return <Component {...props} />;
}

// 使用
<Box as="a" href="/home">Link</Box>  // href 可用
<Box as="button" onClick={() => {}}>Button</Box>  // onClick 可用

Hooks 类型

useState

// 自动推断
const [count, setCount] = useState(0); // number

// 显式泛型(初始值为 null 时需要)
const [user, setUser] = useState<User | null>(null);

useRef

// DOM ref
const inputRef = useRef<HTMLInputElement>(null);

// 可变 ref
const timerRef = useRef<number | undefined>(undefined);
timerRef.current = window.setTimeout(() => {}, 1000);

useReducer

type State = { count: number; step: number };
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "setStep"; payload: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment": return { ...state, count: state.count + state.step };
    case "decrement": return { ...state, count: state.count - state.step };
    case "setStep": return { ...state, step: action.payload };
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
dispatch({ type: "setStep", payload: 5 }); // 类型安全

面试高频问题

Q: React.FC 还值得使用吗?

回答要点:React 18 之后,React.FC 已移除了隐式 children 的问题。但它仍然有局限性——无法用于泛型组件,且多了一层类型包装。当前 React 社区(包括 React 官方文档)倾向于直接在函数参数上标注 Props 类型,这种方式更灵活,也能更好地支持泛型。

Q: 如何为泛型组件添加 forwardRef?

回答要点:传统的 forwardRef 不保留泛型参数,需要类型断言。React 19 的解决方案是将 ref 变为普通 prop,组件可以直接从 props 接收 ref,无需 forwardRef 包装,泛型自然保留。在 React 18 中,常用方案是将 forwardRef 的返回值通过类型断言恢复泛型签名。

Q: 如何正确标注事件处理函数?

回答要点:React 提供了泛型事件类型如 React.ChangeEvent<HTMLInputElement>。最简单的方式是先内联事件处理器让 TypeScript 自动推断,然后再提取为独立函数。也可以使用事件处理器类型 React.MouseEventHandler<HTMLButtonElement> 直接标注函数。


延展阅读