React 组件类型:泛型组件、Hooks、事件处理
第21章:React 组件类型:泛型组件、Hooks、事件处理完全指南
理解React 组件类型是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用泛型组件、Hooks、事件处理完全指南?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 为什么不推荐
React.FC - Props 类型:children、事件、refs
useRef类型化
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 只有在 T 是 HTMLInputElement 或 HTMLTextAreaElement 等有 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 的类型推导机制。