TypeScript + React 实战模式
概述
TypeScript 与 React 的结合是现代前端开发的主流选择。本文涵盖组件类型定义、Hooks 类型、事件处理、状态管理等核心模式,帮助你编写类型安全的 React 应用。
组件类型定义
函数组件
tsx
// 方式一:直接标注参数类型(推荐)
interface ButtonProps {
text: string;
onClick: () => void;
disabled?: boolean;
}
function Button({ text, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{text}
</button>
);
}
// 方式二:使用 React.FC(不推荐,有隐式 children)
const Button: React.FC<ButtonProps> = ({ text, onClick, disabled = false }) => {
return (
<button onClick={onClick} disabled={disabled}>
{text}
</button>
);
};Children 类型
tsx
// ReactNode - 最宽松,接受任何可渲染内容
interface CardProps {
children: React.ReactNode;
}
// ReactElement - 只接受 React 元素
interface ContainerProps {
children: React.ReactElement;
}
// 特定元素类型
interface TabsProps {
children: React.ReactElement<TabProps>[];
}
// 函数作为 children (Render Props)
interface DataFetcherProps<T> {
children: (data: T, loading: boolean) => React.ReactNode;
}
function DataFetcher<T>({ children, url }: DataFetcherProps<T> & { url: string }) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
// ...
return <>{children(data as T, loading)}</>;
}Props 继承与扩展
tsx
// 继承 HTML 元素属性
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
loading?: boolean;
}
function Button({ variant = 'primary', loading, children, ...rest }: ButtonProps) {
return (
<button className={`btn-${variant}`} disabled={loading} {...rest}>
{loading ? 'Loading...' : children}
</button>
);
}
// 继承其他组件属性
interface IconButtonProps extends ButtonProps {
icon: React.ReactNode;
}
// 省略某些属性
interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
size: 'small' | 'medium' | 'large';
}组件作为 Props
tsx
// 传递组件类型
interface LayoutProps {
header: React.ComponentType<{ title: string }>;
sidebar?: React.ComponentType;
}
function Layout({ header: Header, sidebar: Sidebar }: LayoutProps) {
return (
<div>
<Header title="Dashboard" />
{Sidebar && <Sidebar />}
</div>
);
}
// 使用 ReactElement
interface FormProps {
submitButton: React.ReactElement<ButtonProps>;
}Hooks 类型
useState
tsx
// 自动推断
const [count, setCount] = useState(0); // number
// 显式类型
const [user, setUser] = useState<User | null>(null);
// 复杂类型
interface FormState {
name: string;
email: string;
age: number;
}
const [form, setForm] = useState<FormState>({
name: '',
email: '',
age: 0
});
// 懒初始化
const [data, setData] = useState<Data[]>(() => {
const cached = localStorage.getItem('data');
return cached ? JSON.parse(cached) : [];
});useReducer
tsx
// 状态和动作类型
interface State {
count: number;
error: string | null;
}
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number }
| { type: 'error'; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
case 'reset':
return { ...state, count: action.payload };
case 'error':
return { ...state, error: action.payload };
default:
// 类型穷尽检查
const _exhaustive: never = action;
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset</button>
</div>
);
}useRef
tsx
// DOM 引用
const inputRef = useRef<HTMLInputElement>(null);
// 使用时需要判断 null
inputRef.current?.focus();
// 可变值
const countRef = useRef<number>(0);
countRef.current = 10; // 不需要判断 null
// 保存回调
const callbackRef = useRef<(() => void) | null>(null);useContext
tsx
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// 创建上下文
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 自定义 Hook(推荐)
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Provider 组件
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value: ThemeContextType = {
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light')
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}useCallback 和 useMemo
tsx
// useCallback
const handleClick = useCallback((id: number) => {
console.log(id);
}, []);
// 显式类型
const handleSubmit = useCallback<React.FormEventHandler<HTMLFormElement>>(
(event) => {
event.preventDefault();
// ...
},
[]
);
// useMemo
const expensiveValue = useMemo(() => {
return computeExpensiveValue(data);
}, [data]);
// 显式类型
const memoizedData = useMemo<ProcessedData[]>(() => {
return data.map(item => processItem(item));
}, [data]);自定义 Hooks
tsx
// 返回值类型
function useToggle(initial: boolean = false): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
// 对象返回值
interface UseCounterReturn {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
function useCounter(initial: number = 0): UseCounterReturn {
const [count, setCount] = useState(initial);
return {
count,
increment: useCallback(() => setCount(c => c + 1), []),
decrement: useCallback(() => setCount(c => c - 1), []),
reset: useCallback(() => setCount(initial), [initial])
};
}
// 泛型 Hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const valueToStore = newValue instanceof Function ? newValue(prev) : newValue;
localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
}, [key]);
return [value, setStoredValue] as const;
}事件处理
常见事件类型
tsx
// 鼠标事件
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget);
};
// 表单事件
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
// 输入事件
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// 键盘事件
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// ...
}
};
// 焦点事件
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
};
// 拖拽事件
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData('text', 'data');
};事件处理函数类型
tsx
// 使用内置类型
interface Props {
onClick: React.MouseEventHandler<HTMLButtonElement>;
onChange: React.ChangeEventHandler<HTMLInputElement>;
onSubmit: React.FormEventHandler<HTMLFormElement>;
}
// 自定义事件处理
interface Props {
onSelect: (id: string, data: ItemData) => void;
onError?: (error: Error) => void;
}表单处理
受控组件
tsx
interface FormData {
username: string;
email: string;
role: 'admin' | 'user';
}
function Form() {
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
role: 'user'
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input
name="username"
value={formData.username}
onChange={handleChange}
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<select name="role" value={formData.role} onChange={handleChange}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button type="submit">Submit</button>
</form>
);
}表单验证
tsx
interface ValidationErrors {
[key: string]: string | undefined;
}
function useFormValidation<T extends Record<string, unknown>>(
values: T,
validate: (values: T) => ValidationErrors
) {
const [errors, setErrors] = useState<ValidationErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const handleBlur = (field: keyof T) => {
setTouched(prev => ({ ...prev, [field]: true }));
const newErrors = validate(values);
setErrors(newErrors);
};
const isValid = Object.keys(errors).length === 0;
return { errors, touched, handleBlur, isValid };
}泛型组件
列表组件
tsx
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// 使用
interface User {
id: number;
name: string;
}
<List<User>
items={users}
keyExtractor={user => user.id}
renderItem={user => <span>{user.name}</span>}
/>选择组件
tsx
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue
}: SelectProps<T>) {
return (
<select
value={value ? String(getValue(value)) : ''}
onChange={e => {
const option = options.find(
opt => String(getValue(opt)) === e.target.value
);
if (option) onChange(option);
}}
>
<option value="">请选择</option>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}表格组件
tsx
interface Column<T> {
key: keyof T | string;
title: string;
render?: (value: unknown, record: T, index: number) => React.ReactNode;
width?: number | string;
}
interface TableProps<T> {
columns: Column<T>[];
data: T[];
rowKey: keyof T | ((record: T) => string);
loading?: boolean;
}
function Table<T extends Record<string, unknown>>({
columns,
data,
rowKey,
loading
}: TableProps<T>) {
const getRowKey = (record: T): string => {
if (typeof rowKey === 'function') {
return rowKey(record);
}
return String(record[rowKey]);
};
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)} style={{ width: col.width }}>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((record, index) => (
<tr key={getRowKey(record)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(record[col.key as keyof T], record, index)
: String(record[col.key as keyof T] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}高阶组件 (HOC)
tsx
// withLoading HOC
interface WithLoadingProps {
loading: boolean;
}
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingComponent({
loading,
...props
}: P & WithLoadingProps) {
if (loading) {
return <div>Loading...</div>;
}
return <WrappedComponent {...(props as P)} />;
};
}
// withAuth HOC
interface WithAuthProps {
isAuthenticated: boolean;
}
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithAuthComponent(props: P & WithAuthProps) {
const { isAuthenticated, ...rest } = props;
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <WrappedComponent {...(rest as P)} />;
};
}
// 使用
const ProtectedDashboard = withAuth(Dashboard);Render Props
tsx
interface RenderProps<T> {
data: T;
loading: boolean;
error: Error | null;
}
interface FetcherProps<T> {
url: string;
children: (props: RenderProps<T>) => React.ReactNode;
}
function Fetcher<T>({ url, children }: FetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{children({ data: data as T, loading, error })}</>;
}
// 使用
<Fetcher<User[]> url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <UserList users={data} />;
}}
</Fetcher>常见类型工具
tsx
// 获取组件 Props 类型
type ButtonProps = React.ComponentProps<typeof Button>;
type InputProps = React.ComponentProps<'input'>;
// 获取 Ref 类型
type InputRef = React.ElementRef<'input'>; // HTMLInputElement
// PropsWithChildren
type Props = React.PropsWithChildren<{
title: string;
}>;
// PropsWithRef
type Props = React.PropsWithRef<{
value: string;
}>;面试常见问题
1. React.FC 的问题是什么?
tsx
// React.FC 隐式包含 children
// 即使组件不需要 children
const Button: React.FC<{ text: string }> = ({ text, children }) => {
// children 始终存在(可能是 undefined)
};
// 推荐:直接标注参数类型
function Button({ text }: { text: string }) {
// 不接受 children
}2. 如何正确类型化事件处理?
tsx
// 内联函数自动推断
<button onClick={(e) => console.log(e.target)} />
// 独立函数需要显式类型
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
console.log(e.currentTarget);
};
// 或者参数类型
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget);
};3. useRef 的三种使用场景?
tsx
// 1. DOM 引用(只读)
const inputRef = useRef<HTMLInputElement>(null);
// 2. 可变值存储
const countRef = useRef<number>(0);
// 3. 保存上一次值
const prevValueRef = useRef<string>();4. 如何类型化 forwardRef?
tsx
interface InputProps {
label: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label }, ref) => {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
);5. 如何处理泛型组件?
tsx
// 函数声明式
function List<T>(props: ListProps<T>) { ... }
// 箭头函数需要特殊语法(TSX 中)
const List = <T,>(props: ListProps<T>) => { ... };
// 或
const List = <T extends unknown>(props: ListProps<T>) => { ... };总结
TypeScript + React 的核心模式:
- 组件类型:优先使用参数类型标注而非 React.FC
- Hooks 类型:合理使用泛型参数和类型推断
- 事件处理:使用 React 内置事件类型
- 泛型组件:提升组件复用性和类型安全
- 类型工具:善用 ComponentProps、ElementRef 等
掌握这些模式能够编写类型安全、可维护的 React 应用。