React 高阶技巧实战
一、自定义 Hooks
1.1 异步状态管理
// hooks/useAsync.ts
import { useState, useCallback } from 'react';
interface AsyncState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
interface UseAsyncReturn<T> extends AsyncState<T> {
execute: () => Promise<void>;
reset: () => void;
}
export function useAsync<T>(
asyncFn: () => Promise<T>,
options: { immediate?: boolean; onSuccess?: (data: T) => void; onError?: (e: Error) => void } = {}
): UseAsyncReturn<T> {
const { immediate = true, onSuccess, onError } = options;
const [state, setState] = useState<AsyncState<T>>({
data: null,
error: null,
loading: false
});
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const result = await asyncFn();
setState({ data: result, error: null, loading: false });
onSuccess?.(result);
} catch (err) {
const error = err as Error;
setState(prev => ({ ...prev, error, loading: false }));
onError?.(error);
}
}, [asyncFn, onSuccess, onError]);
const reset = useCallback(() => {
setState({ data: null, error: null, loading: false });
}, []);
if (immediate) {
// 使用 useEffect 会在组件首次挂载时执行
const { useEffect } = require('react');
useEffect(() => { execute(); }, []);
}
return { ...state, execute, reset };
}
// 使用
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, execute: refresh } = useAsync(
() => fetch(`/api/user/${userId}`).then(r => r.json()),
{ immediate: true }
);
if (loading) return <Skeleton />;
if (error) return <ErrorFallback error={error} onRetry={refresh} />;
return <div>{user.name}</div>;
}
1.2 自动保存表单
// hooks/useAutoSave.ts
import { useRef, useEffect, useCallback } from 'react';
import { debounce } from 'lodash-es';
interface UseAutoSaveOptions<T> {
data: T;
onSave: (data: T) => Promise<void>;
delay?: number;
onSaveStart?: () => void;
onSaveEnd?: (success: boolean) => void;
}
interface UseAutoSaveReturn {
isSaving: boolean;
lastSaved: Date | null;
saveNow: () => Promise<void>;
}
export function useAutoSave<T>({
data,
onSave,
delay = 3000,
onSaveStart,
onSaveEnd
}: UseAutoSaveOptions<T>): UseAutoSaveReturn {
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const isFirstRender = useRef(true);
const save = useCallback(async () => {
setIsSaving(true);
onSaveStart?.();
try {
await onSave(data);
setLastSaved(new Date());
onSaveEnd?.(true);
} catch (e) {
console.error('Auto-save failed:', e);
onSaveEnd?.(false);
} finally {
setIsSaving(false);
}
}, [data, onSave, onSaveStart, onSaveEnd]);
const debouncedSave = useMemo(
() => debounce(save, delay),
[save, delay]
);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
debouncedSave();
return () => debouncedSave.cancel();
}, [data, debouncedSave]);
const saveNow = useCallback(async () => {
debouncedSave.cancel();
await save();
}, [save, debouncedSave]);
return { isSaving, lastSaved, saveNow };
}
// 使用
function ArticleEditor() {
const [form, setForm] = useState({ title: '', content: '' });
const [status, setStatus] = useState('');
const { isSaving, lastSaved } = useAutoSave({
data: form,
onSave: (data) => api.saveDraft(data),
delay: 5000,
onSaveStart: () => setStatus('保存中...'),
onSaveEnd: (success) => setStatus(success ? '已保存' : '保存失败')
});
return (
<div>
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} />
<textarea value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />
<span>{status}</span>
{isSaving && <Spinner />}
</div>
);
}
1.3 无限滚动
// hooks/useInfiniteScroll.ts
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseInfiniteScrollOptions {
fetchMore: (page: number) => Promise<{ data: any[]; hasMore: boolean }>;
threshold?: number;
immediate?: boolean;
}
interface UseInfiniteScrollReturn {
items: any[];
loading: boolean;
finished: boolean;
error: Error | null;
containerRef: React.RefObject<HTMLDivElement>;
reset: () => void;
}
export function useInfiniteScroll({
fetchMore,
threshold = 100,
immediate = true
}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const containerRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [finished, setFinished] = useState(false);
const [error, setError] = useState<Error | null>(null);
const isFetchingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loading || finished || isFetchingRef.current) return;
isFetchingRef.current = true;
setLoading(true);
setError(null);
try {
const result = await fetchMore(page);
setItems(prev => [...prev, ...result.data]);
setFinished(!result.hasMore);
if (result.hasMore) setPage(p => p + 1);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
isFetchingRef.current = false;
}
}, [fetchMore, page, loading, finished]);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight <= threshold) {
loadMore();
}
}, [loadMore, threshold]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
useEffect(() => {
if (immediate && !finished) {
loadMore();
}
}, []);
const reset = useCallback(() => {
setItems([]);
setPage(1);
setFinished(false);
setError(null);
loadMore();
}, [loadMore]);
return { items, loading, finished, error, containerRef, reset };
}
// 使用
function FeedList() {
const fetchPage = async (page: number) => {
const res = await api.getPosts(page);
return { data: res.items, hasMore: res.hasMore };
};
const { items, containerRef, loading, finished } = useInfiniteScroll({ fetchMore: fetchPage });
return (
<div ref={containerRef} style={{ height: '400px', overflow: 'auto' }}>
{items.map(item => <PostCard key={item.id} data={item} />)}
{loading && <LoadingSpinner />}
{finished && <div>没有更多了</div>}
</div>
);
}
二、Render Props 与组件组合
2.1 动态表格列
// components/DataGrid.tsx
import React from 'react';
interface Column<T> {
key: keyof T | string;
title: string;
width?: number;
render?: (row: T, index: number) => React.ReactNode;
}
interface DataGridProps<T> {
data: T[];
columns: Column<T>[];
rowKey?: keyof T;
emptyText?: string;
}
export function DataGrid<T extends Record<string, any>>({
data,
columns,
rowKey = 'id' as keyof T,
emptyText = '暂无数据'
}: DataGridProps<T>) {
return (
<table className="data-grid">
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)} style={{ width: col.width }}>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="empty-cell">
{emptyText}
</td>
</tr>
) : (
data.map((row, idx) => (
<tr key={String(row[rowKey])}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(row, idx)
: (row as any)[col.key as string]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
// 使用
function UserTable() {
return (
<DataGrid
data={users}
rowKey="id"
columns={[
{ key: 'name', title: '姓名' },
{ key: 'status', title: '状态', render: (row) => (
<span className={row.status === 1 ? 'tag-success' : 'tag-error'}>
{row.status === 1 ? '启用' : '禁用'}
</span>
)},
{ key: 'action', title: '操作', render: (row) => (
<button onClick={() => handleEdit(row)}>编辑</button>
)}
]}
/>
);
}
2.2 递归菜单组件
// components/RecursiveMenu.tsx
import React, { useState } from 'react';
interface MenuItem {
id: string;
label: string;
icon?: string;
children?: MenuItem[];
onClick?: () => void;
}
interface MenuItemProps {
item: MenuItem;
level?: number;
}
function MenuItemComponent({ item, level = 0 }: MenuItemProps) {
const [expanded, setExpanded] = useState(false);
const hasChildren = item.children && item.children.length > 0;
const handleClick = () => {
if (hasChildren) {
setExpanded(!expanded);
} else {
item.onClick?.();
}
};
return (
<div className="menu-item-wrapper">
<div
className="menu-item"
style={{ paddingLeft: level * 16 + 'px' }}
onClick={handleClick}
>
{item.icon && <i className={item.icon} />}
<span>{item.label}</span>
{hasChildren && (
<i className={expanded ? 'icon-arrow-down' : 'icon-arrow-right'} />
)}
</div>
{hasChildren && expanded && (
<div className="menu-children">
{item.children!.map(child => (
<MenuItemComponent key={child.id} item={child} level={level + 1} />
))}
</div>
)}
</div>
);
}
interface RecursiveMenuProps {
items: MenuItem[];
}
export function RecursiveMenu({ items }: RecursiveMenuProps) {
return (
<div className="recursive-menu">
{items.map(item => (
<MenuItemComponent key={item.id} item={item} />
))}
</div>
);
}
// 使用
function App() {
const menuData: MenuItem[] = [
{
id: '1',
label: '系统管理',
icon: 'icon-settings',
children: [
{ id: '1-1', label: '用户管理', onClick: () => navigate('/users') },
{ id: '1-2', label: '角色管理', onClick: () => navigate('/roles') }
]
},
{ id: '2', label: '订单管理', onClick: () => navigate('/orders') }
];
return <RecursiveMenu items={menuData} />;
}
2.3 Render Props 模式
// components/MouseTracker.tsx
import React, { useState, useCallback, useRef, useEffect } from 'react';
interface Position {
x: number;
y: number;
}
interface MouseTrackerProps {
render: (position: Position) => React.ReactNode;
}
export function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const handleMouseMove = useCallback((e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
}, []);
useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [handleMouseMove]);
return <>{render(position)}</>;
}
// 使用
function App() {
return (
<MouseTracker render={({ x, y }) => (
<div>
鼠标位置: ({x}, {y})
<FollowCursor x={x} y={y} />
</div>
)} />
);
}
三、Context 与状态管理
3.1 类型安全的 Context
// context/FormContext.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface FormContextValue {
values: Record<string, any>;
errors: Record<string, string>;
touched: Record<string, boolean>;
setValue: (field: string, value: any) => void;
setError: (field: string, error: string) => void;
setTouched: (field: string) => void;
validate: () => boolean;
reset: () => void;
}
const FormContext = createContext<FormContextValue | null>(null);
interface FormProviderProps {
initialValues: Record<string, any>;
validationRules?: Record<string, (value: any) => string | null>;
onSubmit?: (values: Record<string, any>) => Promise<void>;
children: ReactNode;
}
export function FormProvider({
initialValues,
validationRules = {},
onSubmit,
children
}: FormProviderProps) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const setValue = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }));
if (touched[field] && validationRules[field]) {
const error = validationRules[field](value);
setErrors(prev => ({ ...prev, [field]: error || '' }));
}
}, [touched, validationRules]);
const setError = useCallback((field: string, error: string) => {
setErrors(prev => ({ ...prev, [field]: error }));
}, []);
const setTouched = useCallback((field: string) => {
setTouched(prev => ({ ...prev, [field]: true }));
if (validationRules[field]) {
const error = validationRules[field](values[field]);
setErrors(prev => ({ ...prev, [field]: error || '' }));
}
}, [values, validationRules]);
const validate = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
let isValid = true;
Object.entries(validationRules).forEach(([field, rule]) => {
const error = rule(values[field]);
if (error) {
newErrors[field] = error;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
}, [values, validationRules]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const allTouched = Object.keys(validationRules).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setTouched(allTouched);
if (!validate()) return;
await onSubmit?.(values);
};
const value: FormContextValue = {
values, errors, touched,
setValue, setError, setTouched,
validate, reset
};
return (
<FormContext.Provider value={value}>
<form onSubmit={handleSubmit}>{children}</form>
</FormContext.Provider>
);
}
// 消费 Context
export function useFormContext() {
const context = useContext(FormContext);
if (!context) {
throw new Error('useFormContext must be used within FormProvider');
}
return context;
}
// 使用
function LoginForm() {
return (
<FormProvider
initialValues={{ email: '', password: '' }}
validationRules={{
email: (v) => /^\S+@\S+$/.test(v) ? null : '邮箱格式不正确',
password: (v) => v.length >= 6 ? null : '密码至少6位'
}}
onSubmit={async (values) => { await api.login(values); }}
>
<FormField name="email" label="邮箱">
<FormInput name="email" type="email" />
<FormError name="email" />
</FormField>
<FormField name="password" label="密码">
<FormInput name="password" type="password" />
<FormError name="password" />
</FormField>
<SubmitButton>登录</SubmitButton>
</FormProvider>
);
}
3.2 跨层级状态共享(轻量级)
// hooks/useSharedState.ts
import { useState, useCallback, useRef } from 'react';
const store = new Map<string, { value: any; subscribers: Set<() => void> }>();
export function useSharedState<T>(key: string, initialValue: T): [T, (value: T) => void] {
if (!store.has(key)) {
store.set(key, { value: initialValue, subscribers: new Set() });
}
const [state, setState] = useState<T>(() => store.get(key)!.value);
const entry = store.get(key)!;
const wrappedSetState = useCallback((value: T) => {
entry.value = value;
setState(value);
entry.subscribers.forEach(callback => callback());
}, []);
const wrapperSetState = useCallback((value: T | ((prev: T) => T)) => {
const resolvedValue = typeof value === 'function'
? (value as (prev: T) => T)(entry.value)
: value;
wrappedSetState(resolvedValue);
}, [wrappedSetState]);
return [state, wrapperSetState];
}
// 组件 A
function ComponentA() {
const [count, setCount] = useSharedState('globalCount', 0);
return <button onClick={() => setCount(count + 1)}>A: {count}</button>;
}
// 组件 B(任意层级)
function ComponentB() {
const [count] = useSharedState('globalCount', 0);
return <span>B: {count}</span>;
}
3.3 状态机模式
// hooks/useStateMachine.ts
type StateMachine<S, A> = {
currentState: S;
dispatch: (action: A) => void;
};
export function useStateMachine<S extends string, A extends string>(
config: Record<S, Record<A, { nextState: S; sideEffect?: () => void }>>
) {
const [currentState, setCurrentState] = useState<S>(Object.keys(config)[0] as S);
const sideEffectRef = useRef<(() => void) | null>(null);
const dispatch = useCallback((action: A) => {
const stateConfig = config[currentState];
if (!stateConfig || !stateConfig[action]) return;
const { nextState, sideEffect } = stateConfig[action];
setCurrentState(nextState);
if (sideEffect) {
sideEffectRef.current = sideEffect;
sideEffect();
}
}, [currentState, config]);
return { currentState, dispatch } as StateMachine<S, A>;
}
// 使用
interface FetchStates {
idle: { fetch: { nextState: 'loading' } };
loading: { succeed: { nextState: 'success'; sideEffect: () => void }; fail: { nextState: 'error' } };
success: { refresh: { nextState: 'loading' } };
error: { retry: { nextState: 'loading' } };
}
function useFetch<T>(fetchFn: () => Promise<T>) {
const { currentState, dispatch } = useStateMachine<FetchStates>({
idle: { fetch: { nextState: 'loading' } },
loading: {
succeed: { nextState: 'success', sideEffect: () => console.log('Fetched!') },
fail: { nextState: 'error' }
},
success: { refresh: { nextState: 'loading' } },
error: { retry: { nextState: 'loading' } }
});
const trigger = async () => {
dispatch('fetch');
try {
await fetchFn();
dispatch('succeed');
} catch {
dispatch('fail');
}
};
return { state: currentState, trigger, retry: () => dispatch('retry') };
}
四、性能优化技巧
4.1 虚拟列表
// components/VirtualList.tsx
import React, { useState, useRef, useCallback, useEffect, ReactNode } from 'react';
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
containerHeight: number;
renderItem: (item: T, index: number) => ReactNode;
overscan?: number;
}
export function VirtualList<T>({
items,
itemHeight,
containerHeight,
renderItem,
overscan = 5
}: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const totalHeight = items.length * itemHeight;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(items.length, startIndex + visibleCount + overscan * 2);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, index) => (
<div key={startIndex + index} style={{ height: itemHeight }}>
{renderItem(item, startIndex + index)}
</div>
))}
</div>
</div>
</div>
);
}
// 使用
function App() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}));
return (
<VirtualList
items={items}
itemHeight={50}
containerHeight={400}
renderItem={(item) => <div>{item.name}</div>}
/>
);
}
4.2 useMemo 与 useCallback 策略
// 组件中的优化实践
function ProductList({ categoryId }: { categoryId: string }) {
const [searchText, setSearchText] = useState('');
const [sortKey, setSortKey] = useState<'name' | 'price'>('name');
// 基础数据
const allProducts = useQuery(['products', categoryId], () => api.getProducts(categoryId));
// 筛选逻辑
const filteredProducts = useMemo(() => {
if (!searchText) return allProducts;
return allProducts.filter(p =>
p.name.toLowerCase().includes(searchText.toLowerCase())
);
}, [allProducts, searchText]);
// 排序逻辑(独立计算)
const sortedProducts = useMemo(() => {
return [...filteredProducts].sort((a, b) => {
if (sortKey === 'price') return a.price - b.price;
return a.name.localeCompare(b.name);
});
}, [filteredProducts, sortKey]);
// 事件处理
const handleItemClick = useCallback((productId: string) => {
navigate(`/product/${productId}`);
}, []);
const handleAddToCart = useCallback((productId: string, quantity: number) => {
dispatch(addToCart({ productId, quantity }));
}, []);
// 避免过度 memo(简单计算不需要)
const totalCount = allProducts.length; // 不需要 useMemo
return (
<div>
<SearchInput value={searchText} onChange={setSearchText} />
<SortButtons value={sortKey} onChange={setSortKey} />
{sortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onClick={() => handleItemClick(product.id)}
onAddToCart={(qty) => handleAddToCart(product.id, qty)}
/>
))}
</div>
);
}
4.3 React.lazy 与 Suspense
// routes/index.tsx
import React, { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Users = lazy(() => import('./pages/Users'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
function LoadingFallback() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-content" />
<div className="skeleton-sidebar" />
</div>
);
}
function ErrorBoundary({ children, fallback }: { children: React.ReactNode; fallback: React.ReactNode }) {
const [hasError, setHasError] = useState(false);
return hasError ? <>{fallback}</> : <>{children}</>;
}
function AppRouter() {
const [routes] = useState([
{ path: '/dashboard', component: Dashboard },
{ path: '/users', component: Users },
{ path: '/settings', component: Settings },
{ path: '/reports', component: Reports }
]);
return (
<Routes>
{routes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<ErrorBoundary fallback={<div>加载失败</div>}>
<Suspense fallback={<LoadingFallback />}>
<route.component />
</Suspense>
</ErrorBoundary>
}
/>
))}
</Routes>
);
}
4.4 ref 回调模式
// 稳定的 ref 回调
function RefCallbackDemo() {
const [showTip, setShowTip] = useState(false);
// 使用 useCallback 确保回调稳定,避免重复渲染
const buttonRef = useCallback((node: HTMLButtonElement | null) => {
if (node) {
node.dataset.initialized = 'true';
console.log('Button mounted');
}
}, []);
return (
<div>
<button ref={buttonRef} onClick={() => setShowTip(!showTip)}>
Toggle Tooltip
</button>
{showTip && <Tooltip>提示信息</Tooltip>}
</div>
);
}
// 多个 ref 的合并
function MultiRefDemo() {
const localRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const setRefs = useCallback((node: HTMLDivElement | null) => {
localRef.current = node;
if (node) {
setMounted(true);
node.classList.add('mounted');
}
}, []);
return <div ref={setRefs}>{mounted && 'Ready'}</div>;
}
五、TypeScript 高级模式
5.1 Props 类型推导
// components/types.ts
import React from 'react';
// 基础 Props
interface BaseProps {
className?: string;
style?: React.CSSProperties;
'data-testid'?: string;
}
// Props 组合
interface ButtonProps extends BaseProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
onClick?: (e: React.MouseEvent) => void;
children: React.ReactNode;
icon?: React.ReactNode;
}
// Props 排除/提取
type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
value: string;
onChange: (value: string) => void;
error?: string;
};
// 泛型组件
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
emptyText?: string;
}
export function List<T>({ items, renderItem, keyExtractor, emptyText = '暂无数据' }: ListProps<T>) {
if (items.length === 0) return <div className="empty">{emptyText}</div>;
return (
<div className="list">
{items.map((item, index) => (
<div key={keyExtractor(item)}>{renderItem(item, index)}</div>
))}
</div>
);
}
// 使用泛型
function App() {
const users = [{ id: 1, name: 'Tom' }, { id: 2, name: 'Jerry' }];
return (
<List
items={users}
keyExtractor={u => u.id}
renderItem={(user, index) => <div>{index + 1}. {user.name}</div>}
/>
);
}
5.2 事件类型安全
// 组件事件定义
interface ButtonEmits {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onMouseEnter: (e: React.MouseEvent<HTMLButtonElement>) => void;
onMouseLeave: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
// 联合类型事件
type ToggleEvents =
| { type: 'toggle'; value: boolean }
| { type: 'change'; value: string }
| { type: 'error'; error: Error };
function useToggleReducer() {
return useReducer((state: any, event: ToggleEvents) => {
switch (event.type) {
case 'toggle':
return { ...state, isOpen: event.value };
case 'change':
return { ...state, value: event.value };
case 'error':
return { ...state, error: event.error };
}
}, { isOpen: false, value: '' });
}
5.3 响应式 Hooks 类型
// 自定义 Hooks 的类型约束
interface UseToggleReturn {
value: boolean;
toggle: () => void;
setTrue: () => void;
setFalse: () => void;
}
function useToggle(initialValue = false): UseToggleReturn {
const [value, setValue] = useState(initialValue);
return {
value,
toggle: useCallback(() => setValue(v => !v), []),
setTrue: useCallback(() => setValue(true), []),
setFalse: useCallback(() => setValue(false), [])
};
}
// 泛型 Hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}, [key, storedValue]);
return [storedValue, setValue] as const;
}
// 使用
function App() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [isDark, toggle, setTrue, setFalse] = useToggle(false);
// 类型自动推导
// theme: 'light' | 'dark'
// isDark: boolean
}
六、React Router 技巧
6.1 路由守卫组合
// hooks/useAuth.ts
import { useContext } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { AuthContext } from '@/context/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermissions?: string[];
}
export function ProtectedRoute({ children, requiredPermissions = [] }: ProtectedRouteProps) {
const { user, hasPermissions, isAuthenticated } = useContext(AuthContext);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredPermissions.length > 0 && !hasPermissions(requiredPermissions)) {
return <Navigate to="/403" replace />;
}
return <>{children}</>;
}
// 使用
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/403" element={<ForbiddenPage />} />
<Route
path="/admin"
element={
<ProtectedRoute requiredPermissions={['admin:access']}>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}
6.2 路由缓存控制
// components/RouteCache.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface CacheContextValue {
cachedRoutes: Set<string>;
addCache: (name: string) => void;
removeCache: (name: string) => void;
clearCache: () => void;
}
const CacheContext = createContext<CacheContextValue | null>(null);
export function CacheProvider({ children }: { children: ReactNode }) {
const [cachedRoutes, setCachedRoutes] = useState<Set<string>>(new Set());
const addCache = useCallback((name: string) => {
setCachedRoutes(prev => new Set([...prev, name]));
}, []);
const removeCache = useCallback((name: string) => {
setCachedRoutes(prev => {
const next = new Set(prev);
next.delete(name);
return next;
});
}, []);
const clearCache = useCallback(() => {
setCachedRoutes(new Set());
}, []);
return (
<CacheContext.Provider value={{ cachedRoutes, addCache, removeCache, clearCache }}>
{children}
</CacheContext.Provider>
);
}
export function RouteView() {
const { cachedRoutes } = useContext(CacheContext)!;
const location = useLocation();
// 根据路由 meta 管理缓存
const routeConfig = useRouteConfig(location.pathname);
if (routeConfig?.meta?.keepAlive && !cachedRoutes.has(location.pathname)) {
// 添加到缓存列表
}
return (
<>
{Array.from(cachedRoutes).map(path => (
<div key={path} style={{ display: 'none' }}>
<RouteComponent path={path} />
</div>
))}
<div>
<RouteComponent path={location.pathname} />
</div>
</>
);
}
6.3 滚动行为控制
// hooks/useScrollReset.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function useScrollReset() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [pathname]);
}
// 使用
function App() {
return (
<>
<useScrollReset />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</>
);
}
七、状态管理进阶(Zustand)
7.1 Store 模块化
// stores/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
interface UserInfo {
id: string;
name: string;
email: string;
}
interface UserState {
token: string | null;
userInfo: UserInfo | null;
isLoggedIn: boolean;
permissions: string[];
}
interface UserActions {
login: (credentials: { email: string; password: string }) => Promise<void>;
logout: () => void;
fetchUserInfo: () => Promise<void>;
hasPermission: (permission: string) => boolean;
}
type UserStore = UserState & UserActions;
export const useUserStore = create<UserStore>()(
devtools(
persist(
(set, get) => ({
token: null,
userInfo: null,
isLoggedIn: false,
permissions: [],
login: async ({ email, password }) => {
const response = await api.login({ email, password });
set({
token: response.token,
userInfo: response.user,
isLoggedIn: true,
permissions: response.user.permissions
});
},
logout: () => {
set({
token: null,
userInfo: null,
isLoggedIn: false,
permissions: []
});
},
fetchUserInfo: async () => {
const user = await api.getUserInfo();
set({ userInfo: user, permissions: user.permissions });
},
hasPermission: (permission: string) => {
return get().permissions.includes(permission);
}
}),
{ name: 'user-storage', partialize: (state) => ({ token: state.token }) }
)
)
);
// 使用
function UserMenu() {
const { userInfo, logout, isLoggedIn } = useUserStore();
if (!isLoggedIn) return <LoginLink />;
return (
<div>
<span>{userInfo?.name}</span>
<button onClick={logout}>退出</button>
</div>
);
}
// 选择性订阅(性能优化)
function Badge() {
const permissions = useUserStore(state => state.permissions);
const isAdmin = useUserStore(state => state.permissions.includes('admin'));
return isAdmin ? <AdminBadge /> : null;
}
// 使用 shallow 比较
function PermissionsList() {
const { permissions, role } = useUserStore(
useShallow(state => ({ permissions: state.permissions, role: state.role }))
);
return <div>{permissions.join(', ')}</div>;
}
7.2 Store 组合
// stores/useAppStore.ts
import { create } from 'zustand';
import { useUserStore } from './userStore';
import { usePermissionStore } from './permissionStore';
interface AppState {
theme: 'light' | 'dark';
sidebarCollapsed: boolean;
}
interface AppActions {
setTheme: (theme: 'light' | 'dark') => void;
toggleSidebar: () => void;
canAccess: (route: string) => boolean;
}
export const useAppStore = create<AppState & AppActions>((set, get) => ({
theme: 'light',
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set(state => ({ sidebarCollapsed: !state.sidebarCollapsed })),
canAccess: (route) => {
const user = useUserStore.getState();
const permission = usePermissionStore.getState();
if (!user.isLoggedIn) return false;
return permission.hasRoute(route);
}
}));
// 跨 Store 订阅
export function useSyncUserPermissions() {
const user = useUserStore();
const permission = usePermissionStore();
useEffect(() => {
if (user.isLoggedIn && user.userInfo) {
permission.loadPermissions(user.userInfo.role);
}
}, [user.isLoggedIn, user.userInfo]);
}
八、React 18 新特性
8.1 useTransition
// components/SearchWithTransition.tsx
import React, { useState, useTransition } from 'react';
function SearchResults({ query }: { query: string }) {
const results = useMemo(() => {
// 大量数据筛选
return allData.filter(item => item.name.includes(query));
}, [query]);
return (
<ul>
{results.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
function SearchPage() {
const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState('');
// 将状态更新标记为非紧急
const [isPending, startTransition] = useTransition();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
// 标记为 transition,避免阻塞 UI
startTransition(() => {
setQuery(value);
});
};
return (
<div>
<input value={inputValue} onChange={handleInputChange} />
{isPending ? <Spinner /> : <SearchResults query={query} />}
</div>
);
}
8.2 useDeferredValue
// components/FilteredList.tsx
import React, { useState, useDeferredValue } from 'react';
function FilteredList({ filter }: { filter: string }) {
const deferredFilter = useDeferredValue(filter);
const items = useMemo(() => {
return heavyList.filter(item =>
item.name.includes(deferredFilter)
);
}, [heavyList, deferredFilter]);
const isStale = filter !== deferredFilter;
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{items.map(item => <ListItem key={item.id} item={item} />)}
</div>
);
}
function App() {
const [filter, setFilter] = useState('');
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<FilteredList filter={filter} />
</div>
);
}
8.3 自动批处理
// 行为对比
function OldBehavior() {
const [state, setState] = useState({ count: 0 });
const handleClick = async () => {
// 以前:每个 setState 都会触发一次渲染
const res = await fetch('/api');
setState({ count: 1 }); // 渲染 1
setState({ count: 2 }); // 渲染 2
setError(null); // 渲染 3
};
return null;
}
function NewBehavior() {
const [state, setState] = useState({ count: 0, error: null });
const handleClick = async () => {
// React 18:所有更新自动批处理,只触发一次渲染
const res = await fetch('/api');
setState(prev => ({ ...prev, count: 1 }));
setState(prev => ({ ...prev, count: 2 }));
setState(prev => ({ ...prev, error: null }));
// 只会触发一次渲染!
};
return null;
}
8.4 Suspense 服务端渲染
// SSR 组件
import { lazy, Suspense } from 'react';
const ProductDetails = lazy(() => import('./ProductDetails'));
function ProductPage({ productId }: { productId: string }) {
const product = useQuery(['product', productId], () => api.getProduct(productId));
return (
<div>
<h1>{product.name}</h1>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={productId} />
</Suspense>
</div>
);
}
// 流式渲染
import { renderToPipeableStream } from 'react-dom/server';
app.get('/product/:id', async (req, res) => {
const stream = renderToPipeableStream(<ProductPage productId={req.params.id} />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
stream.pipe(res);
},
onError(error) {
console.error(error);
}
});
});
九、更多自定义 Hooks
9.1 事件监听 Hook
// hooks/useEventListener.ts
import { useEffect, useRef } from 'react';
type EventListener<K extends keyof WindowEventMap> = (event: WindowEventMap[K]) => void;
export function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: EventListener<K>,
target?: EventTarget | null,
options?: boolean | AddEventListenerOptions
): void {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const element = target ?? window;
const listener: EventListener = (e) => handlerRef.current(e as any);
element.addEventListener(event, listener, options);
return () => element.removeEventListener(event, listener);
}, [event, target, options]);
}
// 使用 - 监听窗口大小变化
function ResponsiveComponent() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEventListener('resize', () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
});
return <div>{size.width} x {size.height}</div>;
}
// 使用 - 键盘快捷键
function useKeyboardShortcut(keys: string[], callback: () => void) {
useEventListener('keydown', (e) => {
if (keys.includes(e.key)) {
e.preventDefault();
callback();
}
});
// 使用
// useKeyboardShortcut(['s', 'S'], () => handleSave());
}
9.2 媒体查询 Hook
// hooks/useMediaQuery.ts
import { useState, useEffect } from 'react';
interface UseMediaQueryOptions {
initialMatch?: boolean;
}
export function useMediaQuery(
query: string,
options?: UseMediaQueryOptions
): boolean {
const { initialMatch = false } = options ?? {};
const [matches, setMatches] = useState(initialMatch);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 预设断点
const breakpoints = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
'2xl': '(min-width: 1536px)'
};
function useBreakpoints() {
return Object.entries(breakpoints).reduce(
(acc, [key, value]) => ({
...acc,
[key]: useMediaQuery(value)
}),
{} as Record<keyof typeof breakpoints, boolean>
);
}
// 使用
function ResponsiveLayout() {
const isMobile = !useMediaQuery('(min-width: 768px)');
const isTablet = useMediaQuery('(min-width: 768px)') && !useMediaQuery('(min-width: 1024px)');
const isDesktop = useMediaQuery('(min-width: 1024px)');
const bp = useBreakpoints();
// bp.sm, bp.md, bp.lg, bp.xl, bp['2xl']
return (
<>
{isMobile && <MobileNav />}
{(isTablet || isDesktop) && <Sidebar />}
{bp.xl && <AdvancedFeatures />}
</>
);
}
9.3 本地存储 Hook
// hooks/useLocalStorage.ts
import { useState, useCallback, useRef } from 'react';
import { parseJSON } from '@/utils/json';
type SetValue<T> = T | ((prevValue: T) => T);
export function useLocalStorage<T>(
key: string,
initialValue: T,
options?: {
serialize?: (value: T) => string;
deserialize?: (value: string) => T;
}
): [T, (value: SetValue<T>) => void, () => void] {
const { serialize = JSON.stringify, deserialize = parseJSON } = options ?? {};
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? deserialize(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValueRef = useRef<(value: SetValue<T>) => void>();
setValueRef.current = useCallback((value: SetValue<T>) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (valueToStore === undefined || valueToStore === null) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, serialize(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue, serialize]);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValueRef.current!, removeValue];
}
// 使用
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>(
'app-theme',
'light'
);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<button onClick={toggleTheme}>
当前主题:{theme}
</button>
);
}
9.4 网络状态检测
// hooks/useNetworkStatus.ts
import { useState, useEffect } from 'react';
interface NetworkStatus {
online: boolean;
downlink: number | null;
effectiveType: string | null;
rtt: number | null;
saveData: boolean | null;
}
export function useNetworkStatus(): NetworkStatus {
const [status, setStatus] = useState<NetworkStatus>({
online: navigator.onLine,
downlink: null,
effectiveType: null,
rtt: null,
saveData: null
});
useEffect(() => {
const connection = (navigator as any)?.connection;
const updateOnlineStatus = () =>
setStatus(prev => ({ ...prev, online: navigator.onLine }));
const updateConnectionInfo = () => {
if (!connection) return;
setStatus(prev => ({
...prev,
downlink: connection.downlink,
effectiveType: connection.effectiveType,
rtt: connection.rtt,
saveData: connection.saveData
}));
};
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
if (connection) {
connection.addEventListener('change', updateConnectionInfo);
updateConnectionInfo();
}
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
connection?.removeEventListener('change', updateConnectionInfo);
};
}, []);
return status;
}
// 使用
function NetworkAwareApp() {
const network = useNetworkStatus();
useEffect(() => {
if (!network.online) {
showOfflineNotification();
}
}, [network.online]);
// 根据网络质量调整加载策略
const shouldLoadImages =
network.downlink !== null && network.downlink > 1.5 ||
network.effectiveType !== '2g' && network.effectiveType !== 'slow-2g';
return (
<div className={network.online ? '' : 'offline-mode'}>
{!network.online && <OfflineBanner />}
{shouldLoadImages ? <ImageGallery /> : <TextOnlyContent />}
</div>
);
}
9.5 复制到剪贴板
// hooks/useClipboard.ts
import { useState, useCallback } from 'react';
interface UseClipboardOptions {
successDuration?: number;
}
interface UseClipboardReturn {
copied: boolean;
copy: (text: string) => Promise<boolean>;
resetCopied: () => void;
}
export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn {
const { successDuration = 2000 } = options;
const [copied, setCopied] = useState(false);
const timerRef = useRef<NodeJS.Timeout>();
const copy = useCallback(async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
timerRef.current = setTimeout(() => setCopied(false), successDuration);
return true;
} catch (err) {
console.error('Failed to copy:', err);
setCopied(false);
return false;
}
}, [successDuration]);
const resetCopied = useCallback(() => {
setCopied(false);
clearTimeout(timerRef.current);
}, []);
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return { copied, copy, resetCopied };
}
// 使用
function CopyButton({ text }: { text: string }) {
const { copied, copy } = useClipboard();
return (
<button onClick={() => copy(text)}>
{copied ? '已复制!' : '复制'}
</button>
);
}
十、表单处理进阶
10.1 复杂表单验证
// hooks/useFormValidation.ts
import { useState, useCallback, useMemo } from 'react';
interface ValidationRule<T> {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: T, allValues: Record<string, any>) => string | null;
message?: string;
}
interface FormConfig<T extends Record<string, any>> {
initialValues: T;
validationRules: Partial<{ [K in keyof T]: ValidationRule<T[K]>[] }>;
onSubmit: (values: T) => Promise<void>;
}
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
submitting: boolean;
}
interface FormActions<T> {
setFieldValue: (field: keyof T, value: T[keyof T]) => void;
setFieldTouched: (field: keyof T) => void;
handleSubmit: (e: React.FormEvent) => Promise<void>;
resetForm: () => void;
validateField: (field: keyof T) => string | null;
validateAll: () => boolean;
}
export function useFormValidation<T extends Record<string, any>>(
config: FormConfig<T>
): FormState<T> & FormActions<T> {
const { initialValues, validationRules, onSubmit } = config;
const [state, setState] = useState<FormState<T>>({
values: { ...initialValues },
errors: {},
touched: {},
submitting: false
});
const validateField = useCallback((field: keyof T): string | null => {
const value = state.values[field];
const rules = validationRules[field];
if (!rules) return null;
for (const rule of rules) {
if (rule.required && (!value || String(value).trim() === '')) {
return rule.message || `${String(field)} 为必填项`;
}
if (rule.minLength && String(value).length < rule.minLength) {
return rule.message || `最少需要 ${rule.minLength} 个字符`;
}
if (rule.maxLength && String(value).length > rule.maxLength) {
return rule.message || `最多 ${rule.maxLength} 个字符`;
}
if (rule.pattern && !rule.pattern.test(String(value))) {
return rule.message || '格式不正确';
}
if (rule.custom) {
const error = rule.custom(value, state.values);
if (error) return error;
}
}
return null;
}, [state.values, validationRules]);
const validateAll = useCallback((): boolean => {
const newErrors: Partial<Record<keyof T, string>> = {};
let isValid = true;
Object.keys(validationRules).forEach(field => {
const error = validateField(field as keyof T);
if (error) {
newErrors[field as keyof T] = error;
isValid = false;
}
});
setState(prev => ({ ...prev, errors: newErrors }));
return isValid;
}, [validateField, validationRules]);
const setFieldValue = useCallback((field: keyof T, value: T[keyof T]) => {
setState(prev => ({
...prev,
values: { ...prev.values, [field]: value },
errors: prev.touched[field]
? { ...prev.errors, [field]: validateField(field) || undefined }
: prev.errors
}));
}, [validateField]);
const setFieldTouched = useCallback((field: keyof T) => {
setState(prev => ({
...prev,
touched: { ...prev.touched, [field]: true },
errors: { ...prev.errors, [field]: validateField(field) || undefined }
}));
}, [validateField]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const allTouched = Object.keys(validationRules).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{} as Partial<Record<keyof T, boolean>>
);
setState(prev => ({ ...prev, touched: allTouched }));
if (!validateAll()) return;
setState(prev => ({ ...prev, submitting: true }));
try {
await onSubmit(state.values);
} finally {
setState(prev => ({ ...prev, submitting: false }));
}
}, [onSubmit, state.values, validateAll, validationRules]);
const resetForm = useCallback(() => {
setState({
values: { ...initialValues },
errors: {},
touched: {},
submitting: false
});
}, [initialValues]);
return {
...state,
setFieldValue,
setFieldTouched,
handleSubmit,
resetForm,
validateField,
validateAll
};
}
// 使用
function RegistrationForm() {
const form = useFormValidation({
initialValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false
},
validationRules: {
username: [
{ required: true, message: '请输入用户名' },
{ minLength: 3, message: '用户名至少3个字符' },
{ maxLength: 20, message: '用户名最多20个字符' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字和下划线' }
],
email: [
{ required: true, message: '请输入邮箱' },
{ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
],
password: [
{ required: true, message: '请输入密码' },
{ minLength: 8, message: '密码至少8位' },
{ custom: (value, all) => {
if (all.confirmPassword && value !== all.confirmPassword) {
return '两次输入的密码不一致';
}
return null;
}}
],
confirmPassword: [
{ required: true, message: '请确认密码' },
{ custom: (value, all) => {
if (all.password && value !== all.password) {
return '两次输入的密码不一致';
}
return null;
}}
],
agreeTerms: [
{ custom: (value) => value ? null : '请同意服务条款' }
]
},
onSubmit: async (values) => {
await api.register(values);
toast.success('注册成功!');
}
});
return (
<form onSubmit={form.handleSubmit}>
<FormField label="用户名" error={form.errors.username}>
<input
name="username"
value={form.values.username}
onChange={(e) => form.setFieldValue('username', e.target.value)}
onBlur={() => form.setFieldTouched('username')}
/>
</FormField>
{/* 其他字段... */}
<button type="submit" disabled={form.submitting}>
{form.submitting ? '注册中...' : '注册'}
</button>
</form>
);
}
10.2 表单数组操作
// hooks/useFieldArray.ts
import { useState, useCallback } from 'react';
interface FieldArrayItem {
id: string;
[key: string]: any;
}
interface UseFieldArrayReturn<T extends FieldArrayItem> {
items: T[];
append: (item: Omit<T, 'id'>) => void;
prepend: (item: Omit<T, 'id'>) => void;
remove: (index: number) => void;
insert: (index: number, item: Omit<T, 'id'>) => void;
move: (fromIndex: number, toIndex: number) => void;
swap: (indexA: number, indexB: number) => void;
replace: (index: number, item: Omit<T, 'id'>) => void;
clear: () => void;
update: (index: number, field: string, value: any) => void;
}
let idCounter = 0;
export function useFieldArray<T extends FieldArrayItem>(
initialItems: Omit<T, 'id'>[] = []
): UseFieldArrayReturn<T> {
const [items, setItems] = useState<T[]>(
initialItems.map(item => ({ ...item, id: `field-${++idCounter}` }))
);
const append = useCallback((item: Omit<T, 'id'>) => {
setItems(prev => [...prev, { ...item, id: `field-${++idCounter}` }]);
}, []);
const prepend = useCallback((item: Omit<T, 'id'>) => {
setItems(prev => [{ ...item, id: `field-${++idCounter}` }, ...prev]);
}, []);
const remove = useCallback((index: number) => {
setItems(prev => prev.filter((_, i) => i !== index));
}, []);
const insert = useCallback((index: number, item: Omit<T, 'id'>) => {
setItems(prev => [
...prev.slice(0, index),
{ ...item, id: `field-${++idCounter}` },
...prev.slice(index)
]);
}, []);
const move = useCallback((fromIndex: number, toIndex: number) => {
setItems(prev => {
const result = [...prev];
const [moved] = result.splice(fromIndex, 1);
result.splice(toIndex, 0, moved);
return result;
});
}, []);
const swap = useCallback((indexA: number, indexB: number) => {
setItems(prev => {
const result = [...prev];
[result[indexA], result[indexB]] = [result[indexB], result[indexA]];
return result;
});
}, []);
const replace = useCallback((index: number, item: Omit<T, 'id'>) => {
setItems(prev => prev.map((el, i) => i === index ? { ...item, id: el.id } : el));
}, []);
const clear = useCallback(() => {
setItems([]);
}, []);
const update = useCallback((index: number, field: string, value: any) => {
setItems(prev => prev.map((el, i) => i === index ? { ...el, [field]: value } : el));
}, []);
return { items, append, prepend, remove, insert, move, swap, replace, clear, update };
}
// 使用 - 动态表单列表
function ContactForm() {
const contacts = useFieldArray([
{ name: '', phone: '', email: '', relation: 'friend' }
]);
return (
<form>
<h3>联系人信息</h3>
{contacts.items.map((contact, index) => (
<div key={contact.id} className="contact-item">
<input
placeholder="姓名"
value={contact.name}
onChange={(e) => contacts.update(index, 'name', e.target.value)}
/>
<input
placeholder="电话"
value={contact.phone}
onChange={(e) => contacts.update(index, 'phone', e.target.value)}
/>
<button type="button" onClick={() => contacts.remove(index)}>
删除
</button>
<button
type="button"
onClick={() => contacts.insert(index + 1, { name: '', phone: '', email: '', relation: 'other' })}
>
在下方添加
</button>
</div>
))}
<button type="button" onClick={() => contacts.append({ name: '', phone: '', email: '', relation: 'other' })}>
添加联系人
</button>
</form>
);
}
10.3 表单联动与依赖
// hooks/useDependentFields.ts
import { useState, useEffect, useCallback } from 'react';
interface FieldDependency {
source: string;
target: string;
transform: (sourceValue: any) => any;
condition?: (sourceValue: any) => boolean;
}
export function useFormDependencies<T extends Record<string, any>>(
initialValues: T,
dependencies: FieldDependency[]
) {
const [values, setValues] = useState<T>(initialValues);
const [disabledFields, setDisabledFields] = useState<Set<string>>(new Set());
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
const updateField = useCallback((field: keyof T, value: any) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
useEffect(() => {
dependencies.forEach(dep => {
const sourceValue = values[dep.source];
if (dep.condition && !dep.condition(sourceValue)) {
return;
}
const newValue = dep.transform(sourceValue);
if (newValue !== values[dep.target]) {
setValues(prev => ({ ...prev, [dep.target]: newValue }));
}
});
}, [values, dependencies]);
const setFieldDisabled = useCallback((fields: string[], disabled: boolean) => {
setDisabledFields(prev => {
const next = new Set(prev);
fields.forEach(f => disabled ? next.add(f) : next.delete(f));
return next;
});
}, []);
const setFieldHidden = useCallback((fields: string[], hidden: boolean) => {
setHiddenFields(prev => {
const next = new Set(prev);
fields.forEach(f => hidden ? next.add(f) : next.delete(f));
return next;
});
}, []);
return {
values,
updateField,
disabledFields,
hiddenFields,
setFieldDisabled,
setFieldHidden
};
}
// 使用 - 地区级联选择器
function AddressForm() {
const { values, updateField, disabledFields } = useFormDependencies(
{ province: '', city: '', district: '' },
[
{
source: 'province',
target: 'city',
transform: (province) => '',
condition: (v) => !!v
},
{
source: 'city',
target: 'district',
transform: () => ''
}
]
);
useEffect(() => {
if (values.province) {
setFieldDisabled(['city'], false);
} else {
setFieldDisabled(['city', 'district'], true);
}
}, [values.province]);
useEffect(() => {
if (values.city) {
setFieldDisabled(['district'], false);
} else {
setFieldDisabled(['district'], true);
}
}, [values.city]);
return (
<div>
<select
value={values.province}
onChange={(e) => updateField('province', e.target.value)}
>
<option value="">选择省份</option>
{provinces.map(p => <option key={p.code} value={p.code}>{p.name}</option>)}
</select>
<select
value={values.city}
onChange={(e) => updateField('city', e.target.value)}
disabled={disabledFields.has('city')}
>
<option value="">选择城市</option>
{getCities(values.province).map(c => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
<select
value={values.district}
onChange={(e) => updateField('district', e.target.value)}
disabled={disabledFields.has('district')}
>
<option value="">选择区县</option>
{getDistricts(values.city).map(d => <option key={d.code} value={d.code}>{d.name}</option>)}
</select>
</div>
);
}
十一、动画与交互
11.1 CSS 动画 Hook
// hooks/useAnimation.ts
import { useState, useRef, useEffect, useCallback } from 'react';
interface AnimationConfig {
duration?: number;
easing?: string;
delay?: number;
iterations?: number | 'infinite';
}
interface UseAnimationReturn {
ref: React.RefObject<HTMLElement>;
animate: (keyframes: Keyframe[], config?: AnimationConfig) => Animation;
pause: () => void;
resume: () => void;
cancel: () => void;
isAnimating: boolean;
}
export function useAnimation(): UseAnimationReturn {
const ref = useRef<HTMLElement>(null);
const [animation, setAnimation] = useState<Animation | null>(null);
const animate = useCallback(
(keyframes: Keyframe[], config: AnimationConfig = {}): Animation => {
const element = ref.current;
if (!element) throw new Error('Element not found');
const {
duration = 300,
easing = 'ease',
delay = 0,
iterations = 1
} = config;
const anim = element.animate(keyframes, { duration, easing, delay, iterations });
setAnimation(anim);
anim.onfinish = () => setAnimation(null);
return anim;
},
[]
);
const pause = useCallback(() => animation?.pause(), [animation]);
const resume = useCallback(() => animation?.play(), [animation]);
const cancel = useCallback(() => {
animation?.cancel();
setAnimation(null);
}, [animation]);
return {
ref,
animate,
pause,
resume,
cancel,
isAnimating: !!animation
};
}
// 使用 - 淡入淡出
function FadeInOut({ show, children }: { show: boolean; children: React.ReactNode }) {
const { ref, isAnimating } = useAnimation();
useEffect(() => {
if (!ref.current) return;
if (show) {
ref.current.animate(
[
{ opacity: 0, transform: 'translateY(-10px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 200, easing: 'ease-out' }
);
}
}, [show]);
return (
<div
ref={ref}
style={{ display: show ? 'block' : 'none', pointerEvents: isAnimating ? 'none' : 'auto' }}
>
{children}
</div>
);
}
// 使用 - 弹跳效果
function BounceButton() {
const { ref, animate } = useAnimation();
const handleClick = () => {
animate(
[
{ transform: 'scale(1)', offset: 0 },
{ transform: 'scale(0.95)', offset: 0.5 },
{ transform: 'scale(1.05)', offset: 0.7 },
{ transform: 'scale(1)', offset: 1 }
],
{ duration: 300, easing: 'ease-in-out' }
);
};
return <button ref={ref} onClick={handleClick}>点击弹跳</button>;
}
11.2 过渡动画组件
// components/Transition.tsx
import React, { useState, useEffect, useRef, ReactNode } from 'react';
type TransitionState = 'entering' | 'entered' | 'exiting' | 'exited';
interface TransitionProps {
in: boolean;
timeout?: number;
children: ReactNode;
onEnter?: () => void;
onEntered?: () => void;
onExit?: () => void;
onExited?: () => void;
unmountOnExit?: boolean;
}
export function Transition({
in: show,
timeout = 300,
children,
onEnter,
onEntered,
onExit,
onExited,
unmountOnExit = false
}: TransitionProps) {
const [state, setState] = useState<TransitionState>(show ? 'entered' : 'exited');
const shouldRender = !unmountOnExit || state !== 'exited';
const timerRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (show) {
setState('entering');
onEnter?.();
timerRef.current = setTimeout(() => {
setState('entered');
onEntered?.();
}, 10); // 短暂延迟确保 DOM 更新
} else if (state !== 'exited') {
setState('exiting');
onExit?.();
timerRef.current = setTimeout(() => {
setState('exited');
onExited?.();
}, timeout);
}
return () => clearTimeout(timerRef.current);
}, [show]);
if (!shouldRender) return null;
return <div className={`transition-${state}`}>{children}</div>;
}
// 使用
function Modal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
return (
<Transition in={isOpen} timeout={300}>
<div className="modal-overlay" onClick={onClose}>
<Transition in={isOpen} timeout={200}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>模态框</h2>
<p>内容</p>
<button onClick={onClose}>关闭</button>
</div>
</Transition>
</div>
</Transition>
);
}
// CSS 配合
// .transition-entering { opacity: 0; transform: scale(0.9); }
// .transition-entered { opacity: 1; transform: scale(1); transition: all 0.3s ease; }
// .transition-exiting { opacity: 1; transform: scale(1); }
// .transition-exited { opacity: 0; transform: scale(0.9); transition: all 0.3s ease; }
11.3 拖拽交互
// hooks/useDrag.ts
import { useState, useCallback, useRef, useEffect } from 'react';
interface Position {
x: number;
y: number;
}
interface UseDragOptions {
bounds?: HTMLElement | string;
onStart?: (position: Position) => void;
onDrag?: (position: Position) => void;
onEnd?: (position: Position) => void;
disabled?: boolean;
}
interface UseDragReturn {
ref: React.RefObject<HTMLElement>;
position: Position;
isDragging: boolean;
}
export function useDrag(options: UseDragOptions = {}): UseDragReturn {
const { bounds, onStart, onDrag, onEnd, disabled = false } = options;
const ref = useRef<HTMLElement>(null);
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<Position>({ x: 0, y: 0 });
const getBoundsRect = useCallback(() => {
if (!bounds) return null;
if (typeof bounds === 'string') {
return document.querySelector(bounds)?.getBoundingClientRect() ?? null;
}
return bounds.getBoundingClientRect();
}, [bounds]);
const handleMouseDown = useCallback((e: MouseEvent) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y };
onStart?.(position);
}, [disabled, position, onStart]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging || disabled) return;
let newX = e.clientX - dragStartRef.current.x;
let newY = e.clientY - dragStartRef.current.y;
// 边界约束
const boundsRect = getBoundsRect();
const element = ref.current;
if (boundsRect && element) {
const elWidth = element.offsetWidth;
const elHeight = element.offsetHeight;
newX = Math.max(boundsRect.left, Math.min(newX, boundsRect.right - elWidth));
newY = Math.max(boundsRect.top, Math.min(newY, boundsRect.bottom - elHeight));
}
setPosition({ x: newX, y: newY });
onDrag?.({ x: newX, y: newY });
}, [isDragging, disabled, getBoundsRect, onDrag]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
onEnd?.(position);
}, [onEnd, position]);
useEffect(() => {
const element = ref.current;
if (!element) return;
element.addEventListener('mousedown', handleMouseDown);
return () => element.removeEventListener('mousedown', handleMouseDown);
}, [handleMouseDown]);
useEffect(() => {
if (!isDragging) return;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return { ref, position, isDragging };
}
// 使用 - 可拖拽面板
function DraggablePanel() {
const { ref, position, isDragging } = useDrag({
bounds: '.app-container',
onEnd: (pos) => console.log('最终位置:', pos)
});
return (
<div
ref={ref}
style={{
position: 'absolute',
left: position.x,
top: position.y,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: isDragging ? 'none' : 'auto'
}}
>
<div className="panel-header">拖拽我</div>
<div className="panel-body">内容区域</div>
</div>
);
}
11.4 触摸手势
// hooks/useGestures.ts
import { useRef, useCallback, useEffect } from 'react';
interface GestureHandlers {
onTap?: (point: { x: number; y: number }) => void;
onDoubleTap?: (point: { x: number; y: number }) => void;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
onPinchStart?: (distance: number) => void;
onPinchMove?: (scale: number) => void;
onPinchEnd?: (scale: number) => void;
}
interface UseGesturesOptions {
threshold?: number;
swipeThreshold?: number;
doubleTapDelay?: number;
}
export function useGestures(
handlers: GestureHandlers,
options: UseGesturesOptions = {}
): React.RefObject<HTMLElement> {
const {
threshold = 10,
swipeThreshold = 50,
doubleTapDelay = 300
} = options;
const ref = useRef<HTMLElement>(null);
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
const lastTapRef = useRef<number>(0);
const initialPinchDistance = useRef<number>(0);
const handleTouchStart = useCallback((e: TouchEvent) => {
const touch = e.touches[0];
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
// 双击检测
if (Date.now() - lastTapRef.current < doubleTapDelay) {
handlers.onDoubleTap?.({ x: touch.clientX, y: touch.clientY });
}
lastTapRef.current = Date.now();
// 捏合手势开始
if (e.touches.length === 2) {
initialPinchDistance.current = getPinchDistance(e.touches[0], e.touches[1]);
handlers.onPinchStart?.(initialPinchDistance.current);
}
}, [handlers, doubleTapDelay]);
const handleTouchEnd = useCallback((e: TouchEvent) => {
if (!touchStartRef.current) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchStartRef.current.x;
const deltaY = touch.clientY - touchStartRef.current.y;
const deltaTime = Date.now() - touchStartRef.current.time;
// 点击检测(移动距离小且时间短)
if (Math.abs(deltaX) < threshold && Math.abs(deltaY) < threshold && deltaTime < 300) {
handlers.onTap?.({ x: touch.clientX, y: touch.clientY });
}
// 滑动检测
if (Math.abs(deltaX) > swipeThreshold && Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > 0) handlers.onSwipeRight?.();
else handlers.onSwipeLeft?.();
}
if (Math.abs(deltaY) > swipeThreshold && Math.abs(deltaY) > Math.abs(deltaX)) {
if (deltaY > 0) handlers.onSwipeDown?.();
else handlers.onSwipeUp?.();
}
// 捏合结束
if (e.touches.length === 0 && initialPinchDistance.current > 0) {
handlers.onPinchEnd?.(initialPinchDistance.current);
initialPinchDistance.current = 0;
}
touchStartRef.current = null;
}, [handlers, threshold, swipeThreshold]);
const handleTouchMove = useCallback((e: TouchEvent) => {
if (e.touches.length === 2 && initialPinchDistance.current > 0) {
const currentDistance = getPinchDistance(e.touches[0], e.touches[1]);
const scale = currentDistance / initialPinchDistance.current;
handlers.onPinchMove?.(scale);
}
}, [handlers]);
useEffect(() => {
const element = ref.current;
if (!element) return;
element.addEventListener('touchstart', handleTouchStart as EventListener);
element.addEventListener('touchend', handleTouchEnd as EventListener);
element.addEventListener('touchmove', handleTouchMove as EventListener);
return () => {
element.removeEventListener('touchstart', handleTouchStart as EventListener);
element.removeEventListener('touchend', handleTouchEnd as EventListener);
element.removeEventListener('touchmove', handleTouchMove as EventListener);
};
}, [handleTouchStart, handleTouchEnd, handleTouchMove]);
return ref;
}
function getPinchDistance(touch1: Touch, touch2: Touch): number {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// 使用 - 图片查看器
function ImageViewer({ src }: { src: string }) {
const [scale, setScale] = useState(1);
const imageRef = useGestures({
onDoubleTap: () => setScale(prev => prev === 1 ? 2 : 1),
onSwipeLeft: () => nextImage(),
onSwipeRight: () => prevImage(),
onPinchMove: (s) => setScale(Math.max(0.5, Math.min(s, 4)))
});
return (
<div ref={imageRef} className="image-viewer">
<img
src={src}
alt=""
style={{ transform: `scale(${scale})`, transition: 'transform 0.1s' }}
/>
</div>
);
}
十二、数据获取与缓存策略
12.1 React Query 深度使用
// hooks/useQueryWithCache.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from '@/utils/toast';
interface UseQueryOptions<T> {
queryKey: unknown[];
queryFn: () => Promise<T>;
staleTime?: number;
cacheTime?: number;
retry?: number | ((failureCount: number, error: Error) => boolean);
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
export function useDataQuery<T>({
queryKey,
queryFn,
staleTime = 5 * 60 * 1000, // 5分钟
cacheTime = 30 * 60 * 1000, // 30分钟
retry = 2,
onSuccess,
onError
}: UseQueryOptions<T>) {
return useQuery({
queryKey,
queryFn,
staleTime,
cacheTime: cacheTime,
retry,
onSuccess,
onError: (err) => {
onError?.(err as Error);
toast.error('数据加载失败');
},
select: (data) => data
});
}
// 分页查询
function usePaginatedList(page: number, pageSize: number, filters?: Record<string, any>) {
return useQuery({
queryKey: ['list', page, pageSize, filters],
queryFn: () => api.getList(page, pageSize, filters),
keepPreviousData: true, // 切换页时保留旧数据,避免闪烁
staleTime: 30 * 1000
});
}
// 无限查询
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => api.getPosts(pageParam),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
staleTime: 60 * 1000
});
}
// 使用
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfinitePosts();
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.items.map(post => <PostCard key={post.id} post={post} />)}
</React.Fragment>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
</div>
);
}
12.2 乐观更新
// hooks/useOptimisticMutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { produce } from 'immer';
interface OptimisticUpdateOptions<T> {
mutationFn: (data: T) => Promise<any>;
queryKey: string[];
updateFn: (oldData: T[] | undefined, newData: T) => T[];
rollbackMessage?: string;
}
export function useOptimisticMutation<T extends { id: string | number }>({
mutationFn,
queryKey,
updateFn,
rollbackMessage = '操作失败,已回滚'
}: OptimisticUpdateOptions<T>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onMutate: async (newItem) => {
await queryClient.cancelQueries(queryKey);
const previousData = queryClient.getQueryData<T[]>(queryKey);
queryClient.setQueryData<T[]>(queryKey, old =>
updateFn(old, newItem)
);
return { previousData };
},
onError: (_err, _newItem, context) => {
queryClient.setQueryData(queryKey, context.previousData);
toast.error(rollbackMessage);
},
onSettled: () => {
queryClient.invalidateQueries(queryKey);
}
});
}
// 使用 - 点赞功能
function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const likeMutation = useOptimisticMutation<Post>({
mutationFn: (post) => api.toggleLike(post.id),
queryKey: ['posts'],
updateFn: (posts, updatedPost) =>
produce(posts ?? [], draft => {
const index = draft.findIndex(p => p.id === updatedPost.id);
if (index !== -1) {
draft[index].liked = !draft[index].liked;
draft[index].likeCount += draft[index].liked ? 1 : -1;
}
})
});
const post = useGetPost(postId);
return (
<button
onClick={() => likeMutation.mutate(post.data!)}
disabled={likeMutation.isLoading}
className={post.data?.liked ? 'liked' : ''}
>
❤ {post.data?.likeCount}
</button>
);
}
// 使用 - 添加评论
function CommentForm({ postId }: { postId: string }) {
const addComment = useOptimisticMutation<Comment>({
mutationFn: (comment) => api.addComment(comment),
queryKey: ['comments', postId],
updateFn: (comments, newComment) => [...(comments ?? []), newComment]
});
const handleSubmit = (content: string) => {
addComment.mutate({
id: `temp-${Date.now()}`,
content,
author: currentUser.name,
createdAt: new Date().toISOString(),
pending: true
});
};
return <CommentInput onSubmit={handleSubmit} />;
}
12.3 缓存失效策略
// utils/cacheManager.ts
import { useQueryClient } from '@tanstack/react-query';
class CacheManager {
private queryClient: ReturnType<typeof useQueryClient>;
constructor(queryClient: ReturnType<typeof useQueryClient>) {
this.queryClient = queryClient;
}
// 精确失效单个查询
invalidateExact(key: string[]) {
this.queryClient.invalidateQueries(key);
}
// 模糊匹配失效
invalidatePattern(pattern: string) {
this.queryClient.invalidateQueries({
predicate: (query) => {
const keyString = JSON.stringify(query.queryKey);
return keyString.includes(pattern);
}
});
}
// 预取数据
async prefetch<T>(
key: string[],
fetcher: () => Promise<T>,
options?: { staleTime?: number }
) {
await this.queryClient.prefetchQuery({
queryKey: key,
queryFn: fetcher,
staleTime: options?.staleTime ?? 5 * 60 * 1000
});
}
// 重置特定命名空间
resetNamespace(namespace: string) {
this.queryClient.removeQueries({
predicate: (query) => {
const firstKey = query.queryKey[0];
return typeof firstKey === 'string' && firstKey.startsWith(namespace);
}
});
}
// 获取或设置默认数据
getOrSetDefault<T>(key: string[], defaultValue: T): T {
const cached = this.queryClient.getQueryData<T>(key);
if (cached !== undefined) return cached;
this.queryClient.setQueryData(key, defaultValue);
return defaultValue;
}
}
// 使用
function DataProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const cacheManager = useMemo(() => new CacheManager(queryClient), [queryClient]);
// 在 Context 中暴露
return (
<CacheContext.Provider value={cacheManager}>
{children}
</CacheContext.Provider>
);
}
// 组件中使用
function UserActions() {
const cacheManager = useContext(CacheContext);
const handleUserUpdate = async (userId: string, userData: Partial<User>) => {
await api.updateUser(userId, userData);
// 失效用户相关缓存
cacheManager.invalidateExact(['user', userId]);
cacheManager.invalidatePattern('user-list');
};
const handleLogout = () => {
cacheManager.resetNamespace('user');
cacheManager.resetNamespace('auth');
};
}
十三、组件设计模式
13.1 复合组件模式
// components/Tabs/index.tsx
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) throw new Error('Tabs components must be used within <Tabs>');
return context;
}
// 主容器
interface TabsProps {
defaultActiveTab?: string;
onChange?: (activeTab: string) => void;
children: ReactNode;
}
export function Tabs({ defaultActiveTab, onChange, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultActiveTab ?? '');
const handleSetActiveTab = (id: string) => {
setActiveTab(id);
onChange?.(id);
};
return (
<TabsContext.Provider value={{ activeTab, setActiveTab: handleSetActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 标签列表
export function TabList({ children, ...props }: { children: ReactNode }) {
return <div role="tablist" className="tab-list" {...props}>{children}</div>;
}
// 单个标签
interface TabProps {
id: string;
disabled?: boolean;
children: ReactNode;
}
export function Tab({ id, disabled, children, ...props }: TabProps) {
const { activeTab, setActiveTab } = useTabsContext();
const isActive = activeTab === id;
return (
<button
role="tab"
aria-selected={isActive}
disabled={disabled}
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => !disabled && setActiveTab(id)}
{...props}
>
{children}
</button>
);
}
// 内容面板
interface TabPanelProps {
id: string;
children: ReactNode;
}
export function TabPanel({ id, children, ...props }: TabPanelProps) {
const { activeTab } = useTabsContext();
if (activeTab !== id) return null;
return (
<div role="tabpanel" id={`panel-${id}`} className="tab-panel" {...props}>
{children}
</div>
);
}
// 使用
function App() {
return (
<Tabs defaultActiveTab="profile" onChange={(id) => console.log('切换到:', id)}>
<TabList>
<Tab id="profile">个人资料</Tab>
<Tab id="settings">设置</Tab>
<Tab id="notifications" disabled>通知</Tab>
</TabList>
<TabPanel id="profile">
<ProfileForm />
</TabPanel>
<TabPanel id="settings">
<SettingsForm />
</TabPanel>
<TabPanel id="notifications">
<Notifications />
</TabPanel>
</Tabs>
);
}
13.2 高阶组件(HOC)
// HOC/withAuth.tsx
import React, { ComponentType, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
interface WithAuthOptions {
requiredPermissions?: string[];
redirectTo?: string;
fallback?: ComponentType;
}
export function withAuth<P extends object>(
WrappedComponent: ComponentType<P>,
options: WithAuthOptions = {}
) {
const { requiredPermissions = [], redirectTo = '/login', fallback: FallbackComponent } = options;
return function AuthenticatedComponent(props: P) {
const navigate = useNavigate();
const { isAuthenticated, hasPermissions } = useAuthStore();
useEffect(() => {
if (!isAuthenticated) {
navigate(redirectTo, { replace: true });
return;
}
if (requiredPermissions.length > 0 && !hasPermissions(requiredPermissions)) {
navigate('/403', { replace: true });
}
}, [isAuthenticated, requiredPermissions]);
if (!isAuthenticated) {
return FallbackComponent ? <FallbackComponent /> : null;
}
if (requiredPermissions.length > 0 && !hasPermissions(requiredPermissions)) {
return FallbackComponent ? <FallbackComponent /> : null;
}
return <WrappedComponent {...props} />;
};
}
// HOC/withLoading.tsx
import React, { ComponentType, useState, useEffect } from 'react';
interface WithLoadingProps {
isLoading: boolean;
skeletonCount?: number;
}
export function withLoading<P extends object>(
WrappedComponent: ComponentType<P>,
SkeletonComponent: ComponentType<{ count: number }>
) {
return function LoadingWrapper(props: P & WithLoadingProps) {
const { isLoading, skeletonCount = 3, ...rest } = props;
if (isLoading) {
return <SkeletonComponent count={skeletonCount} />;
}
return <WrappedComponent {...(rest as P)} />;
};
}
// HOC/withErrorBoundary.tsx
import React, { ComponentType, Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="error-fallback">
<h2>出错了</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
重试
</button>
</div>
);
}
return this.props.children;
}
}
export function withErrorBoundary<P extends object>(
WrappedComponent: ComponentType<P>,
errorFallback?: ReactNode
) {
return function ErrorBoundedComponent(props: P) {
return (
<ErrorBoundary fallback={errorFallback}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
};
}
// 组合使用
const AdminDashboard = compose(
withAuth({ requiredPermissions: ['admin:view'] }),
withErrorBoundary(<AdminErrorFallback />),
withLoading(AdminSkeleton)
)(DashboardComponent);
// 使用
function App() {
return (
<Routes>
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
);
}
13.3 Render Props 变体
// components/DataFetcher.tsx
import React, { useState, useEffect, ReactNode } from 'react';
interface DataFetcherProps<T> {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
headers?: Record<string, string>;
children: (result: {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}) => ReactNode;
}
export function DataFetcher<T>({
url,
method = 'GET',
body,
headers,
children
}: DataFetcherProps<T>) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({
data: null,
loading: true,
error: null
});
const fetchData = async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (err) {
setState({ data: null, loading: false, error: err as Error });
}
};
useEffect(() => {
fetchData();
}, [url]);
return <>{children({ ...state, refetch: fetchData })}</>;
}
// 使用
function UserProfile({ userId }: { userId: string }) {
return (
<DataFetcher<User> url={`/api/users/${userId}`}>
{({ data: user, loading, error, refetch }) => {
if (loading) return <UserSkeleton />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<div className="user-profile">
<Avatar src={user.avatarUrl} />
<h1>{user.displayName}</h1>
<p>{user.bio}</p>
<button onClick={refetch}>刷新</button>
</div>
);
}}
</DataFetcher>
);
}
十四、安全与权限控制
14.1 XSS 防护
// utils/sanitizer.ts
import DOMPurify from 'dompurify';
interface SanitizeOptions {
ALLOWED_TAGS?: string[];
ALLOWED_ATTR?: string[];
FORBID_TAGS?: string[];
FORBID_ATTR?: string[];
}
const DEFAULT_CONFIG: SanitizeOptions = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel']
};
export function sanitizeHTML(dirtyHTML: string, options?: SanitizeOptions): string {
const config = { ...DEFAULT_CONFIG, ...options };
return DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: config.ALLOWED_TAGS,
ALLOWED_ATTR: config.ALLOWED_ATTR,
FORBID_TAGS: config.FORBID_TAGS,
FORBID_ATTR: config.FORBID_ATTR
});
}
// 安全渲染富文本
interface SafeHTMLProps {
html: string;
tag?: keyof JSX.IntrinsicElements;
className?: string;
}
export function SafeHTML({ html, tag: Tag = 'div', className }: SafeHTMLProps) {
const sanitized = useMemo(() => sanitizeHTML(html), [html]);
return (
<Tag
className={className}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
// URL 安全处理
export function safeUrl(url: string): string {
try {
const parsed = new URL(url);
// 只允许 http 和 https 协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return '#unsafe-url';
}
// 防止 JavaScript 协议变体
if (/^\s*javascript:/i.test(url)) {
return '#javascript-url';
}
return url;
} catch {
return '#invalid-url';
}
}
// 安全链接组件
interface SecureLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: ReactNode;
}
export function SecureLink({ href, children, ...props }: SecureLinkProps) {
const safeHref = useMemo(() => safeUrl(href), [href]);
return (
<a
href={safeHref}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
}
// 使用
function ArticleContent({ article }: { article: Article }) {
return (
<article>
<h1>{article.title}</h1>
<SafeHTML html={article.content} />
<div className="references">
{article.references.map(ref => (
<SecureLink key={ref.id} href={ref.url}>
{ref.title}
</SecureLink>
))}
</div>
</article>
);
}
14.2 RBAC 权限系统
// types/permissions.ts
type Permission =
| 'user:create'
| 'user:read'
| 'user:update'
| 'user:delete'
| 'role:manage'
| 'system:config';
type Role = 'admin' | 'manager' | 'editor' | 'viewer';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
admin: Object.values({}) as Permission[],
manager: ['user:create', 'user:read', 'user:update', 'role:manage'],
editor: ['user:read', 'user:create'],
viewer: ['user:read']
};
// hooks/usePermission.ts
import { useMemo } from 'react';
import { useAuthStore } from '@/stores/auth';
interface UsePermissionReturn {
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (permissions: Permission[]) => boolean;
hasAllPermissions: (permissions: Permission[]) => boolean;
role: Role | null;
}
export function usePermission(): UsePermissionReturn {
const { user } = useAuthStore();
const role = user?.role ?? null;
const permissions = useMemo(() => {
if (!role) return [];
return ROLE_PERMISSIONS[role] ?? [];
}, [role]);
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission);
};
const hasAnyPermission = (perms: Permission[]): boolean => {
return perms.some(p => permissions.includes(p));
};
const hasAllPermissions = (perms: Permission[]): boolean => {
return perms.every(p => permissions.includes(p));
};
return { hasPermission, hasAnyPermission, hasAllPermissions, role };
}
// 权限按钮组件
interface PermissionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
permission: Permission | Permission[];
fallback?: ReactNode;
children: ReactNode;
}
export function PermissionButton({
permission,
fallback = null,
children,
...props
}: PermissionButtonProps) {
const { hasPermission, hasAnyPermission } = usePermission();
const permitted = Array.isArray(permission)
? hasAnyPermission(permission)
: hasPermission(permission);
if (!permitted) return <>{fallback}</>;
return <button {...props}>{children}</button>;
}
// 路由权限守卫
interface ProtectedRouteProps {
permission: Permission | Permission[];
redirectTo?: string;
children: ReactNode;
}
export function ProtectedRoute({
permission,
redirectTo = '/403',
children
}: ProtectedRouteProps) {
const { hasAnyPermission } = usePermission();
const navigate = useNavigate();
const permitted = Array.isArray(permission)
? hasAnyPermission(permission)
: hasAnyPermission([permission]);
useEffect(() => {
if (!permitted) {
navigate(redirectTo, { replace: true });
}
}, [permitted, redirectTo, navigate]);
if (!permitted) return null;
return <>{children}</>;
}
// 使用
function UserManagement() {
const { hasPermission, role } = usePermission();
return (
<div>
<h1>用户管理</h1>
{/* 基础权限检查 */}
{hasPermission('user:create') && (
<Button variant="primary">创建用户</Button>
)}
{/* 多权限检查 */}
{(hasPermission('user:update') || hasPermission('user:delete')) && (
<Table actions={['edit', 'delete']} />
)}
{/* 使用权限按钮 */}
<PermissionButton permission="user:delete">
<Button variant="danger">删除</Button>
</PermissionButton>
<PermissionButton permission={['user:update', 'user:delete']}>
<Button variant="primary">批量操作</Button>
</PermissionButton>
{/* 角色显示 */}
<Badge variant={role}>当前角色:{role}</Badge>
</div>
);
}
14.3 CSRF 防护
// utils/csrf.ts
import axios from 'axios';
let csrfToken: string | null = null;
export async function fetchCsrfToken(): Promise<string> {
try {
const response = await axios.get('/api/csrf-token');
csrfToken = response.data.token;
return csrfToken;
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
throw error;
}
}
export function getCsrfToken(): string | null {
return csrfToken;
}
// Axios 拦截器自动添加 CSRF Token
export function setupCsrfInterceptor(instance: axios.AxiosInstance) {
instance.interceptors.request.use(async (config) => {
// GET 请求不需要 CSRF
if (config.method?.toLowerCase() === 'get') {
return config;
}
// 获取或刷新 token
let token = csrfToken;
if (!token) {
token = await fetchCsrfToken();
}
// 添加到请求头
config.headers['X-CSRF-Token'] = token;
return config;
});
instance.interceptors.response.use(
(response) => response,
async (error) => {
// 如果是 CSRF 错误,尝试重新获取 token 并重试
if (error.response?.status === 403 && error.response?.data?.code === 'CSRF_INVALID') {
await fetchCsrfToken();
// 重试原始请求
const originalRequest = error.config;
originalRequest.headers['X-CSRF-Token'] = csrfToken!;
return instance(originalRequest);
}
return Promise.reject(error);
}
);
}
// 表单提交包装器
interface FormSubmitOptions {
url: string;
data: FormData | Record<string, any>;
method?: 'POST' | 'PUT' | 'DELETE';
}
export async function secureFormSubmit({ url, data, method = 'POST' }: FormSubmitOptions) {
const token = await fetchCsrfToken();
if (data instanceof FormData) {
data.append('_csrf', token);
} else {
data._csrf = token;
}
return axios({
method,
url,
data,
headers: {
'Content-Type': data instanceof FormData ? undefined : 'application/json',
'X-CSRF-Token': token
}
});
}
// 使用
async function handleDeleteUser(userId: string) {
try {
await secureFormSubmit({
url: `/api/users/${userId}`,
method: 'DELETE',
data: {}
});
toast.success('删除成功');
} catch (error) {
toast.error('操作失败');
}
}
十五、测试策略
15.1 单元测试最佳实践
// __tests__/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { describe, it, expect, beforeEach } from 'vitest';
import { useCounter } from '@/hooks/useCounter';
describe('useCounter', () => {
it('应该返回初始值', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('应该支持自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increment 应该增加计数', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrement 应该减少计数', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('reset 应该重置为初始值', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
it('支持步长参数', () => {
const { result } = renderHook(() => useCounter(0, 5));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(5);
});
});
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Button from '@/components/Button';
describe('Button', () => {
it('应该正确渲染文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByText('点击我')).toBeInTheDocument();
});
it('点击时应该调用 onClick', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>点击</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('禁用状态不应该触发点击', () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>禁用</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('loading 状态应该显示加载指示器', () => {
render(<Button loading>保存中</Button>);
expect(screen.getByText('保存中')).toBeInTheDocument();
expect(screen.getByTestId('spinner')).toBeInTheDocument();
});
it('应该支持不同的 variant', () => {
const { rerender } = render(<Button variant="primary">主要</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-primary');
rerender(<Button variant="danger">危险</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-danger');
});
});
15.2 集成测试
// __tests__/integration/UserForm.test.tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import UserForm from '@/components/UserForm';
// Mock API
vi.mock('@/api/user', () => ({
createUser: vi.fn(),
updateUser: vi.fn(),
getUserById: vi.fn()
}));
import * as userApi from '@/api/user';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false }
}
});
}
function renderWithProviders(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
describe('UserForm 集成测试', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('编辑模式下应该预填充数据', async () => {
const mockUser = {
id: '1',
name: '张三',
email: 'zhangsan@example.com',
role: 'admin'
};
vi.mocked(userApi.getUserById).mockResolvedValueOnce(mockUser);
renderWithProviders(<UserForm userId="1" mode="edit" />);
await waitFor(() => {
expect(screen.getByDisplayValue('张三')).toBeInTheDocument();
expect(screen.getByDisplayValue('zhangsan@example.com')).toBeInTheDocument();
});
});
it('表单验证应该在必填字段为空时显示错误', async () => {
renderWithProviders(<UserForm mode="create" />);
fireEvent.click(screen.getByText('提交'));
await waitFor(() => {
expect(screen.getByText('请输入姓名')).toBeInTheDocument();
expect(screen.getByText('请输入邮箱')).toBeInTheDocument();
});
});
it('成功提交后应该显示成功提示并调用 API', async () => {
vi.mocked(userApi.createUser).mockResolvedValueOnce({ success: true });
renderWithProviders(<UserForm mode="create" />);
fireEvent.change(screen.getByPlaceholderText('姓名'), {
target: { value: '李四' }
});
fireEvent.change(screen.getByPlaceholderText('邮箱'), {
target: { value: 'lisi@test.com' }
});
fireEvent.click(screen.getByText('提交'));
await waitFor(() => {
expect(userApi.createUser).toHaveBeenCalledWith({
name: '李四',
email: 'lisi@test.com'
});
});
await waitFor(() => {
expect(screen.getByText('创建成功')).toBeInTheDocument();
});
});
it('API 失败时应该显示错误信息', async () => {
vi.mocked(userApi.createUser).mockRejectedValueOnce(new Error('网络错误'));
renderWithProviders(<UserForm mode="create" />);
fireEvent.change(screen.getByPlaceholderText('姓名'), {
target: { value: '王五' }
});
fireEvent.click(screen.getByText('提交'));
await waitFor(() => {
expect(screen.getByText(/网络错误/)).toBeInTheDocument();
});
});
});
15.3 E2E 测试示例
// e2e/login.spec.ts
import { test, expect, Page } from '@playwright/test';
test.describe('登录流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('应该成功登录有效凭据', async ({ page }) => {
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="welcome-message"]')).toContainText('欢迎');
});
test('无效凭据应该显示错误提示', async ({ page }) => {
await page.fill('[data-testid="email-input"]', 'invalid@email.com');
await page.fill('[data-testid="password-input"]', 'wrongpass');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('空表单应该显示验证错误', async ({ page }) => {
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="email-error"]')).toContainText('请输入邮箱');
await expect(page.locator('[data-testid="password-error"]']).toContainText('请输入密码');
});
test('记住我应该持久化登录状态', async ({ page }) => {
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.check('[data-testid="remember-me"]');
await page.click('[data-testid="submit-button"]');
await expect(page).toHaveURL('/dashboard');
// 刷新页面后仍然保持登录状态
await page.reload();
await expect(page).toHaveURL('/dashboard');
});
});
// e2e/crud.spec.ts
test.describe('用户 CRUD 操作', () => {
test.beforeEach(async ({ page }) => {
// 登录获取认证
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'admin@example.com');
await page.fill('[data-testid="password-input"]', 'password123');
await page.click('[data-testid="submit-button"]');
await page.waitForURL('/users');
});
test('应该能够创建新用户', async ({ page }) => {
await page.click('[data-testid="add-user-btn"]');
await page.waitForSelector('[data-testid="user-form-modal"]');
await page.fill('[name="username"]', 'testuser');
await page.fill('[name="email"]', 'test@example.com');
await page.selectOption('[name="role"]', 'editor');
await page.click('[data-testid="save-btn"]');
await expect(page.locator('[data-testid="success-toast"]')).toBeVisible();
await expect(page.locator('text=testuser')).toBeVisible();
});
test('应该能够搜索和筛选用户', async ({ page }) => {
await page.fill('[data-testid="search-input"]', 'admin');
await expect(page.locator('.user-row')).toHaveCount(1);
await expect(page.locator('.user-row').first()).toContainText('admin');
});
test('应该能够分页浏览用户列表', async ({ page }) => {
await expect(page.locator('[data-testid="pagination"]')).toBeVisible();
const currentPage = page.locator('[data-testid="current-page"]');
await expect(currentPage).toHaveText('1');
await page.click('[data-testid="next-page"]');
await expect(currentPage).not.toHaveText('1');
});
});
十六、SSR 与 Next.js 进阶
16.1 服务端数据获取
// app/posts/[id]/page.tsx (Next.js App Router)
import { notFound } from 'next/navigation';
import { cache } from 'react';
// 缓存数据获取函数,避免重复请求
const getPost = cache(async (id: string) => {
const res = await fetch(`${process.env.API_URL}/posts/${id}`, {
next: { revalidate: 3600, tags: ['post'] } // ISR:每小时重新验证
});
if (!res.status === 404) notFound();
return res.json();
});
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<PostMetadata
author={post.author}
createdAt={post.createdAt}
views={post.views}
/>
</article>
);
}
// 生成静态参数(用于 SSG)
export async function generateStaticParams() {
const posts = await fetch(`${process.env.API_URL}/posts`).then(r => r.json());
return posts.map((post: any) => ({
id: post.id.toString()
}));
}
// 动态元数据
export async function generateMetadata({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return {
title: `${post.title} - 我的博客`,
description: post.excerpt,
openGraph: {
images: [post.coverImage],
type: 'article',
publishedTime: post.createdAt,
authors: [post.author.name]
},
alternates: {
canonical: `/posts/${params.id}`
}
};
}
16.2 客户端与服务端组件分离
// components/PostContent.server.tsx - 服务端组件(默认)
import { Suspense } from 'react';
import PostComments from './PostComments.client';
interface PostContentProps {
postId: string;
}
async function PostContent({ postId }: PostContentProps) {
// 服务端数据获取
const post = await fetchPost(postId);
const relatedPosts = await fetchRelatedPosts(postId);
return (
<div className="post-content">
<header>
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>
{formatDate(post.createdAt)}
</time>
</header>
<section className="markdown-body">
<MarkdownRenderer content={post.content} />
</section>
{/* 相关文章 - 服务端渲染 */}
<aside className="related-posts">
<h2>相关推荐</h2>
<ul>
{relatedPosts.map(p => (
<li key={p.id}>
<Link href={`/posts/${p.id}`}>{p.title}</Link>
</li>
))}
</ul>
</aside>
{/* 评论区 - 客户端组件 */}
<Suspense fallback={<CommentSkeleton />}>
<PostComments postId={postId} />
</Suspense>
</div>
);
}
// components/PostComments.client.tsx - 客户端组件
'use client';
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function PostComments({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const [replyTo, setReplyTo] = useState<string | null>(null);
// 客户端数据获取
const { data: comments, isLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: () => api.getComments(postId),
staleTime: 5 * 60 * 1000
});
const addMutation = useMutation({
mutationFn: (content: string) => api.addComment(postId, content),
onSuccess: () => {
queryClient.invalidateQueries(['comments', postId]);
setReplyTo(null);
}
});
return (
<section className="comments">
<h2>评论 ({comments?.length ?? 0})</h2>
{isLoading ? (
<CommentSkeleton />
) : (
<ul className="comment-list">
{comments?.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
onReply={() => setReplyTo(comment.id)}
/>
))}
</ul>
)}
<CommentForm
replyTo={replyTo}
onSubmit={(content) => addMutation.mutate(content)}
/>
</section>
);
}
16.3 Server Actions
// actions/post.actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
category: z.enum(['tech', 'life', 'tutorial']),
tags: z.array(z.string()).max(5),
isDraft: z.boolean().default(false)
});
export async function createPost(formData: FormData) {
// 服务端验证
const validatedFields = createPostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
tags: formData.getAll('tags'),
isDraft: formData.get('isDraft') === 'on'
});
if (!validatedFields.success) {
return {
errors: validatedErrors.flatten().fieldErrors,
message: '字段验证失败'
};
}
// 认证检查
const session = await auth();
if (!session?.user) {
return { error: '未授权' };
}
try {
const post = await db.post.create({
data: {
...validatedFields.data,
authorId: session.user.id,
slug: generateSlug(validatedFields.data.title)
}
});
// 增量静态重新验证(ISR)
revalidateTag('posts');
if (!validatedFields.data.isDraft) {
redirect(`/posts/${post.slug}`);
}
return { success: true, post };
} catch (error) {
console.error('创建文章失败:', error);
return { error: '创建失败' };
}
}
export async function updatePost(
id: string,
prevState: any,
formData: FormData
) {
const session = await auth();
// 权限检查
const existingPost = await db.post.findUnique({ where: { id } });
if (!existingPost || existingPost.authorId !== session.user.id) {
return { error: '无权修改' };
}
const updatedData = {
title: formData.get('title') as string,
content: formData.get('content') as string,
updatedAt: new Date()
};
await db.post.update({
where: { id },
data: updatedData
});
revalidatePath(`/posts/${id}`);
revalidatePath('/admin/posts');
return { success: true };
}
// 使用
// app/admin/posts/new/page.tsx
import { createPost } from '@/actions/post.actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="标题" />
<textarea name="content" placeholder="内容" />
<select name="category">
<option value="tech">技术</option>
<option value="life">生活</option>
<option value="tutorial">教程</option>
</select>
<input type="checkbox" name="isDraft" />
<label>保存为草稿</label>
<button type="submit">发布</button>
</form>
);
}
16.4 中间件与路由保护
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 公开路径白名单
const publicPaths = ['/login', '/register', '/forgot-password', '/api/auth'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
// 静态资源跳过
const staticAssets = /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$/;
if (staticAssets.test(pathname)) {
return NextResponse.next();
}
// API 路由认证
if (pathname.startsWith('/api/')) {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token && !isPublicPath) {
return NextResponse.json(
{ error: '未授权' },
{ status: 401 }
);
}
if (token) {
const payload = verifyToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Token 无效' },
{ status: 401 }
);
}
// 将用户信息注入请求头
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId);
return NextResponse.next({
request: { headers: requestHeaders }
});
}
}
// 页面路由认证
if (!isPublicPath) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
const payload = verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('auth-token');
return response;
}
// 管理员路由权限检查
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/', request.url));
}
}
// 安全头设置
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)'
]
};
十七、调试与性能监控
// utils/profiler.ts
import { Profiler, type OnRenderCallback } from 'react';
interface MeasureConfig {
id: string;
phase: 'mount' | 'update';
actualDuration: number;
baseDuration: number;
startTime: number;
commitTime: number;
interactions: Set<any>;
}
const onRenderCallback: OnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
interactions
) => {
// 性能阈值警告
if (actualDuration > 100) {
console.warn(`[Performance] ${id} 渲染耗时 ${actualDuration.toFixed(2)}ms`);
}
// 上报到监控系统
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
sendMetrics({
component: id,
phase,
duration: actualDuration,
timestamp: Date.now(),
url: window.location.pathname
});
}
};
// 性能监控 HOC
export function withProfiler<P extends object>(
WrappedComponent: ComponentType<P>,
componentName?: string
) {
const displayName = componentName || WrappedComponent.displayName || WrappedComponent.name || 'Unknown';
return function ProfiledComponent(props: P) {
return (
<Profiler id={displayName} onRender={onRenderCallback}>
<WrappedComponent {...props} />
</Profiler>
);
};
}
// 使用
const HeavyTable = withProfiler(DataTable, 'HeavyTable');
const ComplexForm = withProfiler(UserForm, 'UserForm');
17.2 自定义错误边界与日志
// components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { captureException } from '@sentry/nextjs';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
resetKeys?: any[];
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 错误上报到 Sentry
captureException(error, {
contexts: {
react: {
componentStack: errorInfo.componentStack
}
},
tags: {
type: 'error-boundary',
location: typeof window !== 'undefined' ? window.location.href : 'unknown'
}
});
this.props.onError?.(error, errorInfo);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
// resetKeys 变化时重置状态
if (
this.props.resetKeys &&
prevProps.resetKeys &&
this.props.resetKeys.some((key, i) => key !== prevProps.resetKeys[i])
) {
this.setState({ hasError: false, error: null });
}
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="error-fallback">
<div className="error-icon">⚠️</div>
<h2>出错了</h2>
<p>{this.state.error?.message}</p>
{process.env.NODE_ENV === 'development' && (
<details className="error-details">
<summary>错误详情</summary>
<pre>{this.state.error?.stack}</pre>
</details>
)}
<div className="error-actions">
<button onClick={() => window.location.reload()}>
刷新页面
</button>
<button onClick={() => this.setState({ hasError: false, error: null })}>
重试
</button>
</div>
</div>
);
}
return this.props.children;
}
}
// 全局错误处理
// app/error.tsx (Next.js App Router)
'use client';
export default function GlobalError({
error,
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Global error:', error);
}, [error]);
return (
<html lang="zh-CN">
<body>
<div className="global-error">
<h1>应用出错</h1>
<p>抱歉,发生了意外错误。</p>
<button onClick={reset}>重新尝试</button>
</div>
</body>
</html>
);
}
17.3 性能指标收集
// utils/performance.ts
type MetricName =
| 'FCP'
| 'LCP'
| 'FID'
| 'CLS'
| 'TTFB'
| 'INP';
interface PerformanceMetric {
name: MetricName;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
timestamp: number;
url: string;
userAgent: string;
}
class PerformanceMonitor {
private metrics: PerformanceMetric[] = [];
private endpoint: string;
constructor(endpoint?: string) {
this.endpoint = endpoint ?? '/api/metrics';
this.init();
}
private init() {
if (typeof window === 'undefined') return;
// Web Vitals 监听
import('web-vitals').then(({ onFCP, onLCP, onFID, onCLS, onTTFB, onINP }) => {
onFCP(this.reportMetric.bind(this));
onLCP(this.reportMetric.bind(this));
onFID(this.reportMetric.bind(this));
onCLS(this.reportMetric.bind(this));
onTTFB(this.reportMetric.bind(this));
onINP(this.reportMetric.bind(this));
});
// 资源加载时间
this.observeResourceTiming();
// 自定义用户交互计时
this.setupInteractionObserver();
}
private reportMetric(metric: any) {
const performanceMetric: PerformanceMetric = {
name: metric.name,
value: metric.value,
rating: metric.rating,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
};
this.metrics.push(performanceMetric);
this.sendToServer(performanceMetric);
// 控制台输出(开发环境)
if (process.env.NODE_ENV === 'development') {
console.log(
`[Web Vital] ${metric.name}: ${metric.value} (${metric.rating})`
);
}
}
private observeResourceTiming() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'resource') {
const resource = entry as PerformanceResourceTiming;
// 慢资源警告
if (resource.duration > 3000) {
console.warn(
`[Slow Resource] ${resource.name} took ${resource.duration.toFixed(0)}ms`
);
this.sendToServer({
name: 'SLOW_RESOURCE',
value: resource.duration,
rating: 'poor',
timestamp: Date.now(),
url: resource.name,
userAgent: navigator.userAgent
});
}
}
}
});
observer.observe({ entryTypes: ['resource'] });
}
private setupInteractionObserver() {
let interactionStart = 0;
document.addEventListener('pointerdown', () => {
interactionStart = performance.now();
}, { passive: true });
document.addEventListener('click', () => {
const duration = performance.now() - interactionStart;
if (duration > 100) {
this.sendToServer({
name: 'CLICK_LATENCY',
value: duration,
rating: duration > 500 ? 'poor' : 'needs-improvement',
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
});
}
});
}
private async sendToServer(metric: PerformanceMetric) {
if (navigator.sendBeacon) {
navigator.sendBeacon(
this.endpoint,
JSON.stringify(metric)
);
} else {
try {
await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true
});
} catch {
// 静默失败,不影响用户体验
}
}
}
// 自定义标记
mark(name: string) {
performance.mark(`${name}-start`);
}
measure(name: string) {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name).pop();
if (measure) {
this.sendToServer({
name: name.toUpperCase() as MetricName,
value: measure.duration,
rating: measure.duration < 100 ? 'good' : 'needs-improvement',
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
});
}
}
getMetrics(): PerformanceMetric[] {
return [...this.metrics];
}
}
export const perfMonitor = new PerformanceMonitor();
// 使用
function DataFetchingExample() {
useEffect(() => {
perfMonitor.mark('user-data-fetch');
fetchData().then(() => {
perfMonitor.measure('user-data-fetch');
});
}, []);
return null;
}
17.4 开发环境调试工具
// devtools/ReactDevToolsHelper.ts
declare global {
interface Window {
__REACT_DEVTOOLS_GLOBAL_HOOK__: any;
}
}
// 开发模式增强
if (process.env.NODE_ENV === 'development') {
// 组件挂载计数器
const mountCounts = new Map<string, number>();
const originalCreateElement = React.createElement;
React.createElement = function(type: any, props: any, ...children: any[]) {
if (typeof type === 'function' && type.displayName) {
const count = mountCounts.get(type.displayName) ?? 0;
mountCounts.set(type.displayName, count + 1);
}
return originalCreateElement.call(this, type, props, ...children);
};
// 暴露调试工具
window.__REACT_DEBUG__ = {
getMountCounts: () => Object.fromEntries(mountCounts),
resetCounts: () => mountCounts.clear(),
findComponentsByName: (name: string) => {
const elements = document.querySelectorAll('[data-reactroot]');
return Array.from(elements).filter(el => el.textContent?.includes(name));
}
};
// 性能警告
const warnIfOverRendered = (() => {
const renderCounts = new WeakMap<object, number>();
return (component: any) => {
const count = renderCounts.get(component) ?? 0;
renderCounts.set(component, count + 1);
if (count > 50 && count % 20 === 0) {
console.warn(
`⚠️ ${component.constructor?.name || 'Anonymous'} 已渲染 ${count + 1} 次`,
component
);
}
};
})();
}
// 自定义 Hook 调试
function useDebugValue<T>(value: T, formatter?: (value: T) => string) {
const formatted = formatter ? formatter(value) : String(value);
if (process.env.NODE_ENV === 'development') {
// 在 DevTools 中显示格式化的值
React.useDebugValue(formatted);
}
return value;
}
// 使用
function UserProfile({ userId }: { userId: string }) {
const user = useQuery(['user', userId], () => api.getUser(userId));
useDebugValue(user.data, user =>
user ? `${user.name} (${user.role})` : 'Loading...'
);
return null;
}
十八、微前端架构
18.1 Module Federation 配置
// next.config.js (Host 应用)
const ModuleFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
if (!options.isServer) {
config.plugins.push(
new ModuleFederationPlugin({
name: 'host',
filename: 'remoteEntry.js',
remotes: {
dashboard: 'dashboard@http://localhost:3001/_next/static/chunks/remoteEntry.js',
settings: 'settings@http://localhost:3002/_next/static/charts/remoteEntry.js',
crm: 'crm@http://localhost:3003/_next/static/charts/remoteEntry.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
'@tanstack/react-query': { singleton: true },
zustand: { singleton: true },
'lodash-es': { singleton: false }
}
})
);
}
return config;
}
};
// next.config.js (Remote 应用 - Dashboard)
module.exports = {
webpack(config, options) {
config.plugins.push(
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./Dashboard': './components/Dashboard',
'./StatsCard': './components/StatsCard',
'./Chart': './components/Chart',
'./useDashboardData': './hooks/useDashboardData'
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
}
})
);
return config;
}
};
18.2 远程组件动态加载
// components/DynamicRemote.tsx
import React, { useState, useEffect, Suspense, lazy } from 'react';
interface DynamicRemoteProps {
remote: string; // remote 名称
module: string; // 暴露的模块路径
fallback?: React.ReactNode;
props?: Record<string, any>;
onError?: (error: Error) => void;
}
export function DynamicRemote({
remote,
module,
fallback = <RemoteLoader />,
props = {},
onError
}: DynamicRemoteProps) {
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let mounted = true;
async function loadRemote() {
try {
setLoading(true);
// 动态导入远程模块
const RemoteComponent = lazy(() =>
import(/* webpackIgnore: true */ `remote/${remote}/${module}`)
);
if (mounted) {
setComponent(() => RemoteComponent);
setLoading(false);
}
} catch (err) {
if (mounted) {
setError(err as Error);
setLoading(false);
onError?.(err as Error);
}
}
}
loadRemote();
return () => { mounted = false; };
}, [remote, module]);
if (error) {
return (
<div className="remote-error">
<p>模块加载失败</p>
<button onClick={() => window.location.reload()}>
刷新页面
</button>
</div>
);
}
return (
<Suspense fallback={fallback}>
{Component && <Component {...props} />}
</Suspense>
);
}
// 使用
function HostApp() {
return (
<Layout>
<Header />
<main>
<DynamicRemote
remote="dashboard"
module="./Dashboard"
props={{ userId: currentUser.id }}
onError={(err) => trackError(err)}
/>
<DynamicRemote
remote="settings"
module="./SettingsPanel"
fallback={<SettingsSkeleton />}
/>
</main>
<Footer />
</Layout>
);
}
18.3 微前端通信总线
// microfrontends/eventBus.ts
type EventPayload = any;
type EventCallback = (payload: EventPayload) => void;
class MicroEventBus {
private events = new Map<string, Set<EventCallback>>();
private history: Array<{ event: string; payload: EventPayload; timestamp: number }> = [];
// 发布事件
emit(event: string, payload?: EventPayload, options?: { persist?: boolean }) {
const callbacks = this.events.get(event);
if (callbacks) {
callbacks.forEach(callback => {
try {
callback(payload);
} catch (error) {
console.error(`Event handler error for "${event}":`, error);
}
});
}
// 记录事件历史
this.history.push({ event, payload, timestamp: Date.now() });
// 限制历史记录数量
if (this.history.length > 100) {
this.history.shift();
}
// 持久化重要事件
if (options?.persist) {
sessionStorage.setItem(
`mfe-event-${event}`,
JSON.stringify({ payload, timestamp: Date.now() })
);
}
}
// 订阅事件
subscribe(event: string, callback: EventCallback): () => void {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(callback);
// 返回取消订阅函数
return () => {
this.events.get(event)?.delete(callback);
};
}
// 只订阅一次
once(event: string, callback: EventCallback): () => void {
const wrapper = (payload: EventPayload) => {
callback(payload);
this.unsubscribe(event, wrapper);
};
return this.subscribe(event, wrapper);
}
// 取消订阅
unsubscribe(event: string, callback?: EventCallback) {
if (callback) {
this.events.get(event)?.delete(callback);
} else {
this.events.delete(event);
}
}
// 获取持久化的事件
getPersistedEvent<T>(event: string): T | null {
try {
const stored = sessionStorage.getItem(`mfe-event-${event}`);
return stored ? JSON.parse(stored).payload : null;
} catch {
return null;
}
}
// 清除持久化事件
clearPersistedEvent(event: string) {
sessionStorage.removeItem(`mfe-event-${event}`);
}
// 获取事件历史
getHistory(event?: string): typeof this.history {
if (event) {
return this.history.filter(e => e.event === event);
}
return [...this.history];
}
}
// 全局单例
export const microEventBus = new MicroEventBus();
// 类型安全的事件定义
interface MFEEvents {
'user:login': { userId: string; token: string };
'user:logout': {};
'theme:change': { theme: 'light' | 'dark' };
'notification:show': { type: 'success' | 'error' | 'info'; message: string };
'route:change': { path: string; from: string };
}
// 类型安全的包装器
export function createTypedEventBus<E extends Record<string, any>>() {
return {
emit<K extends keyof E>(event: K, payload: E[K]) {
microEventBus.emit(event as string, payload);
},
subscribe<K extends keyof E>(
event: K,
callback: (payload: E[K]) => void
) {
return microEventBus.subscribe(event as string, callback);
},
once<K extends keyof E>(
event: K,
callback: (payload: E[K]) => void
) {
return microEventBus.once(event as string, callback);
}
};
}
export const typedEventBus = createTypedEventBus<MFEEvents>();
// 使用示例
// Dashboard 微应用
typedEventBus.subscribe('user:login', ({ userId }) => {
loadUserData(userId);
});
// Settings 微应用
function ThemeSwitcher() {
const changeTheme = (theme: 'light' | 'dark') => {
typedEventBus.emit('theme:change', { theme });
};
return (
<button onClick={() => changeTheme('dark')}>
切换暗色主题
</button>
);
}
18.4 共享状态管理
// microfrontends/sharedStore.ts
import { createStore } from 'zustand/vanilla';
// 跨应用共享的 Store
interface SharedStore {
// 用户信息
user: User | null;
setUser: (user: User | null) => void;
// 主题
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
// 全局通知
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
// 导航状态
sidebarOpen: boolean;
toggleSidebar: () => void;
}
export const sharedStore = createStore<SharedStore>((set, get) => ({
user: null,
setUser: (user) => set({ user }),
theme: 'light',
setTheme: (theme) => {
set({ theme });
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
},
notifications: [],
addNotification: (notification) => {
const id = crypto.randomUUID();
set(state => ({
notifications: [...state.notifications, { ...notification, id }]
}));
// 自动移除
setTimeout(() => {
get().removeNotification(id);
}, 5000);
},
removeNotification: (id) => {
set(state => ({
notifications: state.notifications.filter(n => n.id !== id)
}));
},
sidebarOpen: true,
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen }))
}));
// React Hook 封装
import { useStore } from 'zustand';
export function useSharedStore() {
return useStore(sharedStore);
}
// 选择性订阅(性能优化)
export function useTheme() {
return useSharedStore(state => state.theme);
}
export function useNotifications() {
return useSharedStore(state => state.notifications);
}
// 初始化共享 Store
export function initSharedStore() {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
if (savedTheme) {
sharedStore.getState().setTheme(savedTheme);
}
// 同步用户登录状态
const persistedUser = sessionStorage.getItem('user');
if (persistedUser) {
try {
sharedStore.setState({ user: JSON.parse(persistedUser) });
} catch {
// 忽略解析错误
}
}
}