🏗️ 写完 Todos 之后,大型 React 项目的 7 个架构真相
摘要:很多同学学完 React 基础后,写个小 Demo 没问题,但一到实际项目就懵了------文件怎么组织?状态怎么管理?hooks 写在哪?本文从一个最简单的 Todos 学习项目出发,结合真实踩坑经验,带你一步步看清大型 React 项目的全貌。
📌 前言
最近在带一些同学学 React,从 TypeScript 类型定义、自定义 Hooks 到组件拆分,大家上手都挺快。但有个问题被反复问到:
"老师,Todos 这种小项目我能写,但实际工作中的大型项目,架构到底是啥样的?"
好问题。今天就从我们正在写的 todos-ts-hooks 项目出发,聊聊从「能跑」到「能维护」之间,到底差了些什么。
⚠️ 本文不是纯理论堆砌,每个章节都会附上真实踩坑案例------都是我在实际项目中踩过的坑,希望你能绕过去。
🎯 本文适合谁
- 学完了 React 基础,想了解项目架构的初中级开发者
- 会写自定义 Hooks,但不知道 Hooks 在大项目中怎么组织的同学
- 准备面试或进阶,想补齐「工程化」这块短板的前端人
📚 一、先看看我们的 Todos 项目长啥样
我们当前的项目结构很简单:
bash
todos-ts-hooks/
├── src/
│ ├── types/
│ │ └── todo.ts # 类型定义
│ ├── hooks/
│ │ └── useTodos.ts # 自定义 Hook
│ ├── components/
│ │ ├── TodoInput.tsx # 输入组件
│ │ ├── TodoItem.tsx # 单项组件
│ │ ├── TodoList.tsx # 列表组件
│ │ └── TodoFilter.tsx # 筛选组件
│ ├── App.tsx # 根组件
│ └── main.tsx # 入口文件
├── package.json
└── vite.config.ts
核心代码就两块:
类型定义 --- 用 TypeScript 约束数据结构:
typescript
// types/todo.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
}
export type FilterType = 'all' | 'completed' | 'uncompleted';
自定义 Hook --- 把状态和逻辑封装到 useTodos:
typescript
// hooks/useTodos.ts
import { useState } from 'react';
import type { Todo, FilterType } from '../types/todo';
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
const addTodo = (text: string) => {
if (!text.trim()) return;
const newTodo: Todo = {
id: Date.now().toString(),
text: text.trim(),
completed: false,
};
setTodos(prev => [...prev, newTodo]);
};
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
);
};
const deleteTodos = (id: string) => {
setTodos(prev => prev.filter(item => item.id !== id));
};
const clearCompleted = () => {
setTodos(prev => prev.filter(item => !item.completed));
};
return {
todos,
filter,
setFilter,
addTodo,
toggleTodo,
deleteTodos,
clearCompleted,
};
}
这个结构对学习来说完全够用。但如果这个 Todos 项目要变成一个「待办管理系统」------加上用户登录、多列表、数据持久化、团队协作......现有的结构就会崩掉。
接下来我们一个个问题拆解。
📂 二、目录结构:大型项目的第一道门槛
小项目的痛点
我们的 Todos 项目用的是按类型分文件夹 的方式(types/、hooks/、components/)。这在文件少的时候很清晰,但当项目膨胀到 50+ 个组件、20+ 个页面时,你会发现自己在 components/ 文件夹里翻半天找不到想要的文件。
🪤 真实踩坑:平铺式目录的噩梦
我曾经接手过一个项目,所有的组件都平铺在 src/components/ 下,一共 87 个文件。新来的同事要改一个「用户信息编辑表单」,在 components/ 里找了 20 分钟,最后发现文件名叫 UForm.tsx。
教训 :目录结构不是给别人看的,是给三个月后的自己 和刚入职的同事看的。
大型项目的推荐结构
实际工作中,更推荐按功能模块(Feature-based) 组织代码:
python
src/
├── app/ # 应用级配置
│ ├── providers/ # 全局 Provider(主题、国际化等)
│ ├── router.tsx # 路由配置
│ └── store.ts # 全局状态(如果需要)
│
├── features/ # ⭐ 业务功能模块
│ ├── auth/ # 认证模块
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── RegisterForm.tsx
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── useLogin.ts
│ │ ├── services/
│ │ │ └── authApi.ts
│ │ ├── types/
│ │ │ └── auth.ts
│ │ └── index.ts # 模块导出
│ │
│ ├── todo/ # 待办模块(我们的项目)
│ │ ├── components/
│ │ │ ├── TodoInput.tsx
│ │ │ ├── TodoItem.tsx
│ │ │ ├── TodoList.tsx
│ │ │ └── TodoFilter.tsx
│ │ ├── hooks/
│ │ │ ├── useTodos.ts
│ │ │ └── useTodoFilter.ts
│ │ ├── services/
│ │ │ └── todoApi.ts
│ │ ├── types/
│ │ │ └── todo.ts
│ │ └── index.ts
│ │
│ └── dashboard/ # 仪表盘模块
│ └── ...
│
├── shared/ # ⭐ 公共/共享层
│ ├── components/ # 通用组件
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ └── index.ts
│ │ ├── Modal/
│ │ └── EmptyState/
│ ├── hooks/ # 通用 Hooks
│ │ ├── useDebounce.ts
│ │ ├── useLocalStorage.ts
│ │ └── useMediaQuery.ts
│ ├── utils/ # 工具函数
│ │ ├── format.ts
│ │ └── storage.ts
│ └── constants/ # 常量
│ └── index.ts
│
├── assets/ # 静态资源
│ ├── images/
│ └── fonts/
│
└── styles/ # 全局样式
├── variables.css
└── global.css
核心原则
| 原则 | 说明 | 反例 |
|---|---|---|
| 高内聚 | 相关的组件、hooks、类型放在同一个 feature 目录下 | 所有 hooks 平铺在 hooks/ 里 |
| 低耦合 | feature 之间不直接引用,通过 shared 层通信 | Todo 模块直接 import Auth 模块的代码 |
| 就近原则 | 样式、测试、类型和组件放在一起 | 样式文件单独放 styles/ 目录 |
| index.ts 导出 | 每个模块通过 index.ts 控制对外暴露的接口 | 直接从深层路径 import |
typescript
// features/todo/index.ts --- 模块的"门面"
// 外部只通过这个文件引用 todo 模块
export { TodoList } from './components/TodoList';
export { TodoInput } from './components/TodoInput';
export { useTodos } from './hooks/useTodos';
export type { Todo, FilterType } from './types/todo';
💡 记住这个口诀 :业务按功能拆,公共抽出来,入口统一管。
🪝 三、自定义 Hooks:从能用到好用
我们的 useTodos 已经做得不错了------把状态和逻辑从组件中抽离出来。但在大型项目中,Hooks 的设计还有更多讲究。
1. 单一职责原则
当前的 useTodos 做了太多事情:管理列表数据、管理筛选状态、CRUD 操作。拆开更清晰:
typescript
// hooks/useTodoList.ts --- 只管数据 CRUD
import { useState } from 'react';
import type { Todo } from '../types/todo';
export function useTodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
if (!text.trim()) return;
setTodos(prev => [
...prev,
{ id: Date.now().toString(), text: text.trim(), completed: false },
]);
};
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
);
};
const deleteTodo = (id: string) => {
setTodos(prev => prev.filter(item => item.id !== id));
};
const clearCompleted = () => {
setTodos(prev => prev.filter(item => !item.completed));
};
return { todos, addTodo, toggleTodo, deleteTodo, clearCompleted };
}
typescript
// hooks/useTodoFilter.ts --- 只管筛选逻辑
import { useState, useMemo } from 'react';
import type { Todo, FilterType } from '../types/todo';
export function useTodoFilter(todos: Todo[]) {
const [filter, setFilter] = useState<FilterType>('all');
const filteredTodos = useMemo(() => {
switch (filter) {
case 'completed':
return todos.filter(t => t.completed);
case 'uncompleted':
return todos.filter(t => !t.completed);
default:
return todos;
}
}, [todos, filter]);
return { filter, setFilter, filteredTodos };
}
typescript
// hooks/useTodos.ts --- 组合 Hook,对外保持原有接口
import { useTodoList } from './useTodoList';
import { useTodoFilter } from './useTodoFilter';
export function useTodos() {
const { todos, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodoList();
const { filter, setFilter, filteredTodos } = useTodoFilter(todos);
return {
todos: filteredTodos,
filter,
setFilter,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
};
}
这就是 Hooks 的组合模式------小 Hook 组合成大 Hook,就像小函数组合成大函数一样。
🪤 真实踩坑:Hook 拆分的度在哪?
有次我把一个表单拆成了 useFormData、useFormValidation、useFormSubmit、useFormDirty、useFormReset 五个 Hook,最后在页面组件里要这样用:
typescript
// ❌ 过度拆分 --- 调用方反而更复杂了
const { data, setField } = useFormData(initialValues);
const { errors, validate } = useFormValidation(rules);
const { submitting, submit } = useFormSubmit(api);
const { isDirty, resetDirty } = useFormDirty(data);
const { reset } = useFormReset(data, setField);
后来合并成了一个 useForm,内部再按逻辑拆分,对外只暴露一个接口:
typescript
// ✅ 合理拆分 --- 对外一个接口,内部逻辑清晰
const { data, errors, submitting, submit, reset, isDirty } = useForm({
initialValues,
rules,
onSubmit: async (values) => await api.save(values),
});
教训 :Hook 拆分的度取决于调用方的体验。如果拆完之后调用方反而更复杂了,说明拆过头了。
2. 通用 Hooks vs 业务 Hooks
在大型项目中,Hooks 分两类:
通用 Hooks(放 shared/hooks/):不依赖任何业务逻辑,任何项目都能用。
typescript
// shared/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
typescript
// shared/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
业务 Hooks(放 features/xxx/hooks/):和具体业务强相关。
typescript
// features/todo/hooks/useTodos.ts --- 我们的业务 Hook
// 包含业务逻辑:Todo 的增删改查
3. Hooks 命名规范
| 类型 | 命名模式 | 示例 |
|---|---|---|
| 状态管理 | use + 名词 |
useTodos、useUser |
| 副作用 | use + 动词 |
useFetch、useSubscribe |
| 事件处理 | use + 动词 + Handler |
useFormSubmit、useClickOutside |
| 计算派生 | use + 形容词/名词 |
useFilteredTodos、useWindowSize |
🧩 四、组件设计模式:从一坨 JSX 到优雅拆分
组件分层
大型项目中,组件通常分三层:
css
┌─────────────────────────────────────────┐
│ 容器组件 (Container) │
│ 负责数据获取、状态管理、业务逻辑 │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 业务组件 (Feature) │ │
│ │ 特定业务场景的组件,可复用性有限 │ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 通用组件 (Shared/UI) │ │ │
│ │ │ Button、Input、Modal、Table │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
以我们的 Todos 为例:
typescript
// features/todo/components/TodoList.tsx --- 容器组件
// 负责:获取数据、处理事件、组装子组件
import { useTodos } from '../hooks/useTodos';
import { TodoItem } from './TodoItem';
export function TodoList() {
const { todos, toggleTodo, deleteTodo } = useTodos();
if (todos.length === 0) {
return <div className="empty-state">暂无待办事项</div>;
}
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
))}
</ul>
);
}
typescript
// features/todo/components/TodoItem.tsx --- 展示组件
// 负责:只管渲染,不关心数据从哪来
import type { Todo } from '../types/todo';
interface TodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className="todo-text">{todo.text}</span>
<button className="delete-btn" onClick={() => onDelete(todo.id)}>
删除
</button>
</li>
);
}
🪤 真实踩坑:容器/展示组件的边界模糊
有次我在展示组件 TodoItem 里直接调了 API:
typescript
// ❌ 展示组件里发请求 --- 违反了职责分离
export function TodoItem({ todo, onDelete }: TodoItemProps) {
const handleDelete = async () => {
await fetch(`/api/todos/${todo.id}`, { method: 'DELETE' }); // 💀
onDelete(todo.id);
};
return <button onClick={handleDelete}>删除</button>;
}
结果写测试的时候发现,TodoItem 无法脱离 API 独立测试,每次跑测试都要 Mock 网络请求。
教训 :展示组件永远不要直接发请求。数据获取是容器组件的事,展示组件只通过 Props 接收数据和回调。
复合组件模式(Compound Components)
当组件之间有隐式的关联关系时,可以用复合组件模式:
typescript
// 复合组件写法 --- Ant Design、Radix UI 的常见模式
<TodoFilter>
<TodoFilter.Option value="all">全部</TodoFilter.Option>
<TodoFilter.Option value="completed">已完成</TodoFilter.Option>
<TodoFilter.Option value="uncompleted">未完成</TodoFilter.Option>
</TodoFilter>
实现方式:
typescript
import { createContext, useContext, useState } from 'react';
// 创建 Context 让子组件共享状态
const FilterContext = createContext<{
value: string;
onChange: (value: string) => void;
} | null>(null);
function TodoFilter({ children, defaultValue = 'all' }: {
children: React.ReactNode;
defaultValue?: string;
}) {
const [value, setValue] = useState(defaultValue);
return (
<FilterContext.Provider value={{ value, onChange: setValue }}>
<div className="todo-filter">{children}</div>
</FilterContext.Provider>
);
}
function Option({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = useContext(FilterContext);
if (!ctx) throw new Error('Option must be used within TodoFilter');
return (
<button
className={ctx.value === value ? 'active' : ''}
onClick={() => ctx.onChange(value)}
>
{children}
</button>
);
}
TodoFilter.Option = Option;
🗂️ 五、状态管理:什么时候需要 Redux?
我们的 Todos 项目只用了 useState,完全够用。但当项目变大,你需要考虑状态管理的分层:
状态分层策略
scss
┌──────────────────────────────────────┐
│ 全局状态 (Global State) │ ← Redux / Zustand / Jotai
│ 用户信息、主题、语言、全局通知 │
├──────────────────────────────────────┤
│ 服务端状态 (Server State) │ ← TanStack Query / SWR
│ API 数据缓存、请求状态、分页 │
├──────────────────────────────────────┤
│ 页面状态 (Page State) │ ← URL / useSearchParams
│ 筛选条件、排序、分页参数 │
├──────────────────────────────────────┤
│ 组件状态 (Local State) │ ← useState / useReducer
│ 表单输入、展开/折叠、hover 状态 │
└──────────────────────────────────────┘
🪤 真实踩坑:全局状态滥用
我见过一个项目,把所有状态都放进了 Redux:
typescript
// ❌ 全局状态滥用 --- modal 的开关为什么要全局管理?
const initialState = {
user: null,
theme: 'light',
isModalOpen: false, // 这是组件状态!
searchText: '', // 这是页面状态!
hoverItemId: null, // 这更是组件状态!
tableSortOrder: 'asc', // 这应该是 URL 参数!
};
结果 Redux DevTools 里密密麻麻几百个 action,排查一个 bug 要翻半天。
教训 :不是所有状态都要放全局。能用组件状态解决的,绝不用全局状态。全局状态是最后的手段,不是第一选择。
状态管理方案对比(2026 版)
| 方案 | 包体积 | 学习成本 | 适合场景 | 推荐指数 |
|---|---|---|---|---|
| useState/useReducer | 0 | ⭐ | 组件内局部状态 | ⭐⭐⭐⭐⭐ |
| Zustand | ~1KB | ⭐⭐ | 中小型项目全局状态 | ⭐⭐⭐⭐⭐ |
| TanStack Query | ~13KB | ⭐⭐⭐ | 服务端状态管理 | ⭐⭐⭐⭐⭐ |
| Jotai | ~3KB | ⭐⭐ | 原子化状态 | ⭐⭐⭐⭐ |
| Redux Toolkit | ~11KB | ⭐⭐⭐⭐ | 大型复杂项目 | ⭐⭐⭐ |
| MobX | ~16KB | ⭐⭐⭐ | 复杂响应式场景 | ⭐⭐⭐ |
💡 2026 年的建议 :大多数项目用 Zustand + TanStack Query 就够了。除非你的团队已经有 Redux 的技术积累,否则新项目不建议上 Redux。
以 Zustand 为例
如果我们的 Todos 项目需要全局状态(比如多个页面共享 Todo 数据),可以这样改造:
typescript
// features/todo/store/todoStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Todo } from '../types/todo';
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
clearCompleted: () => void;
}
export const useTodoStore = create<TodoStore>()(
persist(
(set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [
...state.todos,
{ id: Date.now().toString(), text: text.trim(), completed: false },
],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
deleteTodo: (id) =>
set((state) => ({
todos: state.todos.filter((t) => t.id !== id),
})),
clearCompleted: () =>
set((state) => ({
todos: state.todos.filter((t) => !t.completed),
})),
}),
{ name: 'todo-storage' } // 自动持久化到 localStorage
)
);
注意 persist 中间件------数据自动存到 localStorage,刷新页面不丢失。这在我们的学习项目中还要手动处理,Zustand 一行搞定。
🔀 六、路由组织:React Router 的最佳实践
当项目有了多个页面,路由就成了架构的重要一环。
路由配置集中管理
typescript
// app/router.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { Layout } from './Layout';
import { AuthGuard } from './components/AuthGuard';
// 路由懒加载 --- 大型项目必备,首屏只加载当前页面的代码
const Dashboard = lazy(() => import('@/features/dashboard/pages/Dashboard'));
const TodoPage = lazy(() => import('@/features/todo/pages/TodoPage'));
const Settings = lazy(() => import('@/features/settings/pages/Settings'));
const Login = lazy(() => import('@/features/auth/pages/Login'));
const Loading = () => <div className="page-loading">加载中...</div>;
export const router = createBrowserRouter([
{
path: '/',
element: (
<AuthGuard>
<Layout />
</AuthGuard>
),
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{
path: 'dashboard',
element: (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
),
},
{
path: 'todos',
element: (
<Suspense fallback={<Loading />}>
<TodoPage />
</Suspense>
),
},
{
path: 'settings',
element: (
<Suspense fallback={<Loading />}>
<Settings />
</Suspense>
),
},
],
},
{
path: '/login',
element: <Login />,
},
]);
路由守卫
typescript
// app/components/AuthGuard.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/features/auth/hooks/useAuth';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
// 记录用户想去的页面,登录后跳转回来
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
🪤 真实踩坑:没有路由守卫的线上事故
有个项目上线后,有人直接在浏览器地址栏输入 /admin 就进了管理后台------因为前端没有路由守卫,全靠后端接口鉴权。虽然后端会返回 401,但页面已经渲染出来了,用户体验很差,还有安全隐患。
教训 :前端鉴权不是为了安全,是为了体验。安全交给后端,但前端必须做好路由守卫和权限控制。
⚙️ 七、工程化配置:大型项目不能少的基建
路径别名
在 vite.config.ts 中配置路径别名,告别 ../../../../ 地狱:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@features': path.resolve(__dirname, 'src/features'),
'@shared': path.resolve(__dirname, 'src/shared'),
'@assets': path.resolve(__dirname, 'src/assets'),
},
},
});
typescript
// ❌ 之前:痛苦的相对路径,移动文件就要改一堆 import
import { useTodos } from '../../../features/todo/hooks/useTodos';
// ✅ 之后:清晰的别名路径,移动文件也不怕
import { useTodos } from '@/features/todo/hooks/useTodos';
TypeScript 路径映射
json
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"]
}
}
}
ESLint + Prettier 规范
json
// .eslintrc.cjs
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"react/react-in-jsx-scope": "off",
"no-console": "warn",
"@typescript-eslint/no-unused-vars": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
环境变量管理
ini
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_TITLE=Todo App (Dev)
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=Todo App
typescript
// 使用
const apiUrl = import.meta.env.VITE_API_BASE_URL;
🧪 八、测试策略:大型项目的质量保障
大项目不能靠手动点点点来保证质量。测试通常分三层:
scss
/\
/ \ E2E 测试 (Cypress / Playwright)
/ \ 少量,覆盖核心用户流程
/------\
/ \ 集成测试 (Testing Library)
/ \ 中量,测试组件交互
/------------\
/ \ 单元测试 (Vitest)
/ \ 大量,测试工具函数和 Hooks
Hooks 单元测试
typescript
// hooks/useTodos.test.ts
import { renderHook, act } from '@testing-library/react';
import { useTodos } from './useTodos';
describe('useTodos', () => {
it('添加一个待办', () => {
const { result } = renderHook(() => useTodos());
act(() => {
result.current.addTodo('学习 React');
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].text).toBe('学习 React');
expect(result.current.todos[0].completed).toBe(false);
});
it('空内容不添加', () => {
const { result } = renderHook(() => useTodos());
act(() => {
result.current.addTodo(' ');
});
expect(result.current.todos).toHaveLength(0);
});
it('切换待办状态', () => {
const { result } = renderHook(() => useTodos());
act(() => {
result.current.addTodo('学习 TypeScript');
});
act(() => {
result.current.toggleTodo(result.current.todos[0].id);
});
expect(result.current.todos[0].completed).toBe(true);
});
it('清除已完成', () => {
const { result } = renderHook(() => useTodos());
act(() => {
result.current.addTodo('任务1');
result.current.addTodo('任务2');
});
act(() => {
result.current.toggleTodo(result.current.todos[0].id);
});
act(() => {
result.current.clearCompleted();
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].text).toBe('任务2');
});
});
组件集成测试
typescript
// components/TodoItem.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoItem } from './TodoItem';
describe('TodoItem', () => {
const mockTodo = { id: '1', text: '学习 React', completed: false };
it('渲染待办文本', () => {
render(<TodoItem todo={mockTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('学习 React')).toBeInTheDocument();
});
it('点击复选框触发 onToggle', () => {
const onToggle = vi.fn();
render(<TodoItem todo={mockTodo} onToggle={onToggle} onDelete={vi.fn()} />);
fireEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith('1');
});
it('点击删除按钮触发 onDelete', () => {
const onDelete = vi.fn();
render(<TodoItem todo={mockTodo} onToggle={vi.fn()} onDelete={onDelete} />);
fireEvent.click(screen.getByText('删除'));
expect(onDelete).toHaveBeenCalledWith('1');
});
it('已完成的待办有 completed 样式', () => {
const completedTodo = { ...mockTodo, completed: true };
render(<TodoItem todo={completedTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByRole('listitem')).toHaveClass('completed');
});
});
API Mock:用 MSW 拦截请求
当你的组件涉及到 API 请求时,推荐使用 MSW(Mock Service Worker) 来 Mock 接口,而不是直接 Mock fetch:
typescript
// __mocks__/handlers.ts
import { http, HttpResponse } from 'msw';
const todos = [
{ id: '1', text: '学习 React', completed: false },
{ id: '2', text: '学习 TypeScript', completed: true },
];
export const handlers = [
// Mock GET /api/todos
http.get('/api/todos', () => {
return HttpResponse.json(todos);
}),
// Mock POST /api/todos
http.post('/api/todos', async ({ request }) => {
const body = await request.json();
const newTodo = { id: Date.now().toString(), ...body, completed: false };
todos.push(newTodo);
return HttpResponse.json(newTodo, { status: 201 });
}),
// Mock DELETE /api/todos/:id
http.delete('/api/todos/:id', ({ params }) => {
const index = todos.findIndex(t => t.id === params.id);
if (index === -1) return new HttpResponse(null, { status: 404 });
todos.splice(index, 1);
return new HttpResponse(null, { status: 204 });
}),
];
typescript
// __mocks__/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
typescript
// vitest.setup.ts
import { server } from './__mocks__/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
💡 为什么用 MSW 而不是直接 Mock fetch? MSW 在网络层拦截请求,你的业务代码不需要任何修改。测试跑完后把 MSW 去掉,代码依然正常工作。而 Mock fetch 会污染全局环境,还可能导致测试和实际行为不一致。
🚀 九、性能优化:大型项目绕不开的话题
当项目变大,性能问题会逐渐暴露。这里介绍三个最实用的优化手段。
1. React.memo --- 避免不必要的重渲染
typescript
// ❌ 没有 memo --- 父组件每次渲染,TodoItem 都会跟着渲染
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
console.log(`TodoItem ${todo.id} 渲染了`); // 每次父组件更新都会触发
return (
<li>
<span onClick={() => onToggle(todo.id)}>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>删除</button>
</li>
);
}
typescript
// ✅ 使用 memo --- 只有 props 变化时才重新渲染
import { memo } from 'react';
export const TodoItem = memo(function TodoItem({
todo,
onToggle,
onDelete,
}: TodoItemProps) {
console.log(`TodoItem ${todo.id} 渲染了`); // 只在 todo 变化时触发
return (
<li>
<span onClick={() => onToggle(todo.id)}>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>删除</button>
</li>
);
});
2. useMemo / useCallback --- 稳定引用
typescript
// ❌ 每次渲染都创建新的函数引用,导致 memo 失效
export function TodoPage() {
const { todos } = useTodos();
const handleToggle = (id: string) => {
// 这个函数每次渲染都是新的引用
console.log('toggle', id);
};
return (
<TodoList todos={todos} onToggle={handleToggle} />
);
}
typescript
// ✅ 使用 useCallback 稳定引用,memo 才能正常工作
import { useCallback, useMemo } from 'react';
export function TodoPage() {
const { todos, toggleTodo, deleteTodo } = useTodos();
// 稳定的函数引用,不会因为重新渲染而变化
const handleToggle = useCallback((id: string) => {
toggleTodo(id);
}, [toggleTodo]);
const handleDelete = useCallback((id: string) => {
deleteTodo(id);
}, [deleteTodo]);
// 稳定的数组引用,只有 todos 变化时才重新计算
const completedCount = useMemo(
() => todos.filter(t => t.completed).length,
[todos]
);
return (
<div>
<p>已完成:{completedCount}</p>
<TodoList todos={todos} onToggle={handleToggle} onDelete={handleDelete} />
</div>
);
}
🪤 真实踩坑:memo 的过度使用
有人把所有组件 都加了 memo,甚至一个只渲染一次的 <Header /> 也加了。结果代码可读性变差,但性能没有任何提升。
教训 :
memo不是银弹。只对「渲染开销大」或「频繁重渲染」的组件使用 。一个简单的<li>标签加memo是浪费时间。
3. 路由懒加载 --- 首屏提速的关键
前面路由章节已经展示了 lazy() + <Suspense> 的用法。来看实际效果:
typescript
// ❌ 同步导入 --- 所有页面代码打包到一个文件,首屏加载 500KB
import Dashboard from '@/features/dashboard/pages/Dashboard';
import TodoPage from '@/features/todo/pages/TodoPage';
import Settings from '@/features/settings/pages/Settings';
// ✅ 懒加载 --- 每个页面独立打包,首屏只加载 100KB
const Dashboard = lazy(() => import('@/features/dashboard/pages/Dashboard'));
const TodoPage = lazy(() => import('@/features/todo/pages/TodoPage'));
const Settings = lazy(() => import('@/features/settings/pages/Settings'));
可以用 rollup-plugin-visualizer 来分析打包结果:
typescript
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
filename: 'stats.html', // 生成可视化的打包分析图
}),
],
});
性能优化速查表
| 优化手段 | 适用场景 | 效果 | 优先级 |
|---|---|---|---|
| 路由懒加载 | 所有项目 | 首屏体积减少 50%+ | ⭐⭐⭐⭐⭐ |
| React.memo | 列表项组件 | 减少无意义重渲染 | ⭐⭐⭐⭐ |
| useMemo | 昂贵的计算 | 避免重复计算 | ⭐⭐⭐⭐ |
| useCallback | 传递给 memo 子组件的回调 | 稳定引用 | ⭐⭐⭐ |
| 虚拟列表 | 长列表(100+ 项) | DOM 节点减少 90% | ⭐⭐⭐⭐ |
💡 十、一张图总结大型 React 项目架构
vbnet
┌─────────────────────────────────────────────────────────┐
│ React 大型项目架构 │
├─────────────────────────────────────────────────────────┤
│ │
│ 📂 目录结构 features/ + shared/ 按功能模块划分 │
│ │
│ 🪝 Hooks 设计 单一职责 → 组合模式 → 分层管理 │
│ │
│ 🧩 组件模式 容器组件 / 展示组件 / 复合组件 │
│ │
│ 🗂️ 状态管理 局部 → 页面 → 服务端 → 全局 │
│ │
│ 🔀 路由组织 懒加载 + 路由守卫 + 集中配置 │
│ │
│ ⚙️ 工程化 别名 + ESLint + 环境变量 + CI/CD │
│ │
│ 🧪 测试策略 单元 → 集成 → E2E,金字塔模型 │
│ │
│ 🚀 性能优化 memo + 懒加载 + 虚拟列表 │
│ │
└─────────────────────────────────────────────────────────┘
🎯 总结:从小项目到大项目的进阶路线
| 阶段 | 你需要掌握的 | 对应学习项目 |
|---|---|---|
| 入门 | useState、Props、事件处理 | Todos 基础版 |
| 进阶 | 自定义 Hooks、TypeScript 类型 | Todos-TS-Hooks(✅ 你在这里) |
| 实战 | 路由、状态管理、API 对接 | 完整 Todo 应用 |
| 架构 | 目录设计、组件模式、工程化 | 大型项目实战 |
| 高级 | 性能优化、微前端、SSR | 企业级项目 |
🔑 关键心得:架构不是一蹴而就的,而是在项目不断膨胀的过程中「演进」出来的。小项目不需要大架构,但你要知道当项目变大时,该往哪个方向走。
每一条架构原则的背后,都是一个真实的踩坑故事。 希望这篇文章能让你少走一些弯路。
🔗 参考资料
- React 官方文档 - Thinking in React
- Bulletproof React - 大型 React 项目最佳实践
- Zustand 官方文档
- TanStack Query 官方文档
- MSW - Mock Service Worker
觉得有用?点个赞 👍 收藏 ⭐ 关注 👆,后续会继续更新 React 进阶系列!
📮 有问题欢迎评论区交流,我会一一回复。