名称: react 描述: 现代 React 开发模式,包括组件、钩子、状态管理、路由、表单和UI架构。涵盖 React 19+、React Router v7、Jotai 原子、服务器组件、可访问性、性能优化和测试。用于构建具有客户端路由、全局状态、组件组合和异步数据加载的 React 应用。触发词: react, jsx, tsx, 组件, 钩子, useState, useEffect, useContext, useReducer, useMemo, useCallback, useRef, 属性, 状态, 渲染, 虚拟DOM, 协调, 单页面应用, spa, react-router, jotai, vite, bun, Next.js, Remix, 客户端路由, 服务器组件, 可访问性, a11y, ARIA, 性能, 代码分割, 懒加载, Suspense, 错误边界, 表单验证, UI组件, 设计系统, 组合模式。
React 单页面应用开发
概述
本技能提供构建现代 React 单页面应用(SPA)的指导,使用:
- React 19+ 用于 UI 组件和钩子
- React Router v7 用于客户端路由和导航
- Jotai 用于原子全局状态管理
- Vite 用于快速开发和优化构建
- Bun 作为包管理器和运行时
本技能专注于客户端 React 应用,不包括像 Next.js 或 Remix 这样的服务器端渲染框架。
React 19 特性
操作和 useActionState
React 19 引入了操作来处理异步状态转换:
import { useActionState } from 'react'
interface FormState {
message: string
error?: string
}
async function updateProfile(previousState: FormState, formData: FormData) {
const name = formData.get('name') as string
try {
await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify({ name }),
})
return { message: '个人资料更新成功' }
} catch (error) {
return { message: '', error: '更新失败' }
}
}
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, { message: '' })
return (
<form action={formAction}>
<input type="text" name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '更新中...' : '更新个人资料'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
)
}
useOptimistic 用于即时 UI 更新
import { useOptimistic, useState } from 'react'
interface Todo {
id: string
title: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodo(formData: FormData) {
const title = formData.get('title') as string
const tempTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(tempTodo)
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<form action={addTodo}>
<input type="text" name="title" />
<button type="submit">添加待办事项</button>
</form>
</div>
)
}
use() 用于读取 Promise 和 Context
import { use, Suspense } from 'react'
interface User {
id: string
name: string
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`)
return response.json()
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() 解包 Promise
const user = use(userPromise)
return <div>{user.name}</div>
}
export function UserContainer({ userId }: { userId: string }) {
const userPromise = fetchUser(userId)
return (
<Suspense fallback={<div>加载用户中...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
文档元数据组件
import { useEffect } from 'react'
// React 19 允许在组件中渲染元数据
export function ProductPage({ product }: { product: Product }) {
return (
<div>
<title>{product.name} - 我的商店</title>
<meta name="description" content={product.description} />
<meta property="og:title" content={product.name} />
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
)
}
服务器组件 vs 客户端组件
重要:本技能专注于客户端 SPA。然而,在使用支持 React 服务器组件(RSC)的框架时:
当不使用 Next.js/Remix(本技能重点)
在 SPA 中,所有组件默认为客户端组件。您可以完全访问:
- 浏览器 API(window, document, localStorage)
- 事件处理器(onClick, onChange 等)
- React 钩子(useState, useEffect 等)
- 客户端路由
当使用 Next.js 或 Remix(本技能外)
服务器组件是默认的,不能使用:
- 客户端钩子或状态
- 浏览器 API
- 事件处理器
用 'use client' 标记组件以使其成为客户端组件:
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
UI 组件模式
设计系统基础
// src/components/ui/Button.tsx
import { ComponentPropsWithoutRef, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'内联弹性项目,居中,圆角,字体中等,过渡颜色,焦点可见轮廓,禁用指针事件,禁用透明度',
{
variants: {
variant: {
default: '背景主要 文字白色 悬停背景主要/90',
secondary: '背景次要 文字白色 悬停背景次要/90',
outline: '边框灰色300 背景透明 悬停背景灰色100',
ghost: '悬停背景灰色100',
danger: '背景红色600 文字白色 悬停背景红色700',
},
size: {
sm: '高8 内边距3 文字小',
md: '高10 内边距4',
lg: '高12 内边距6 文字大',
icon: '高10 宽10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => {
return (
<button
ref={ref}
className={buttonVariants({ variant, size, className })}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? (
<>
<svg className="动画旋转 -ml-1 mr-2 h-4 w-4" />
加载中...
</>
) : (
children
)}
</button>
)
}
)
Button.displayName = 'Button'
组合式卡片组件
// src/components/ui/Card.tsx
import { ComponentPropsWithoutRef, forwardRef } from 'react'
export const Card = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={`圆角边框 背景白色 阴影 ${className}`}
{...props}
/>
)
)
export const CardHeader = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={`p-6 ${className}`} {...props} />
)
)
export const CardTitle = forwardRef<HTMLHeadingElement, ComponentPropsWithoutRef<'h3'>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={`文字2xl 字体半粗 ${className}`} {...props} />
)
)
export const CardContent = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'>>(
({ className, ...props }, ref) => (
<div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
)
)
// 用法
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
export function ProductCard({ product }: { product: Product }) {
return (
<Card>
<CardHeader>
<CardTitle>{product.name}</CardTitle>
</CardHeader>
<CardContent>
<p>{product.description}</p>
<p className="文字-xl 字体粗体">${product.price}</p>
</CardContent>
</Card>
)
}
多态组件
// src/components/ui/Text.tsx
import { ElementType, ComponentPropsWithoutRef } from 'react'
type TextProps<E extends ElementType> = {
as?: E
variant?: 'h1' | 'h2' | 'h3' | 'body' | 'small'
} & ComponentPropsWithoutRef<E>
export function Text<E extends ElementType = 'p'>({
as,
variant = 'body',
className,
...props
}: TextProps<E>) {
const Component = as || 'p'
const variantClasses = {
h1: '文字-4xl 字体粗体',
h2: '文字-3xl 字体半粗体',
h3: '文字-2xl 字体半粗体',
body: '文字基础',
small: '文字小 文字灰色600',
}
return (
<Component
className={`${variantClasses[variant]} ${className || ''}`}
{...props}
/>
)
}
// 用法 - 灵活的元素类型
<Text variant="h1">标题</Text>
<Text as="h1" variant="h1">带 h1 标签的标题</Text>
<Text as="span" variant="small">小文本在 span 中</Text>
渲染属性模式
// src/components/DataLoader.tsx
import { ReactNode } from 'react'
interface DataLoaderProps<T> {
data: T | null
isLoading: boolean
error: Error | null
children: (data: T) => ReactNode
loadingFallback?: ReactNode
errorFallback?: (error: Error) => ReactNode
}
export function DataLoader<T>({
data,
isLoading,
error,
children,
loadingFallback = <div>加载中...</div>,
errorFallback = (err) => <div>错误: {err.message}</div>,
}: DataLoaderProps<T>) {
if (isLoading) return <>{loadingFallback}</>
if (error) return <>{errorFallback(error)}</>
if (!data) return null
return <>{children(data)}</>
}
// 用法
import { useFetch } from '@/hooks/useFetch'
export function UsersList() {
const { data, isLoading, error } = useFetch<User[]>('/api/users')
return (
<DataLoader data={data} isLoading={isLoading} error={error}>
{(users) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</DataLoader>
)
}
项目设置
使用 Bun 和 Vite 的初始设置
# 使用 Vite 模板创建新的 React 应用
bun create vite my-app --template react-ts
cd my-app
# 安装依赖
bun install
# 添加 React Router 和 Jotai
bun add react-router jotai
# 添加开发依赖
bun add -D @types/react @types/react-dom
# 启动开发服务器
bun run dev
Vite 配置
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [
react({
// 启用 React Refresh 以实现快速刷新
fastRefresh: true,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
"@hooks": path.resolve(__dirname, "./src/hooks"),
"@store": path.resolve(__dirname, "./src/store"),
"@utils": path.resolve(__dirname, "./src/utils"),
},
},
server: {
port: 3000,
open: true,
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
"react-vendor": ["react", "react-dom"],
router: ["react-router"],
state: ["jotai"],
},
},
},
},
});
TypeScript 配置
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@store/*": ["./src/store/*"],
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
React Router v7 模式
使用 createBrowserRouter 的路由器设置
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createBrowserRouter } from 'react-router'
import './index.css'
// 导入路由组件
import { RootLayout } from './layouts/RootLayout'
import { HomePage } from './pages/HomePage'
import { AboutPage } from './pages/AboutPage'
import { UsersPage } from './pages/users/UsersPage'
import { UserDetailPage } from './pages/users/UserDetailPage'
import { ErrorPage } from './pages/ErrorPage'
import { NotFoundPage } from './pages/NotFoundPage'
// 创建带有类型安全路由定义的路由器
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: 'about',
element: <AboutPage />,
},
{
path: 'users',
children: [
{
index: true,
element: <UsersPage />,
loader: async () => {
// 用户列表的数据加载
const response = await fetch('/api/users')
return response.json()
},
},
{
path: ':userId',
element: <UserDetailPage />,
loader: async ({ params }) => {
// 特定用户的数据加载
const response = await fetch(`/api/users/${params.userId}`)
if (!response.ok) {
throw new Response('用户未找到', { status: 404 })
}
return response.json()
},
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
],
},
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)
带有 Outlet 的根布局
// src/layouts/RootLayout.tsx
import { Outlet, Link, useNavigation } from 'react-router'
export function RootLayout() {
const navigation = useNavigation()
const isNavigating = navigation.state === 'loading'
return (
<div className="app">
<header>
<nav>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
<Link to="/users">用户</Link>
</nav>
</header>
<main>
{isNavigating && <div className="loading-bar">加载中...</div>}
<Outlet />
</main>
<footer>
<p>© 2025 我的应用</p>
</footer>
</div>
)
}
使用加载器的数据加载
// src/pages/users/UsersPage.tsx
import { useLoaderData, Link } from 'react-router'
interface User {
id: string
name: string
email: string
}
export function UsersPage() {
// 类型安全的加载器数据访问
const users = useLoaderData() as User[]
return (
<div>
<h1>用户</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>
{user.name} ({user.email})
</Link>
</li>
))}
</ul>
</div>
)
}
导航钩子
// src/components/UserForm.tsx
import { useNavigate, useSearchParams } from 'react-router'
import { useState } from 'react'
export function UserForm() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [name, setName] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 创建用户
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const user = await response.json()
// 以编程方式导航
navigate(`/users/${user.id}`)
}
const filter = searchParams.get('filter') || ''
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="用户名"
/>
<input
type="text"
value={filter}
onChange={(e) => setSearchParams({ filter: e.target.value })}
placeholder="过滤"
/>
<button type="submit">创建用户</button>
</form>
)
}
受保护路由模式
// src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router'
import { useAtomValue } from 'jotai'
import { userAtom } from '@store/auth'
export function ProtectedRoute() {
const user = useAtomValue(userAtom)
if (!user) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
// 在路由器配置中使用
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'dashboard',
element: <ProtectedRoute />,
children: [
{
index: true,
element: <DashboardPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
],
},
])
Jotai 状态管理
基本原子
// src/store/counter.ts
import { atom } from 'jotai'
// 原始原子
export const countAtom = atom(0)
// 只读派生原子
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
// 读写派生原子
export const incrementAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
)
export const decrementAtom = atom(
null,
(get, set) => set(countAtom, get(countAtom) - 1)
)
// 在组件中使用
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
export function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubled = useAtomValue(doubledCountAtom)
const increment = useSetAtom(incrementAtom)
return (
<div>
<p>计数: {count}</p>
<p>翻倍: {doubled}</p>
<button onClick={increment}>增加</button>
<button onClick={() => setCount((c) => c - 1)}>减少</button>
</div>
)
}
异步原子
// src/store/users.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
interface User {
id: string
name: string
email: string
}
// 用于获取用户的异步原子
export const usersAtom = atom(async () => {
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error('获取用户失败')
}
return response.json() as Promise<User[]>
})
// 带有刷新能力的原子
export const refreshUsersAtom = atom(0)
export const refreshableUsersAtom = atom(async (get) => {
get(refreshUsersAtom) // 用于刷新的依赖
const response = await fetch('/api/users')
return response.json() as Promise<User[]>
})
// 在组件中使用
import { useAtomValue, useSetAtom } from 'jotai'
import { Suspense } from 'react'
function UsersList() {
const users = useAtomValue(usersAtom)
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export function UsersContainer() {
return (
<Suspense fallback={<div>加载用户中...</div>}>
<UsersList />
</Suspense>
)
}
原子族
// src/store/todos.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
interface Todo {
id: string
title: string
completed: boolean
}
// 基础待办事项原子
export const todosAtom = atom<Todo[]>([])
// 用于单个待办事项的原子族
export const todoAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(todosAtom).find((todo) => todo.id === id),
(get, set, update: Partial<Todo>) => {
const todos = get(todosAtom)
const index = todos.findIndex((todo) => todo.id === id)
if (index !== -1) {
const newTodos = [...todos]
newTodos[index] = { ...newTodos[index]!, ...update }
set(todosAtom, newTodos)
}
}
)
)
// 用法
function TodoItem({ id }: { id: string }) {
const [todo, updateTodo] = useAtom(todoAtomFamily(id))
if (!todo) return null
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => updateTodo({ completed: e.target.checked })}
/>
<span>{todo.title}</span>
</div>
)
}
使用 atomWithStorage 的持久存储
// src/store/auth.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface User {
id: string;
name: string;
email: string;
token: string;
}
// 自动持久化到 localStorage
export const userAtom = atomWithStorage<User | null>("user", null);
export const isAuthenticatedAtom = atom((get) => {
const user = get(userAtom);
return user !== null;
});
// 登录操作
export const loginAtom = atom(
null,
async (get, set, credentials: { email: string; password: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error("登录失败");
}
const user = await response.json();
set(userAtom, user);
return user;
},
);
// 登出操作
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null);
});
原子组合的复杂状态
// src/store/cart.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface CartItem {
productId: string;
quantity: number;
price: number;
}
// 持久化的购物车项目
export const cartItemsAtom = atomWithStorage<CartItem[]>("cart", []);
// 派生:总项目计数
export const cartCountAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.quantity, 0);
});
// 派生:总价格
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
// 操作:添加到购物车
export const addToCartAtom = atom(
null,
(get, set, item: Omit<CartItem, "quantity"> & { quantity?: number }) => {
const items = get(cartItemsAtom);
const existingIndex = items.findIndex(
(i) => i.productId === item.productId,
);
if (existingIndex !== -1) {
const newItems = [...items];
const existing = newItems[existingIndex]!;
newItems[existingIndex] = {
...existing,
quantity: existing.quantity + (item.quantity ?? 1),
};
set(cartItemsAtom, newItems);
} else {
set(cartItemsAtom, [...items, { ...item, quantity: item.quantity ?? 1 }]);
}
},
);
// 操作:从购物车移除
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
const items = get(cartItemsAtom);
set(
cartItemsAtom,
items.filter((item) => item.productId !== productId),
);
});
// 操作:清空购物车
export const clearCartAtom = atom(null, (get, set) => {
set(cartItemsAtom, []);
});
组件模式
使用 TypeScript 的函数式组件
// src/components/Button.tsx
import { ComponentPropsWithoutRef, forwardRef } from 'react'
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => {
return (
<button
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? '加载中...' : children}
</button>
)
}
)
Button.displayName = 'Button'
自定义钩子
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
// 用法
function SearchInput() {
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
useEffect(() => {
if (debouncedSearch) {
// 执行搜索
fetchResults(debouncedSearch)
}
}, [debouncedSearch])
return (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)
}
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// src/hooks/useFetch.ts
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
refetch: () => void;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [refetchIndex, setRefetchIndex] = useState(0);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP 错误! 状态: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => {
controller.abort();
};
}, [url, refetchIndex]);
const refetch = () => setRefetchIndex((i) => i + 1);
return { data, error, isLoading, refetch };
}
复合组件模式
// src/components/Tabs/Tabs.tsx
import {
createContext,
useContext,
useState,
ReactNode,
} from 'react'
interface TabsContextValue {
activeTab: string
setActiveTab: (id: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
function useTabs() {
const context = useContext(TabsContext)
if (!context) {
throw new Error('Tabs 组件必须在 <Tabs> 内使用')
}
return context
}
interface TabsProps {
defaultTab: string
children: ReactNode
}
export function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
interface TabListProps {
children: ReactNode
}
function TabList({ children }: TabListProps) {
return <div className="tab-list">{children}</div>
}
interface TabProps {
id: string
children: ReactNode
}
function Tab({ id, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs()
return (
<button
className={`tab ${activeTab === id ? 'active' : ''}`}
onClick={() => setActiveTab(id)}
>
{children}
</button>
)
}
interface TabPanelsProps {
children: ReactNode
}
function TabPanels({ children }: TabPanelsProps) {
return <div className="tab-panels">{children}</div>
}
interface TabPanelProps {
id: string
children: ReactNode
}
function TabPanel({ id, children }: TabPanelProps) {
const { activeTab } = useTabs()
if (activeTab !== id) return null
return <div className="tab-panel">{children}</div>
}
// 导出复合组件
Tabs.TabList = TabList
Tabs.Tab = Tab
Tabs.TabPanels = TabPanels
Tabs.TabPanel = TabPanel
// 用法
export function Example() {
return (
<Tabs defaultTab="profile">
<Tabs.TabList>
<Tabs.Tab id="profile">个人资料</Tabs.Tab>
<Tabs.Tab id="settings">设置</Tabs.Tab>
<Tabs.Tab id="notifications">通知</Tabs.Tab>
</Tabs.TabList>
<Tabs.TabPanels>
<Tabs.TabPanel id="profile">
<h2>个人资料内容</h2>
</Tabs.TabPanel>
<Tabs.TabPanel id="settings">
<h2>设置内容</h2>
</Tabs.TabPanel>
<Tabs.TabPanel id="notifications">
<h2>通知内容</h2>
</Tabs.TabPanel>
</Tabs.TabPanels>
</Tabs>
)
}
表单处理
带验证的受控表单
// src/components/LoginForm.tsx
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router'
import { useSetAtom } from 'jotai'
import { loginAtom } from '@store/auth'
interface FormErrors {
email?: string
password?: string
}
export function LoginForm() {
const navigate = useNavigate()
const login = useSetAtom(loginAtom)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!email) {
newErrors.email = '邮箱为必填项'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = '邮箱格式无效'
}
if (!password) {
newErrors.password = '密码为必填项'
} else if (password.length < 8) {
newErrors.password = '密码至少需要8个字符'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!validate()) {
return
}
setIsSubmitting(true)
try {
await login({ email, password })
navigate('/dashboard')
} catch (error) {
setErrors({
email: '凭据无效',
})
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '登录中...' : '登录'}
</button>
</form>
)
}
使用自定义钩子的表单
// src/hooks/useForm.ts
import { useState, ChangeEvent, FormEvent } from 'react'
interface UseFormOptions<T> {
initialValues: T
validate?: (values: T) => Partial<Record<keyof T, string>>
onSubmit: (values: T) => void | Promise<void>
}
export function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setValues((prev) => ({ ...prev, [name]: value }))
// 清除此字段的错误
if (errors[name as keyof T]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[name as keyof T]
return newErrors
})
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (validate) {
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
}
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}
const reset = () => {
setValues(initialValues)
setErrors({})
setIsSubmitting(false)
}
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset,
setValues,
setErrors,
}
}
// 用法
interface ContactFormData {
name: string
email: string
message: string
}
export function ContactForm() {
const { values, errors, isSubmitting, handleChange, handleSubmit } =
useForm<ContactFormData>({
initialValues: {
name: '',
email: '',
message: '',
},
validate: (values) => {
const errors: Partial<Record<keyof ContactFormData, string>> = {}
if (!values.name) {
errors.name = '姓名为必填项'
}
if (!values.email) {
errors.email = '邮箱为必填项'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = '邮箱无效'
}
if (!values.message) {
errors.message = '消息为必填项'
}
return errors
},
onSubmit: async (values) => {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
},
})
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={values.name}
onChange={handleChange}
placeholder="姓名"
/>
{errors.name && <span>{errors.name}</span>}
<input
name="email"
value={values.email}
onChange={handleChange}
placeholder="邮箱"
/>
{errors.email && <span>{errors.email}</span>}
<textarea
name="message"
value={values.message}
onChange={handleChange}
placeholder="消息"
/>
{errors.message && <span>{errors.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '发送中...' : '发送'}
</button>
</form>
)
}
最佳实践
组件组织
src/
├── components/ # 可重用 UI 组件
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── Button.module.css
│ └── Input/
├── pages/ # 路由组件
│ ├── HomePage.tsx
│ └── users/
│ ├── UsersPage.tsx
│ └── UserDetailPage.tsx
├── layouts/ # 布局组件
│ └── RootLayout.tsx
├── hooks/ # 自定义钩子
│ ├── useDebounce.ts
│ └── useForm.ts
├── store/ # Jotai 原子
│ ├── auth.ts
│ ├── cart.ts
│ └── users.ts
├── utils/ # 实用函数
│ └── api.ts
├── types/ # TypeScript 类型
│ └── index.ts
└── main.tsx # 入口点
性能优化
import { memo, useMemo, useCallback, lazy, Suspense } from 'react'
// 记忆化昂贵组件
export const ExpensiveList = memo(function ExpensiveList({
items,
}: {
items: Item[]
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
})
// 记忆化昂贵计算
function FilteredList({ items, filter }: { items: Item[]; filter: string }) {
const filteredItems = useMemo(() => {
return items.filter((item) => item.name.includes(filter))
}, [items, filter])
return <ExpensiveList items={filteredItems} />
}
// 记忆化回调以防止子组件重新渲染
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
setCount((c) => c + 1)
}, [])
return <Child onClick={handleClick} />
}
// 使用懒加载进行代码分割
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
function App() {
return (
<Suspense fallback={<div>加载中...</div>}>
<DashboardPage />
</Suspense>
)
}
错误边界
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('错误被边界捕获:', error, errorInfo)
}
reset = () => {
this.setState({ error: null })
}
render() {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div role="alert">
<h2>出错了</h2>
<pre>{this.state.error.message}</pre>
<button onClick={this.reset}>重试</button>
</div>
)
}
return this.props.children
}
}
// 用法
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>错误: {error.message}</h1>
<button onClick={reset}>重试</button>
</div>
)}
>
<YourApp />
</ErrorBoundary>
)
}
可访问性(a11y)
带有焦点管理的模态对话框
// src/components/Modal.tsx
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const previousActiveElement = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
dialogRef.current?.focus()
// 将焦点限制在模态框内
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements?.[0] as HTMLElement
const lastElement = focusableElements?.[
focusableElements.length - 1
] as HTMLElement
const handleTab = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleTab)
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleTab)
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
previousActiveElement.current?.focus()
}
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭模态框">
关闭
</button>
</div>
</div>,
document.body
)
}
带实时验证的可访问表单
// src/components/AccessibleForm.tsx
import { useState, useId } from 'react'
export function AccessibleForm() {
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const emailId = useId()
const errorId = useId()
const validateEmail = (value: string) => {
if (!value) {
setEmailError('邮箱为必填项')
return false
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setEmailError('请输入有效的邮箱地址')
return false
}
setEmailError('')
return true
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
if (validateEmail(email)) {
// 提交表单
}
}}
noValidate
>
<div>
<label htmlFor={emailId}>
邮箱地址
<span aria-label="required">*</span>
</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => validateEmail(e.target.value)}
aria-invalid={!!emailError}
aria-describedby={emailError ? errorId : undefined}
aria-required="true"
/>
{emailError && (
<span id={errorId} role="alert" className="error">
{emailError}
</span>
)}
</div>
<button type="submit">提交</button>
</form>
)
}
带加载状态的可访问按钮
// src/components/AccessibleButton.tsx
import { ComponentPropsWithoutRef, forwardRef } from 'react'
interface AccessibleButtonProps extends ComponentPropsWithoutRef<'button'> {
isLoading?: boolean
loadingText?: string
}
export const AccessibleButton = forwardRef<
HTMLButtonElement,
AccessibleButtonProps
>(({ isLoading, loadingText = '加载中', children, ...props }, ref) => {
return (
<button
ref={ref}
disabled={isLoading || props.disabled}
aria-busy={isLoading}
aria-live="polite"
{...props}
>
{isLoading ? (
<>
<span className="visually-hidden">{loadingText}</span>
<span aria-hidden="true">
<svg className="animate-spin" />
{loadingText}
</span>
</>
) : (
children
)}
</button>
)
})
AccessibleButton.displayName = 'AccessibleButton'
跳转到内容链接
// src/components/SkipLink.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
style={{
position: 'absolute',
left: '-10000px',
top: 'auto',
width: '1px',
height: '1px',
overflow: 'hidden',
}}
onFocus={(e) => {
e.currentTarget.style.left = '0'
e.currentTarget.style.width = 'auto'
e.currentTarget.style.height = 'auto'
}}
onBlur={(e) => {
e.currentTarget.style.left = '-10000px'
e.currentTarget.style.width = '1px'
e.currentTarget.style.height = '1px'
}}
>
跳转到主要内容
</a>
)
}
// 在布局中使用
export function Layout({ children }: { children: ReactNode }) {
return (
<div>
<SkipLink />
<header>
<nav>...</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</div>
)
}
可访问的下拉菜单
// src/components/DropdownMenu.tsx
import { useState, useRef, useEffect, useId } from 'react'
interface DropdownMenuProps {
trigger: React.ReactNode
items: Array<{ label: string; onClick: () => void }>
}
export function DropdownMenu({ trigger, items }: DropdownMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLUListElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
useEffect(() => {
if (isOpen && menuRef.current) {
const firstItem = menuRef.current.querySelector('button') as HTMLButtonElement
firstItem?.focus()
}
}, [isOpen])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault()
setIsOpen(true)
}
return
}
switch (e.key) {
case 'Escape':
setIsOpen(false)
buttonRef.current?.focus()
break
case 'ArrowDown':
e.preventDefault()
const nextItem = (document.activeElement?.nextElementSibling as HTMLElement)
nextItem?.querySelector('button')?.focus()
break
case 'ArrowUp':
e.preventDefault()
const prevItem = (document.activeElement?.previousElementSibling as HTMLElement)
prevItem?.querySelector('button')?.focus()
break
}
}
return (
<div className="dropdown">
<button
ref={buttonRef}
aria-haspopup="true"
aria-expanded={isOpen}
aria-controls={menuId}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
>
{trigger}
</button>
{isOpen && (
<ul
ref={menuRef}
id={menuId}
role="menu"
className="dropdown-menu"
>
{items.map((item, index) => (
<li key={index} role="none">
<button
role="menuitem"
onClick={() => {
item.onClick()
setIsOpen(false)
buttonRef.current?.focus()
}}
onKeyDown={handleKeyDown}
>
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
)
}
用于公告的实时区域
// src/components/LiveRegion.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
import { createPortal } from 'react-dom'
interface LiveRegionContextValue {
announce: (message: string, priority?: 'polite' | 'assertive') => void
}
const LiveRegionContext = createContext<LiveRegionContextValue | undefined>(
undefined
)
export function useLiveRegion() {
const context = useContext(LiveRegionContext)
if (!context) {
throw new Error('useLiveRegion 必须在 LiveRegionProvider 内使用')
}
return context
}
export function LiveRegionProvider({ children }: { children: ReactNode }) {
const [message, setMessage] = useState('')
const [priority, setPriority] = useState<'polite' | 'assertive'>('polite')
const announce = (
newMessage: string,
newPriority: 'polite' | 'assertive' = 'polite'
) => {
setMessage('')
setTimeout(() => {
setMessage(newMessage)
setPriority(newPriority)
}, 100)
}
return (
<LiveRegionContext.Provider value={{ announce }}>
{children}
{createPortal(
<div
role="status"
aria-live={priority}
aria-atomic="true"
className="visually-hidden"
>
{message}
</div>,
document.body
)}
</LiveRegionContext.Provider>
)
}
// 用法
function SaveButton() {
const { announce } = useLiveRegion()
const handleSave = async () => {
try {
await saveData()
announce('数据保存成功', 'polite')
} catch (error) {
announce('数据保存失败', 'assertive')
}
}
return <button onClick={handleSave}>保存</button>
}
视觉隐藏工具类
/* src/styles/utilities.css */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
反模式
禁止:切勿使用这些
// 禁止:Next.js(这是 SPA 技能,不是 SSR)
// 不要使用 Next.js、App Router、Next.js 的服务器组件
// 不要使用:next/navigation、next/router、next/link 等
// 禁止:Remix(这是 SPA 技能)
// 不要使用 Remix 框架
// 禁止:create-react-app
// 始终使用 Vite 进行新项目
// CRA 已弃用且未维护
// 禁止:直接使用 webpack
// 始终使用 Vite 作为捆绑器
// 不要创建自定义 webpack 配置
// 禁止:Redux(当可以使用 Jotai 时)
// 不要使用 Redux、Redux Toolkit 或 React-Redux
// 仅使用 Jotai 进行全局状态管理
// 例外:已经使用 Redux 的现有项目
// 禁止:使用 Context API 进行全局状态管理
// 不要使用 Context + useContext 进行应用程序状态管理
// Context 适用于组件级状态(主题等)
// 所有全局应用程序状态使用 Jotai 原子
要避免的常见错误
// 错误:突变而不是不可变性
const [items, setItems] = useState<Item[]>([])
items.push(newItem) // 直接突变
setItems(items) // React 不会检测到变化
// 正确:不可变更新
setItems([...items, newItem])
setItems((prev) => [...prev, newItem])
// 错误:useEffect 中缺少依赖项
useEffect(() => {
fetchData(userId)
}, []) // userId 不在依赖项中
// 正确:包括所有依赖项
useEffect(() => {
fetchData(userId)
}, [userId])
// 错误:应该计算的派生状态
const [items, setItems] = useState<Item[]>([])
const [filteredItems, setFilteredItems] = useState<Item[]>([])
useEffect(() => {
setFilteredItems(items.filter(filter))
}, [items, filter])
// 正确:在渲染期间计算
const filteredItems = useMemo(
() => items.filter(filter),
[items, filter]
)
// 错误:通过多层传递属性
function App() {
const [user, setUser] = useState<User>()
return <Level1 user={user} setUser={setUser} />
}
// 正确:使用 Jotai 进行共享状态
const userAtom = atom<User | null>(null)
function App() {
return <Level1 />
}
function DeepChild() {
const [user, setUser] = useAtom(userAtom)
// 直接访问,无需属性传递
}
// 错误:在渲染中创建函数
function Parent() {
return (
<Child
onClick={() => {
doSomething()
}}
/>
)
}
// 正确:使用 useCallback 提供稳定引用
function Parent() {
const handleClick = useCallback(() => {
doSomething()
}, [])
return <Child onClick={handleClick} />
}
// 错误:在没有 Suspense 的组件中获取
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false))
}, [userId])
if (loading) return <div>加载中...</div>
// ...
}
// 正确:使用异步原子和 Suspense
const userAtomFamily = atomFamily((userId: string) =>
atom(async () => {
const response = await fetch(`/api/users/${userId}`)
return response.json()
})
)
function UserProfile({ userId }: { userId: string }) {
const user = useAtomValue(userAtomFamily(userId))
return <div>{user.name}</div>
}
function UserContainer({ userId }: { userId: string }) {
return (
<Suspense fallback={<div>加载中...</div>}>
<UserProfile userId={userId} />
</Suspense>
)
}
不要使用
- Next.js - 用于 SSR 项目,不是 SPA
- Remix - 用于全栈项目,不是 SPA
- Redux - 使用 Jotai 代替,以实现更简单、更原子的状态管理
- 用于全局状态的 Context API - 使用 Jotai 原子代替
- create-react-app - 已弃用,使用 Vite
- webpack - 使用 Vite 捆绑器
- 类组件 - 使用带有钩子的函数式组件
- 默认导出 - 更倾向于命名导出以便于重构
测试
使用 Vitest 进行组件测试
// vite.config.ts - 添加测试配置
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
// src/test/setup.ts
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
afterEach(() => {
cleanup()
})
// src/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('正确渲染子元素', () => {
render(<Button>点击我</Button>)
expect(screen.getByRole('button', { name: /点击我/i })).toBeInTheDocument()
})
it('点击时调用 onClick', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>点击我</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('当 isLoading 为 true 时禁用', () => {
render(<Button isLoading>点击我</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
使用 Jotai 进行测试
// src/store/counter.test.ts
import { renderHook, act } from "@testing-library/react";
import { useAtom } from "jotai";
import { describe, it, expect } from "vitest";
import { countAtom, incrementAtom } from "./counter";
describe("计数器原子", () => {
it("增加计数", () => {
const { result } = renderHook(() => ({
count: useAtom(countAtom),
increment: useAtom(incrementAtom),
}));
expect(result.current.count[0]).toBe(0);
act(() => {
result.current.increment[1]();
});
expect(result.current.count[0]).toBe(1);
});
});
此 React 技能提供了使用 React Router、Jotai 和 Vite 构建现代 SPA 的全面指导,同时明确避免不适合 SPA 范式的 Next.js、Redux 和其他工具。