react-web by alinaqi/claude-bootstrap
npx skills add https://github.com/alinaqi/claude-bootstrap --skill react-web加载方式:base.md + typescript.md
关键:测试必须在实现代码之前编写。对于前端组件来说,这是不容商量的。
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('渲染', () => {
it('使用标签渲染', () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('应用变体类', () => {
render(<Button label="Click" onClick={() => {}} variant="secondary" />);
expect(screen.getByRole('button')).toHaveClass('secondary');
});
});
describe('交互', () => {
it('点击时调用 onClick', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('禁用时不调用 onClick', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} disabled />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
describe('可访问性', () => {
it('禁用时具有正确的 aria 属性', () => {
render(<Button label="Click" onClick={() => {}} disabled />);
expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
});
});
});
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// useCounter.test.ts - 首先创建此文件
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('从初始值开始', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('递增', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('递减', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
it('重置到初始值', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
});
在编写任何组件/hook 实现之前:
Component.test.tsx如果跳过测试,Claude 必须:
⚠️ 测试优先违规
无法创建 [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 # 页面特定的 hook
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── hooks/ # 共享的自定义 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 接口,即使很简单
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('');
// 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'
})),
}));
// 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'] });
},
});
}
// 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;
}
// 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}>Click</button>
// 使用一致的模式,提取重复的组合
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>
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>
);
}
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('点击时调用 onClick', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} />);
fireEvent.click(screen.getByText('Click me'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('禁用时不调用 onClick', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} disabled />);
fireEvent.click(screen.getByText('Click me'));
expect(onClick).not.toHaveBeenCalled();
});
it('应用正确的变体类', () => {
render(<Button label="Click" 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);
});
});
// 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('Welcome')).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>
每周安装数
94
代码仓库
GitHub 星标数
529
首次出现
2026年1月20日
安全审计
安装于
opencode73
gemini-cli68
claude-code67
codex65
cursor59
github-copilot56
Load with: base.md + typescript.md
CRITICAL: Tests MUST be written BEFORE implementation code. This is non-negotiable for frontend components.
1. Write test file first → Defines expected behavior
2. Run test (it fails) → Confirms test is valid
3. Write minimal code → Just enough to pass
4. Run test (it passes) → Validates implementation
5. Refactor if needed → Tests catch regressions
# CORRECT ORDER - Test first
1. Create Button.test.tsx # Write tests for expected behavior
2. Run tests (they fail) # npm test -- Button
3. Create Button.tsx # Implement to pass tests
4. Run tests (they pass) # Verify implementation
5. Create Button.module.css # Style after logic works
# WRONG ORDER - Never do this
1. Create Button.tsx # ❌ No tests exist yet
2. Create Button.module.css # ❌ Still no tests
3. "I'll add tests later" # ❌ Tests never get written
// Button.test.tsx - CREATE THIS FIRST
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
// Define ALL expected behaviors upfront
describe('rendering', () => {
it('renders with label', () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('applies variant class', () => {
render(<Button label="Click" onClick={() => {}} variant="secondary" />);
expect(screen.getByRole('button')).toHaveClass('secondary');
});
});
describe('interactions', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button label="Click me" 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="Click me" onClick={onClick} disabled />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
describe('accessibility', () => {
it('has correct aria attributes when disabled', () => {
render(<Button label="Click" onClick={() => {}} disabled />);
expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
});
});
});
// useCounter.test.ts - CREATE THIS FIRST
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);
});
});
Before writing ANY component/hook implementation:
Component.test.tsxIf tests are skipped, Claude MUST:
⚠️ TEST-FIRST VIOLATION
Cannot create [Component].tsx - no test file exists.
Creating [Component].test.tsx first with tests for:
- Rendering with required props
- User interactions
- Edge cases
- Accessibility
project/
├── src/
│ ├── core/ # Pure business logic (no React)
│ │ ├── types.ts
│ │ └── services/
│ ├── components/ # Reusable UI components
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.module.css # or .styles.ts
│ │ │ └── index.ts
│ │ └── index.ts # Barrel export
│ ├── pages/ # Route-level components
│ │ ├── Home/
│ │ │ ├── HomePage.tsx
│ │ │ ├── useHome.ts # Page-specific hook
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── hooks/ # Shared custom hooks
│ ├── store/ # State management
│ ├── api/ # API client and queries
│ ├── utils/ # Utilities
│ ├── App.tsx
│ └── main.tsx
├── tests/
│ ├── unit/
│ └── e2e/
├── public/
├── package.json
├── tsconfig.json
├── vite.config.ts # or next.config.js
└── CLAUDE.md
// Good - simple, testable
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 - all logic here
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 - pure presentation
export function HomePage(): JSX.Element {
const { items, loading, refresh } = useHome();
if (loading) return <Spinner />;
return <ItemList items={items} onRefresh={refresh} />;
}
// Always define props interface, even if simple
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>
);
}
// Start with useState, escalate only when needed
const [value, setValue] = useState('');
// 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'
})),
}));
// 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 minutes
});
}
export function useCreateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: itemsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
}
// 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;
}
// 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}>Click</button>
// Use consistent patterns, extract repeated combinations
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>
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
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) => {
// handle submit
};
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">Login</button>
</form>
);
}
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} />);
fireEvent.click(screen.getByText('Click me'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} disabled />);
fireEvent.click(screen.getByText('Click me'));
expect(onClick).not.toHaveBeenCalled();
});
it('applies correct variant class', () => {
render(<Button label="Click" onClick={() => {}} variant="secondary" />);
expect(screen.getByRole('button')).toHaveClass('secondary');
});
});
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can login', 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('Welcome')).toBeVisible();
});
// Memoize expensive components
const ItemList = memo(function ItemList({ items }: ItemListProps) {
return items.map(item => <ItemCard key={item.id} item={item} />);
});
// Memoize callbacks passed to children
const handleClick = useCallback((id: string) => {
setSelectedId(id);
}, []);
// Memoize expensive computations
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
// Lazy load routes
const ItemPage = lazy(() => import('./pages/Item'));
<Suspense fallback={<Spinner />}>
<Route path="/items/:id" element={<ItemPage />} />
</Suspense>
Weekly Installs
94
Repository
GitHub Stars
529
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode73
gemini-cli68
claude-code67
codex65
cursor59
github-copilot56
后端测试指南:API端点、业务逻辑与数据库测试最佳实践
11,800 周安装
AI智能体指令文件重构工具 - 渐进式披露原则优化AGENTS.md/CLAUDE.md文件结构
298 周安装
Ruby on Rails 应用开发指南:构建功能全面的Rails应用,包含模型、控制器、身份验证与最佳实践
298 周安装
Reddit 只读浏览技能 - 安全获取帖子、评论与搜索,助力智能体开发
297 周安装
原生广告投放指南:Taboola/Outbrain平台优化策略、创意测试与预算建议
313 周安装
自主智能体架构指南:从ReAct模式到可靠部署的最佳实践与风险规避
296 周安装
GitHub API 集成与自动化操作指南 - 使用 Membrane 管理仓库、议题和拉取请求
72 周安装