React 高阶技巧实战

React 高阶技巧实战

一、自定义 Hooks

1.1 异步状态管理

typescript 复制代码
// 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 自动保存表单

typescript 复制代码
// 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 无限滚动

typescript 复制代码
// 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 动态表格列

tsx 复制代码
// 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 递归菜单组件

tsx 复制代码
// 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 模式

tsx 复制代码
// 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

typescript 复制代码
// 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 跨层级状态共享(轻量级)

typescript 复制代码
// 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 状态机模式

typescript 复制代码
// 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 虚拟列表

tsx 复制代码
// 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 策略

tsx 复制代码
// 组件中的优化实践
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

tsx 复制代码
// 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 回调模式

tsx 复制代码
// 稳定的 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 类型推导

typescript 复制代码
// 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 事件类型安全

typescript 复制代码
// 组件事件定义
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 类型

typescript 复制代码
// 自定义 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 路由守卫组合

tsx 复制代码
// 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 路由缓存控制

tsx 复制代码
// 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 滚动行为控制

tsx 复制代码
// 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 模块化

typescript 复制代码
// 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 组合

typescript 复制代码
// 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

tsx 复制代码
// 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

tsx 复制代码
// 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 自动批处理

tsx 复制代码
// 行为对比
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 服务端渲染

tsx 复制代码
// 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

typescript 复制代码
// 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

typescript 复制代码
// 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

typescript 复制代码
// 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 网络状态检测

typescript 复制代码
// 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 复制到剪贴板

typescript 复制代码
// 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 复杂表单验证

typescript 复制代码
// 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 表单数组操作

typescript 复制代码
// 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 表单联动与依赖

typescript 复制代码
// 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

typescript 复制代码
// 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 过渡动画组件

tsx 复制代码
// 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 拖拽交互

typescript 复制代码
// 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 触摸手势

typescript 复制代码
// 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 深度使用

typescript 复制代码
// 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 乐观更新

typescript 复制代码
// 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 缓存失效策略

typescript 复制代码
// 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 复合组件模式

tsx 复制代码
// 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)

typescript 复制代码
// 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 变体

tsx 复制代码
// 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 防护

typescript 复制代码
// 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 权限系统

typescript 复制代码
// 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 防护

typescript 复制代码
// 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 单元测试最佳实践

typescript 复制代码
// __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 集成测试

typescript 复制代码
// __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 测试示例

typescript 复制代码
// 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 服务端数据获取

typescript 复制代码
// 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 客户端与服务端组件分离

tsx 复制代码
// 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

typescript 复制代码
// 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 中间件与路由保护

typescript 复制代码
// 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).*)'
  ]
};

十七、调试与性能监控

17.1 React DevTools Profiler 集成

typescript 复制代码
// 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 自定义错误边界与日志

tsx 复制代码
// 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 性能指标收集

typescript 复制代码
// 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 开发环境调试工具

typescript 复制代码
// 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 配置

javascript 复制代码
// 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 远程组件动态加载

tsx 复制代码
// 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 微前端通信总线

typescript 复制代码
// 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 共享状态管理

typescript 复制代码
// 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 {
      // 忽略解析错误
    }
  }
}
相关推荐
拉拉肥_King2 分钟前
Vue 3 主题切换深度解析:从炫酷动画到零闪烁方案
前端·vue.js
excel4 分钟前
为什么 Pinia + localForage 持久化后,页面初始化拿不到数据?
前端
雨雨雨雨雨别下啦7 分钟前
vant介绍
前端
小小小小宇7 分钟前
大模型失忆问题探讨
前端
wordbaby10 分钟前
rn-cross-calendar:一个兼容 React 18/19、RN/RNOH 的跨平台日历组件
前端·react native·harmonyos
weixin_5231853212 分钟前
Collections.unmodifiableMap详解:真的不可修改吗?
java·linux·前端
江米小枣tonylua13 分钟前
关掉 VSCode:在 NeoVim12 上配置 Claude Code
前端·程序员
2301_7736436223 分钟前
ceph镜像
前端·javascript·ceph
程序员黑豆44 分钟前
AI全栈开发之Java:什么是JDK
前端·后端·ai编程
To_OC1 小时前
万字解析《JS语言精粹》之第四章:函数15大核心精髓(JS灵魂核心)
前端·javascript·代码规范