🏗️ 写完 Todos 之后,大型 React 项目的 7 个架构真相

🏗️ 写完 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 拆分的度在哪?

有次我把一个表单拆成了 useFormDatauseFormValidationuseFormSubmituseFormDirtyuseFormReset 五个 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 + 名词 useTodosuseUser
副作用 use + 动词 useFetchuseSubscribe
事件处理 use + 动词 + Handler useFormSubmituseClickOutside
计算派生 use + 形容词/名词 useFilteredTodosuseWindowSize

🧩 四、组件设计模式:从一坨 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 进阶系列!

📮 有问题欢迎评论区交流,我会一一回复。

相关推荐
胡萝卜术1 小时前
复用与并行:从自定义 Hooks 封装状态逻辑,到 Web Worker 的多线程计算
前端·javascript·面试
circuitsosk1 小时前
长文本与高并发下的Token“瘦身”策略:Prompt压缩与上下文窗口优化
java·前端·python·prompt·上下文窗口·token优化
huabuyu1 小时前
CLS 总是修不好?因为你只盯着分数,从没拆开看过它
前端·javascript
倾颜1 小时前
会 Vue / React,上手 Electron 真没那么难:前端开发者需要补齐的核心知识
前端
kisshyshy1 小时前
从多页面到SPA:React Router 路由进阶完全指南
前端·javascript·react.js
JakeJiang1 小时前
抓到接口还不够:用 AIProxy 改返回、Mock 数据、切测试环境
前端·后端
倾颜1 小时前
从 Web 到桌面:AI Mind Electron Desktop Host 的安全边界设计
前端
Csvn1 小时前
📡 前端错误监控从零搭建:window.onerror 与 unhandledrejection 的完整实践
前端
jarvisuni1 小时前
翻车了!GPT5.6接手Opus4.8的项目之后!
前端·人工智能·ai编程