第 21 章

React 组件类型:泛型组件、Hooks、事件处理

第21章:React 组件类型:泛型组件、Hooks、事件处理完全指南

理解React 组件类型是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用泛型组件、Hooks、事件处理完全指南?关键的设计决策和陷阱是什么?

读完本章你将理解


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

为什么不推荐 React.FC

React.FC(即 React.FunctionComponent)曾经是 React TypeScript 项目的标配写法,现在官方和社区都不再推荐它。原因是具体的。

// 用 React.FC 的写法
const Greeting: React.FC<{ name: string }> = ({ name }) => {
  return <div>Hello, {name}</div>;
};

React.FC 隐含了一个行为:它在 props 类型中自动添加了 children: React.ReactNode。React 18 之前的版本保留了这个行为。这意味着:

// React.FC 的隐式 children 问题(React 17 及以前)
const Button: React.FC<{ label: string }> = ({ label }) => (
  <button>{label}</button>
);

// 这行代码可以通过编译 — 即便 Button 没打算接受 children
<Button label="Click me"><span>extra</span></Button>;

React 18 修复了这个问题(React.FC 不再隐式包含 children),但这暴露了另一个问题:React.FC 本身并不提供额外的类型安全,它只是一个类型注解,而且写法比普通函数更繁琐。

推荐写法:普通函数

// 直接写函数,Props 接口单独定义
interface GreetingProps {
  name: string;
  age?: number;
}

function Greeting({ name, age }: GreetingProps) {
  return <div>Hello, {name}{age ? `, age ${age}` : ''}</div>;
}

// 或者箭头函数
const Greeting = ({ name, age }: GreetingProps) => (
  <div>Hello, {name}</div>
);

好处:返回类型由编译器推导(JSX.Element),不需要显式注解,代码更干净。

Props 类型:children、事件、refs

children 的正确类型

import { ReactNode, PropsWithChildren } from 'react';

// 方法一:手动声明 children
interface CardProps {
  title: string;
  children: ReactNode;   // 最通用的 children 类型
}

// 方法二:用 PropsWithChildren 工具类型
interface CardBaseProps {
  title: string;
}
type CardProps = PropsWithChildren<CardBaseProps>;
// 等价于 { title: string; children?: ReactNode }

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="content">{children}</div>
    </div>
  );
}

ReactNode 包含 ReactElement | string | number | boolean | null | undefined | ReactPortal — 几乎所有可以渲染的东西。如果只需要单个 React 元素(不接受字符串/数字),用 ReactElement

事件处理器类型

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

interface ButtonProps {
  onClick: (event: MouseEvent<HTMLButtonElement>) => void;
  onDoubleClick?: (event: MouseEvent<HTMLButtonElement>) => void;
  label: string;
}

interface InputProps {
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
  value: string;
}

interface FormProps {
  onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}

// 实际使用
function SearchInput({ onChange, value }: InputProps) {
  return (
    <input
      type="text"
      value={value}
      onChange={onChange}
      // onChange 的类型是 (event: ChangeEvent<HTMLInputElement>) => void
      // 这与 input 元素的 onChange 期望的类型完全匹配
    />
  );
}

ChangeEvent<T> 中的泛型 T 是 DOM 元素类型。event.target 的类型变为 T,所以 event.target.value 只有在 THTMLInputElementHTMLTextAreaElement 等有 value 属性的元素时才能访问。

// 常见事件类型对照
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
  e.target.value;        // string
  e.target.checked;      // boolean(只有 type="checkbox" 时有意义,但 TS 允许)
};

const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
  e.target.value;        // string(选中项的 value)
  e.target.selectedIndex; // number
};

const handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
  e.target.value;        // string
};

useRef 类型化

useRef 有两种完全不同的用途,类型签名也不同。

用途一:持有 DOM 引用

import { useRef, useEffect } from 'react';

function AutoFocusInput() {
  // useRef<HTMLInputElement>(null) — 初始值必须是 null
  // 类型:RefObject<HTMLInputElement>(ref.current 可能是 null)
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // ref.current 在这里可能是 null — 必须检查
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} />;
}

// 注意:如果传 undefined 而不是 null,类型会不同
const ref1 = useRef<HTMLInputElement>(null);
// 类型:React.RefObject<HTMLInputElement>
// ref1.current 是 HTMLInputElement | null(只读,React 管理)

const ref2 = useRef<HTMLInputElement | null>(null);
// 类型:React.MutableRefObject<HTMLInputElement | null>
// ref2.current 可写(你可以手动赋值)

用途二:持有可变值(不触发重渲染)

function Timer() {
  // 存储 interval ID,不需要触发重渲染
  const intervalId = useRef<ReturnType<typeof setInterval> | null>(null);
  // 等价于 useRef<number | null>(null) 在浏览器中

  const start = () => {
    intervalId.current = setInterval(() => {
      console.log('tick');
    }, 1000);
  };

  const stop = () => {
    if (intervalId.current !== null) {
      clearInterval(intervalId.current);
      intervalId.current = null;
    }
  };

  return (
    <div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

关键区分:DOM ref 用 useRef<ElementType>(null),可变值用 useRef<ValueType>(initialValue)

useState 复杂类型

import { useState } from 'react';

// 简单类型:直接推导,不需要泛型
const [count, setCount] = useState(0);         // number
const [name, setName] = useState('');           // string
const [visible, setVisible] = useState(false);  // boolean

// 复杂类型:需要显式泛型,因为初始值无法告知完整类型
interface User {
  id: string;
  name: string;
  email: string;
}

// 初始状态是 null,但之后会是 User
const [user, setUser] = useState<User | null>(null);
// user 类型:User | null
// setUser 类型:Dispatch<SetStateAction<User | null>>

// 数组状态
const [items, setItems] = useState<string[]>([]);
// 如果不写泛型,items 类型会是 never[](无法推导出字符串数组)

// 联合类型状态
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
// 这样 setStatus('typo') 会报错

// 对象状态
interface FormState {
  username: string;
  password: string;
  rememberMe: boolean;
}

const [form, setForm] = useState<FormState>({
  username: '',
  password: '',
  rememberMe: false,
});

// 更新部分字段
const handleChange = (field: keyof FormState, value: FormState[keyof FormState]) => {
  setForm(prev => ({ ...prev, [field]: value }));
};

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

useReducer 与判别联合 Action

import { useReducer } from 'react';

// 定义 State 类型
interface CartState {
  items: CartItem[];
  total: number;
  isLoading: boolean;
}

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

// 判别联合 Action — 每个 action 有唯一的 type
type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'SET_LOADING'; payload: boolean }
  | { type: 'CLEAR_CART' };

// Reducer 函数:TypeScript 在每个 case 分支中缩窄 action 类型
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      // action.payload 类型:CartItem
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price * action.payload.quantity,
      };

    case 'REMOVE_ITEM':
      // action.payload 类型:{ id: string }
      const filtered = state.items.filter(item => item.id !== action.payload.id);
      return {
        ...state,
        items: filtered,
        total: filtered.reduce((sum, item) => sum + item.price * item.quantity, 0),
      };

    case 'UPDATE_QUANTITY':
      // action.payload 类型:{ id: string; quantity: number }
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ),
      };

    case 'SET_LOADING':
      // action.payload 类型:boolean
      return { ...state, isLoading: action.payload };

    case 'CLEAR_CART':
      // 没有 payload
      return { items: [], total: 0, isLoading: false };

    default:
      // TypeScript 的穷举检查:如果所有 case 都处理了,这里是 never
      const _exhaustive: never = action;
      return state;
  }
}

// 使用
function Cart() {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    total: 0,
    isLoading: false,
  });

  // dispatch 的参数类型被完整推导为 CartAction
  dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Book', price: 29.9, quantity: 1 } });
  dispatch({ type: 'CLEAR_CART' }); // 不需要 payload
  // dispatch({ type: 'WRONG' }) — 编译错误
}

useContext 严格类型化

import { createContext, useContext, useState, ReactNode } from 'react';

// 定义 Context 值的类型
interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// 方法一:非 null 断言(适合保证 Provider 存在的场景)
const ThemeContext = createContext<ThemeContextValue>(
  null as unknown as ThemeContextValue  // 强制初始值,Provider 必须存在
);

// 方法二:自定义 hook 做运行时检查(更安全)
const ThemeContext2 = createContext<ThemeContextValue | null>(null);

function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext2);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;  // 类型:ThemeContextValue(null 已被排除)
}

// Provider 组件
function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext2.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext2.Provider>
  );
}

// 消费
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  // theme 类型:'light' | 'dark'
  return (
    <button
      onClick={toggleTheme}
      style={{ background: theme === 'dark' ? '#333' : '#fff' }}
    >
      Switch to {theme === 'dark' ? 'light' : 'dark'}
    </button>
  );
}

自定义 Hooks:返回类型推导

import { useState, useEffect, useCallback } from 'react';

// 返回类型由编译器推导
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json: T = await res.json();
      setData(json);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // 返回类型自动推导为:
  // { data: T | null; loading: boolean; error: Error | null; refetch: () => Promise<void> }
  return { data, loading, error, refetch: fetchData };
}

// 使用时传入期望的数据类型
interface User {
  id: string;
  name: string;
}

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
  // data 类型:User | null — 完全类型安全
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;
  return <div>{data.name}</div>;
}

显式声明返回类型(适合公共 API)

// 有时候显式返回类型更清晰,防止实现细节泄漏
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  // ...同上
}

泛型组件:<T,> 的语法细节

泛型组件让你写出可复用的列表、表格、选择器等组件,同时保留类型安全。

import { ReactNode } from 'react';

// 泛型组件定义
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

// 注意 <T,> 的逗号!在 .tsx 文件中,<T> 会被解析为 JSX 标签
// 加逗号告诉解析器这是泛型参数,不是 JSX
function List<T,>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
  if (items.length === 0) return <div>{emptyMessage}</div>;

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

// 使用时类型自动推导
interface User {
  id: string;
  name: string;
  email: string;
}

function UserList({ users }: { users: User[] }) {
  return (
    <List
      items={users}
      keyExtractor={user => user.id}
      // item 自动推导为 User — 不需要手动标注
      renderItem={user => <span>{user.name} ({user.email})</span>}
    />
  );
}

泛型组件约束

// 要求 T 必须有某些属性
function SortableList<T extends { id: string; order: number }>(
  { items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }
) {
  const sorted = [...items].sort((a, b) => a.order - b.order);
  return (
    <ul>
      {sorted.map(item => <li key={item.id}>{renderItem(item)}</li>)}
    </ul>
  );
}

替代方案:避开 .tsx 解析歧义

// 在 .tsx 中,也可以用 extends 来消歧义
function Select<T extends object>(props: SelectProps<T>) { ... }

// 或者把组件写在 .ts 文件里(不包含 JSX),再在 .tsx 里调用

forwardRef 类型化

import { forwardRef, useImperativeHandle, useRef } from 'react';

interface InputProps {
  label: string;
  error?: string;
  onChange: (value: string) => void;
}

// forwardRef 的泛型:第一个是 ref 的类型,第二个是 props 类型
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, onChange }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input
          ref={ref}
          onChange={e => onChange(e.target.value)}
          aria-invalid={!!error}
        />
        {error && <span className="error">{error}</span>}
      </div>
    );
  }
);

Input.displayName = 'Input';

// 使用
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focusInput = () => inputRef.current?.focus();

  return (
    <div>
      <Input
        ref={inputRef}
        label="Username"
        onChange={value => console.log(value)}
      />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

useImperativeHandle:自定义暴露的 API

interface VideoPlayerHandle {
  play: () => void;
  pause: () => void;
  seek: (seconds: number) => void;
}

interface VideoPlayerProps {
  src: string;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
  ({ src }, ref) => {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(ref, () => ({
      play: () => videoRef.current?.play(),
      pause: () => videoRef.current?.pause(),
      seek: (seconds) => {
        if (videoRef.current) videoRef.current.currentTime = seconds;
      },
    }));

    return <video ref={videoRef} src={src} />;
  }
);

// 使用:ref 的类型是 VideoPlayerHandle,不是 HTMLVideoElement
function MoviePage() {
  const playerRef = useRef<VideoPlayerHandle>(null);
  return (
    <div>
      <VideoPlayer ref={playerRef} src="/movie.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.seek(30)}>Skip 30s</button>
    </div>
  );
}

高阶组件类型化

import { ComponentType } from 'react';

// HOC:接受一个组件,返回增强版本
function withAuth<P extends { userId?: string }>(
  WrappedComponent: ComponentType<P>
) {
  // 返回的组件不需要 userId prop(HOC 注入它)
  type PublicProps = Omit<P, 'userId'>;

  return function AuthenticatedComponent(props: PublicProps) {
    const { userId } = useAuth();  // 假设存在这个 hook

    if (!userId) {
      return <div>Please log in</div>;
    }

    // 需要类型断言因为 TypeScript 无法确认 { ...props, userId } 满足 P
    return <WrappedComponent {...(props as P)} userId={userId} />;
  };
}

// 使用
interface DashboardProps {
  userId: string;
  title: string;
}

function Dashboard({ userId, title }: DashboardProps) {
  return <div>User {userId}: {title}</div>;
}

const AuthDashboard = withAuth(Dashboard);
// AuthDashboard 只需要 { title: string },userId 由 HOC 注入
<AuthDashboard title="My Dashboard" />;

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

React 的类型系统设计经历了从 React.FC(隐式 children)到普通函数组件的演进。useRef 的两种重载(useRef<T>(null) 返回 RefObject<T> vs useRef<T>(initialValue) 返回 MutableRefObject<T>)体现了 TypeScript 重载在 React Hooks 类型设计中的巧妙应用。泛型组件在 .tsx 文件中需要 <T,><T extends unknown> 语法来消除与 JSX 标签的歧义——这是 TypeScript JSX 解析器的技术限制。

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

反模式

反模式 问题 正确做法
React.FC 用于普通组件 历史包袱,React 18 前隐式 children 普通函数 + 显式 Props 接口
事件类型写 any 失去 event.target 的类型信息 ChangeEvent<HTMLInputElement> 等具体类型
useState 初始 null 不写泛型 类型推导为 null,后续赋值报错 useState<User | null>(null)
不检查 useRef.current 是否为 null 运行时 TypeError ref.current?.method() 或 if 检查
泛型组件在 .tsx 中写 <T> 解析为 JSX,语法错误 <T,><T extends unknown>
hook 没有返回类型注解(公共 API) 类型推导泄漏实现细节 显式写 interface HookResult
Context 不检查 null 直接用 useContext 在 Provider 外返回 null 自定义 hook 加运行时检查

总结

场景 推荐方式
组件定义 普通函数 + Props 接口
DOM ref useRef<HTMLXxxElement>(null)
可变值 ref useRef<T>(initialValue)
复杂 state useState<T>(initial) 显式泛型
多 action reducer 判别联合 Action + switch 穷举
Context 自定义 hook + 运行时 null 检查
泛型组件(.tsx) function Comp<T,>extends 语法
ref 转发 forwardRef<RefType, Props>

下一章讲全栈类型安全:tRPC 如何在服务端和客户端之间共享类型而不需要代码生成,以及 Prisma 和 Drizzle ORM 的类型推导机制。

本章评分
4.7  / 5  (8 评分)

💬 留言讨论