ReactWeb技能 react-web

React Web 开发技能,涵盖测试先行开发、组件和钩子测试模式、项目结构、组件模式、状态管理、路由、样式、表单处理、测试策略和性能优化。关键词:React, 测试先行,组件开发,状态管理,React Router,CSS 模块,Tailwind, 性能优化。

前端开发 0 次安装 0 次浏览 更新于 3/5/2026

React Web 技能

加载与:base.md + typescript.md


测试先行开发(MANDATORY)

CRITICAL: 测试代码必须在实现代码之前编写。对于前端组件来说,这是不可协商的。

TFD 工作流程

1. 先写测试文件 → 定义预期行为
2. 运行测试(失败)→ 确认测试有效
3. 编写最小代码 → 刚好足够通过
4. 运行测试(通过)→ 验证实现
5. 如有需要则重构 → 测试捕获回归

组件开发顺序

# 正确的顺序 - 测试先行
1. 创建 Button.test.tsx    # 为预期行为编写测试
2. 运行测试(它们失败)     # npm test -- Button
3. 创建 Button.tsx         # 实现以通过测试
4. 运行测试(它们通过)     # 验证实现
5. 创建 Button.module.css  # 在逻辑工作后样式

# 错误的顺序 - 永不这样做
1. 创建 Button.tsx         # ❌ 还没有测试存在
2. 创建 Button.module.css  # ❌ 仍然没有测试
3. "我稍后会添加测试"    # ❌ 测试从未被编写

测试文件结构(首先创建)

// Button.test.tsx - 首先创建这个
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  // 上游定义所有预期行为
  describe('rendering', () => {
    it('renders with label', () => {
      render(<Button label="点击我" onClick={() => {}} />);
      expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
    });

    it('applies variant class', () => {
      render(<Button label="点击" onClick={() => {}} variant="secondary" />);
      expect(screen.getByRole('button')).toHaveClass('secondary');
    });
  });

  describe('interactions', () => {
    it('calls onClick when clicked', () => {
      const onClick = vi.fn();
      render(<Button label="点击我" onClick={onClick} />);
      fireEvent.click(screen.getByRole('button'));
      expect(onClick).toHaveBeenCalledTimes(1);
    });

    it('does not call onClick when disabled', () => {
      const onClick = vi.fn();
      render(<Button label="点击我" onClick={onClick} disabled />);
      fireEvent.click(screen.getByRole('button'));
      expect(onClick).not.toHaveBeenCalled();
    });
  });

  describe('accessibility', () => {
    it('has correct aria attributes when disabled', () => {
      render(<Button label="点击" onClick={() => {}} disabled />);
      expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
    });
  });
});

钩子测试先行模式

// useCounter.test.ts - 首先创建这个
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('starts at initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('increments', () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it('decrements', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.decrement());
    expect(result.current.count).toBe(4);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(10);
  });
});

执行检查表

在编写任何组件/钩子实现之前:

  • [ ] 测试文件存在:Component.test.tsx
  • [ ] 所有预期行为都有测试用例
  • [ ] 测试运行并失败(证明测试有效)
  • [ ] 然后才创建实现文件

如果跳过测试,Claude 必须:

⚠️ TEST-FIRST VIOLATION

不能创建 [Component].tsx - 没有测试文件存在。

首先创建 [Component].test.tsx 并为以下内容编写测试:
- 用所需属性渲染
- 用户交互
- 边缘情况
- 可访问性

项目结构

project/
├── src/
│   ├── core/                   # 纯业务逻辑(无 React)
│   │   ├── types.ts
│   │   └── services/
│   ├── components/             # 可重用的 UI 组件
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   ├── Button.test.tsx
│   │   │   ├── Button.module.css  # 或 .styles.ts
│   │   │   └── index.ts
│   │   └── index.ts            # 桶导出
│   ├── pages/                  # 路由级组件
│   │   ├── Home/
│   │   │   ├── HomePage.tsx
│   │   │   ├── useHome.ts      # 页面特定钩子
│   │   │   └── index.ts
│   │   └── index.ts
│   ├── hooks/                  # 共享自定义钩子
│   ├── store/                  # 状态管理
│   ├── api/                    # API 客户端和查询
│   ├── utils/                  # 工具
│   ├── App.tsx
│   └── main.tsx
├── tests/
│   ├── unit/
│   └── e2e/
├── public/
├── package.json
├── tsconfig.json
├── vite.config.ts              # 或 next.config.js
└── CLAUDE.md

组件模式

仅限函数组件

// 好 - 简单,可测试
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary';
}

export function Button({
  label,
  onClick,
  disabled = false,
  variant = 'primary'
}: ButtonProps): JSX.Element {
  return (
    <button
      className={styles[variant]}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

提取逻辑到钩子

// useHome.ts - 所有逻辑都在这里
export function useHome() {
  const [items, setItems] = useState<Item[]>([]);
  const [loading, setLoading] = useState(false);

  const refresh = useCallback(async () => {
    setLoading(true);
    const data = await fetchItems();
    setItems(data);
    setLoading(false);
  }, []);

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

  return { items, loading, refresh };
}

// HomePage.tsx - 纯呈现
export function HomePage(): JSX.Element {
  const { items, loading, refresh } = useHome();

  if (loading) return <Spinner />;

  return <ItemList items={items} onRefresh={refresh} />;
}

总是明确 Props 接口

// 总是定义 props 接口,即使很简单
interface ItemCardProps {
  item: Item;
  onClick: (id: string) => void;
}

export function ItemCard({ item, onClick }: ItemCardProps): JSX.Element {
  return (
    <div onClick={() => onClick(item.id)}>
      <h3>{item.title}</h3>
    </div>
  );
}

状态管理

首先使用本地状态

// 从 useState 开始,只有在需要时才升级
const [value, setValue] = useState('');

如果需要,使用 Zustand 进行全局状态管理

// store/useAppStore.ts
import { create } from 'zustand';

interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  setUser: (user: User | null) => void;
  toggleTheme: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  user: null,
  theme: 'light',
  setUser: (user) => set({ user }),
  toggleTheme: () => set((state) => ({
    theme: state.theme === 'light' ? 'dark' : 'light'
  })),
}));

使用 React Query 管理服务器状态

// api/queries/useItems.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemsApi } from '../client';

export function useItems() {
  return useQuery({
    queryKey: ['items'],
    queryFn: itemsApi.getAll,
    staleTime: 5 * 60 * 1000, // 5 分钟
  });
}

export function useCreateItem() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: itemsApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['items'] });
    },
  });
}

路由

React Router (Vite/CRA)

// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';

export function App(): JSX.Element {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/items/:id" element={<ItemPage />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </BrowserRouter>
  );
}

受保护的路由

interface ProtectedRouteProps {
  children: JSX.Element;
}

function ProtectedRoute({ children }: ProtectedRouteProps): JSX.Element {
  const { user } = useAppStore();
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

样式

CSS 模块(首选)

// Button.module.css
.primary {
  background: var(--color-primary);
  color: white;
}

.secondary {
  background: transparent;
  border: 1px solid var(--color-primary);
}

// Button.tsx
import styles from './Button.module.css';

<button className={styles.primary}>点击</button>

Tailwind(替代方案)

// 使用一致的模式,提取重复的组合
const buttonVariants = {
  primary: 'bg-blue-500 text-white hover:bg-blue-600',
  secondary: 'bg-transparent border border-blue-500 text-blue-500',
} as const;

<button className={buttonVariants[variant]}>{label}</button>

表单

React Hook Form + Zod

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('无效的电子邮件'),
  password: z.string().min(8, '密码至少为8个字符'),
});

type FormData = z.infer<typeof schema>;

export function LoginForm(): JSX.Element {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    // 处理提交
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">登录</button>
    </form>
  );
}

测试

使用 React Testing Library 组件测试

import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('点击时调用 onClick', () => {
    const onClick = vi.fn();
    render(<Button label="点击我" onClick={onClick} />);

    fireEvent.click(screen.getByText('点击我'));

    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('禁用时不调用 onClick', () => {
    const onClick = vi.fn();
    render(<Button label="点击我" onClick={onClick} disabled />);

    fireEvent.click(screen.getByText('点击我'));

    expect(onClick).not.toHaveBeenCalled();
  });

  it('应用正确的变体类', () => {
    render(<Button label="点击" onClick={() => {}} variant="secondary" />);

    expect(screen.getByRole('button')).toHaveClass('secondary');
  });
});

钩子测试

import { renderHook, act, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('增加计数器', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

使用 Playwright 进行 E2E 测试

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('用户可以登录', async ({ page }) => {
  await page.goto('/login');

  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('欢迎')).toBeVisible();
});

性能

记忆化

// 记忆化昂贵的组件
const ItemList = memo(function ItemList({ items }: ItemListProps) {
  return items.map(item => <ItemCard key={item.id} item={item} />);
});

// 记忆化传递给子组件的回调
const handleClick = useCallback((id: string) => {
  setSelectedId(id);
}, []);

// 记忆化昂贵的计算
const sortedItems = useMemo(() => {
  return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);

代码分割

// 延迟加载路由
const ItemPage = lazy(() => import('./pages/Item'));

<Suspense fallback={<Spinner />}>
  <Route path="/items/:id" element={<ItemPage />} />
</Suspense>

React Web 反模式

  • ❌ 内联 JSX 中的函数 - 使用 useCallback
  • ❌ 在渲染中的逻辑 - 提取到钩子
  • ❌ 深层组件嵌套 - 扁平层次结构
  • ❌ 列表中使用索引作为键 - 使用稳定的 ID
  • ❌ 直接状态变异 - 总是使用设置器
  • ❌ 通过 > 2 级传递属性 - 使用上下文或状态管理
  • ❌ 使用 useEffect 用于派生状态 - 使用 useMemo
  • ❌ 在 useEffect 中获取 - 使用 React Query
  • ❌ 将业务逻辑与 UI 混合 - 保持核心/纯净
  • ❌ 大型组件(>100 行) - 分割成更小的部分
  • ❌ CSS in JS 对象 - 使用 CSS 模块或 Tailwind
  • ❌ 忽略 TypeScript 错误 - 修复它们