第 2 章:架构设计与状态管理

本章学习目标

  • 理解后台系统的分层架构设计
  • 从零实现一个轻量级状态管理方案
  • 掌握 axios 的拦截器封装与统一错误处理
  • 学会设计路由体系(嵌套路由、权限路由、动态菜单)

2.1 后台系统架构分层

在写业务代码之前,先把架构想清楚。一个好的分层能让项目可维护、可测试、可扩展。

2.1.1 六层架构

bash 复制代码
┌──────────────────────────────────┐
│         页面层 (Pages)           │  页面容器,组装组件
├──────────────────────────────────┤
│        组件层 (Components)       │  可复用 UI 组件
├──────────────────────────────────┤
│        Hook 层 (Hooks)           │  可复用逻辑
├──────────────────────────────────┤
│        状态层 (Store)            │  全局/页面级状态
├──────────────────────────────────┤
│        服务层 (Services)         │  API 调用与数据转换
├──────────────────────────────────┤
│        模型层 (Types)            │  数据类型定义
└──────────────────────────────────┘

2.1.2 各层职责

层级 职责 依赖方向
页面层 组合组件、绑定数据、处理页面级逻辑 依赖组件层、Hook 层、Store
组件层 纯 UI 渲染,通过 props 接收数据 只依赖工具函数和 antd
Hook 层 抽离通用逻辑(分页、防抖、表格...) 依赖服务层和 Store
状态层 管理跨组件共享状态 依赖服务层
服务层 封装 API 调用,统一请求/响应处理 依赖类型层
类型层 定义所有数据结构和接口类型 零依赖

核心原则:依赖只能从上往下,不能反向依赖。类型层最稳定,页面层最易变。


2.2 轻量级状态管理:不用 Redux

很多项目一上来就上 Redux,但后台系统 80% 的场景其实不需要这么重的方案。我们用 useSyncExternalStore + 发布订阅模式,自己实现一个轻量 Store。

2.2.1 为什么不用 Redux?

  • 学习成本高:reducer、action、middleware、saga...概念太多
  • 模板代码多:改一个状态要改 3 个文件
  • 后台场景简单:大部分状态是页面级的,不需要全局管理

2.2.2 自己实现 Store 工厂函数

创建 src/store/createStore.ts:

typescript 复制代码
import { useSyncExternalStore } from 'react';

type Listener = () => void;

export function createStore<TState>(initialState: TState) {
  let state = initialState;
  const listeners = new Set<Listener>();

  const getState = () => state;

  const setState = (partial: Partial<TState> | ((prev: TState) => Partial<TState>)) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    state = { ...state, ...nextState };
    listeners.forEach((listener) => listener());
  };

  const subscribe = (listener: Listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const useStore = <TSelected>(selector: (state: TState) => TSelected): TSelected => {
    return useSyncExternalStore(
      subscribe,
      () => selector(state),
      () => selector(initialState),
    );
  };

  return { getState, setState, subscribe, useStore };
}

原理说明:

  • useSyncExternalStore 是 React 18 提供的 Hook,专门用于订阅外部状态
  • 每次 setState 触发所有订阅者更新
  • selector 让组件只订阅自己关心的那部分状态,避免不必要的重渲染

2.2.3 使用示例:用户全局 Store

创建 src/store/useUserStore.ts:

typescript 复制代码
import { createStore } from './createStore';
import { UserInfo } from '@types/user';
import { authApi } from '@services/auth';

interface UserState {
  userInfo: UserInfo | null;
  token: string;
  permissions: string[];
  isLoading: boolean;
}

const userStore = createStore<UserState>({
  userInfo: null,
  token: localStorage.getItem('token') || '',
  permissions: [],
  isLoading: false,
});

export const useUserStore = userStore.useStore;
export const setUserState = userStore.setState;
export const getUserState = userStore.getState;

export const login = async (username: string, password: string) => {
  setUserState({ isLoading: true });
  try {
    const { token, userInfo, permissions } = await authApi.login({ username, password });
    localStorage.setItem('token', token);
    setUserState({ token, userInfo, permissions, isLoading: false });
    return true;
  } catch {
    setUserState({ isLoading: false });
    return false;
  }
};

export const logout = () => {
  localStorage.removeItem('token');
  setUserState({ userInfo: null, token: '', permissions: [] });
};

在组件中使用:

tsx 复制代码
import { Button } from 'antd';
import { useUserStore, logout } from '@store/useUserStore';

function UserProfile() {
  const userInfo = useUserStore((s) => s.userInfo);

  return (
    <div>
      <span>{userInfo?.name}</span>
      <Button onClick={logout}>退出登录</Button>
    </div>
  );
}

2.2.4 页面级 Store 的使用

不是所有状态都要放全局。比如任务单列表的筛选条件、分页状态,离开页面就没用了。

typescript 复制代码
// src/pages/ticket/store/useTicketListStore.ts
import { createStore } from '@store/createStore';
import { Ticket, TicketFilters } from '@types/ticket';

interface TicketListState {
  list: Ticket[];
  total: number;
  page: number;
  pageSize: number;
  filters: TicketFilters;
  loading: boolean;
}

const ticketListStore = createStore<TicketListState>({
  list: [],
  total: 0,
  page: 1,
  pageSize: 20,
  filters: {},
  loading: false,
});

export const useTicketListStore = ticketListStore.useStore;
export const setTicketListState = ticketListStore.setState;

什么时候用全局 Store?

  • 用户信息、权限、token(登录后到处用)
  • 全局主题、语言设置
  • 通知/消息中心数据

什么时候用页面级 Store?

  • 列表筛选条件、分页
  • 详情页的数据
  • 只有当前页面用得到的状态

2.3 API 层封装

好的 API 层封装能让业务代码更干净,也方便统一处理错误、loading、token 等。

2.3.1 axios 实例与拦截器

创建 src/services/request.ts:

typescript 复制代码
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { message } from 'antd';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: { 'Content-Type': 'application/json' },
});

// 取消请求的 Map
const pendingRequests = new Map<string, AbortController>();

const generateRequestKey = (config: InternalAxiosRequestConfig) => {
  const { method, url, params, data } = config;
  return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&');
};

// 请求拦截器
request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    // 防重复请求:同一个请求正在进行的话,取消上一个
    const key = generateRequestKey(config);
    if (pendingRequests.has(key)) {
      pendingRequests.get(key)?.abort();
    }
    const controller = new AbortController();
    config.signal = controller.signal;
    pendingRequests.set(key, controller);

    return config;
  },
  (error) => Promise.reject(error),
);

// 响应拦截器
request.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const key = generateRequestKey(response.config);
    pendingRequests.delete(key);

    const { code, data, msg } = response.data;

    if (code === 0) {
      return data as any;
    }

    // 业务错误处理
    if (code === 401) {
      message.error('登录已过期,请重新登录');
      localStorage.removeItem('token');
      window.location.href = '/login';
      return Promise.reject(new Error(msg || '未授权'));
    }

    message.error(msg || '请求失败');
    return Promise.reject(new Error(msg || '请求失败'));
  },
  (error: AxiosError) => {
    if (error.code === 'ERR_CANCELED') {
      return Promise.reject(new Error('请求已取消'));
    }

    if (error.response?.status === 401) {
      message.error('登录已过期,请重新登录');
      localStorage.removeItem('token');
      window.location.href = '/login';
    } else if (error.response?.status === 500) {
      message.error('服务器错误,请稍后重试');
    } else if (error.code === 'ECONNABORTED') {
      message.error('请求超时,请检查网络');
    } else {
      message.error(error.message || '网络错误');
    }

    return Promise.reject(error);
  },
);

// 通用响应类型
export interface ApiResponse<T = any> {
  code: number;
  data: T;
  msg: string;
}

// 分页响应类型
export interface PageResponse<T> {
  list: T[];
  total: number;
  page: number;
  pageSize: number;
}

export default request;

2.3.2 业务模块接口封装

按业务模块组织 API 文件,例如 src/services/ticket.ts:

typescript 复制代码
import request, { PageResponse } from './request';
import { Ticket, TicketFilters, TicketCreateData } from '@types/ticket';

export const ticketApi = {
  getList: (params: TicketFilters & { page: number; pageSize: number }) =>
    request.get<PageResponse<Ticket>>('/api/v1/tickets', { params }),

  getDetail: (id: string) =>
    request.get<Ticket>(`/api/v1/tickets/${id}`),

  create: (data: TicketCreateData) =>
    request.post<Ticket>('/api/v1/tickets', data),

  update: (id: string, data: Partial<TicketCreateData>) =>
    request.put<Ticket>(`/api/v1/tickets/${id}`, data),

  delete: (id: string) =>
    request.delete(`/api/v1/tickets/${id}`),

  batchUpdate: (ids: string[], data: Record<string, any>) =>
    request.post('/api/v1/tickets/batch', { ids, ...data }),
};

2.4 路由设计

后台系统的路由有三个特点:嵌套布局、权限控制、动态菜单。

2.4.1 路由表配置

创建 src/router/routes.ts:

typescript 复制代码
import { lazy } from 'react';
import { Navigate } from 'react-router-dom';
import type { RouteObject } from 'react-router-dom';
import BasicLayout from '@layouts/BasicLayout';

const Workbench = lazy(() => import('@pages/workbench'));
const TicketList = lazy(() => import('@pages/ticket/list'));
const TicketDetail = lazy(() => import('@pages/ticket/detail'));
const TicketCreate = lazy(() => import('@pages/ticket/create'));
const ScheduledTask = lazy(() => import('@pages/scheduled-task'));
const Knowledge = lazy(() => import('@pages/knowledge'));
const Statistics = lazy(() => import('@pages/statistics'));
const AIAssistant = lazy(() => import('@pages/ai-assistant'));
const Approval = lazy(() => import('@pages/approval'));
const Login = lazy(() => import('@pages/login'));
const NotFound = lazy(() => import('@pages/404'));

export interface RouteMeta {
  title?: string;
  icon?: string;
  requiresAuth?: boolean;
  permission?: string;
  hidden?: boolean;
}

export interface AppRouteObject extends Omit<RouteObject, 'children'> {
  meta?: RouteMeta;
  children?: AppRouteObject[];
}

export const routes: AppRouteObject[] = [
  {
    path: '/login',
    element: <Login />,
    meta: { hidden: true, requiresAuth: false },
  },
  {
    path: '/',
    element: <BasicLayout />,
    meta: { requiresAuth: true },
    children: [
      { index: true, element: <Navigate to="/workbench" replace /> },
      {
        path: 'workbench',
        element: <Workbench />,
        meta: { title: '工作台', icon: 'DashboardOutlined' },
      },
      {
        path: 'ticket',
        meta: { title: '任务单管理', icon: 'FileTextOutlined' },
        children: [
          { path: '', element: <TicketList />, meta: { title: '任务单列表' } },
          { path: 'create', element: <TicketCreate />, meta: { title: '新建任务单', hidden: true } },
          { path: ':id', element: <TicketDetail />, meta: { title: '任务单详情', hidden: true } },
        ],
      },
      {
        path: 'scheduled-task',
        element: <ScheduledTask />,
        meta: { title: '定时任务', icon: 'ClockCircleOutlined' },
      },
      {
        path: 'approval',
        element: <Approval />,
        meta: { title: '审批中心', icon: 'CheckCircleOutlined' },
      },
      {
        path: 'knowledge',
        element: <Knowledge />,
        meta: { title: '知识中心', icon: 'BookOutlined' },
      },
      {
        path: 'statistics',
        element: <Statistics />,
        meta: { title: '数据统计', icon: 'BarChartOutlined' },
      },
      {
        path: 'ai-assistant',
        element: <AIAssistant />,
        meta: { title: 'AI 助手', icon: 'RobotOutlined' },
      },
      { path: '*', element: <NotFound />, meta: { hidden: true } },
    ],
  },
];

2.4.2 权限路由守卫

创建 src/router/PrivateRoute.tsx:

tsx 复制代码
import { Navigate, useLocation } from 'react-router-dom';
import { useUserStore } from '@store/useUserStore';
import type { AppRouteObject } from './routes';

interface Props {
  route: AppRouteObject;
  children: React.ReactNode;
}

export default function PrivateRoute({ route, children }: Props) {
  const token = useUserStore((s) => s.token);
  const permissions = useUserStore((s) => s.permissions);
  const location = useLocation();

  // 需要登录但未登录
  if (route.meta?.requiresAuth !== false && !token) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  // 需要特定权限
  if (route.meta?.permission && !permissions.includes(route.meta.permission)) {
    return <Navigate to="/403" replace />;
  }

  return <>{children}</>;
}

2.4.3 动态菜单生成

侧边栏菜单根据路由表自动生成,不用维护两份配置:

tsx 复制代码
// src/layouts/BasicLayout/Sidebar.tsx
import { Menu } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { routes } from '@/router/routes';
import type { AppRouteObject } from '@/router/routes';
import * as Icons from '@ant-design/icons';
import React from 'react';

const getIcon = (iconName?: string) => {
  if (!iconName) return null;
  const IconComponent = (Icons as Record<string, React.ComponentType<any>>)[iconName];
  return IconComponent ? React.createElement(IconComponent) : null;
};

interface MenuItem {
  key: string;
  label: string;
  icon?: React.ReactNode;
  children?: MenuItem[];
}

const buildMenuItems = (routeList: AppRouteObject[], parentPath = ''): MenuItem[] => {
  return routeList
    .filter((r) => !r.meta?.hidden && r.path)
    .map((route) => {
      const fullPath = parentPath ? `${parentPath}/${route.path}` : route.path || '';
      const item: MenuItem = {
        key: fullPath,
        label: route.meta?.title || route.path || '',
        icon: getIcon(route.meta?.icon),
      };
      if (route.children?.length) {
        const children = buildMenuItems(route.children, fullPath);
        if (children.length > 0) {
          item.children = children;
        }
      }
      return item;
    })
    .filter((item) => item.children || item.label);
};

export default function Sidebar() {
  const navigate = useNavigate();
  const location = useLocation();
  const menuItems = buildMenuItems(routes[1].children || []);

  return (
    <Menu
      mode="inline"
      theme="dark"
      selectedKeys={[location.pathname]}
      items={menuItems}
      onClick={({ key }) => navigate(key)}
      style={{ height: '100%', borderRight: 0 }}
    />
  );
}

2.4.4 路由入口

创建 src/router/index.tsx:

tsx 复制代码
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Suspense } from 'react';
import { Spin } from 'antd';
import { routes, AppRouteObject } from './routes';
import PrivateRoute from './PrivateRoute';

const renderRoutes = (routeList: AppRouteObject[]): any[] => {
  return routeList.map((route) => {
    const element = route.element ? (
      <PrivateRoute route={route}>
        <Suspense
          fallback={
            <div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
              <Spin size="large" />
            </div>
          }
        >
          {route.element}
        </Suspense>
      </PrivateRoute>
    ) : undefined;

    return {
      path: route.path,
      index: route.index,
      element,
      children: route.children ? renderRoutes(route.children) : undefined,
    };
  });
};

const router = createBrowserRouter(renderRoutes(routes));

export default router;

2.5 通用 Hooks 封装

在进入业务开发之前,先准备一些高频使用的自定义 Hook。

2.5.1 useTable ------ 表格通用逻辑

typescript 复制代码
// src/hooks/useTable.ts
import { useState, useCallback, useEffect } from 'react';
import { PageResponse } from '@services/request';

interface UseTableOptions<TData, TParams> {
  fetchFn: (params: TParams & { page: number; pageSize: number }) => Promise<PageResponse<TData>>;
  defaultParams?: TParams;
  defaultPageSize?: number;
  immediate?: boolean;
}

export function useTable<TData, TParams extends Record<string, any> = Record<string, any>>(
  options: UseTableOptions<TData, TParams>,
) {
  const { fetchFn, defaultParams = {} as TParams, defaultPageSize = 20, immediate = true } = options;

  const [dataSource, setDataSource] = useState<TData[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(defaultPageSize);
  const [searchParams, setSearchParams] = useState<TParams>(defaultParams);

  const fetchData = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetchFn({ ...searchParams, page, pageSize });
      setDataSource(res.list);
      setTotal(res.total);
    } finally {
      setLoading(false);
    }
  }, [fetchFn, searchParams, page, pageSize]);

  useEffect(() => {
    if (immediate) {
      fetchData();
    }
  }, [fetchData, immediate]);

  const handleSearch = (params: TParams) => {
    setSearchParams(params);
    setPage(1);
  };

  const handleReset = () => {
    setSearchParams(defaultParams);
    setPage(1);
  };

  const handlePageChange = (newPage: number, newPageSize: number) => {
    setPage(newPage);
    setPageSize(newPageSize);
  };

  const refresh = () => {
    fetchData();
  };

  return {
    dataSource,
    total,
    loading,
    page,
    pageSize,
    searchParams,
    handleSearch,
    handleReset,
    handlePageChange,
    refresh,
  };
}

2.5.2 useDebounce ------ 防抖

typescript 复制代码
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay = 300): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

本章小结

知识点 关键内容
架构分层 页面层 / 组件层 / Hook 层 / 状态层 / 服务层 / 类型层,自上而下依赖
状态管理 基于 useSyncExternalStore 实现轻量 Store,分全局和页面级
API 封装 axios 拦截器统一处理 token、错误、防重复请求、响应解包
路由体系 嵌套布局、路由懒加载、权限守卫、动态菜单生成
通用 Hook useTable 封装表格 CRUD 逻辑,useDebounce 防抖

下一章预告:我们开始做第一个完整的业务模块------工作台数据看板,学习统计卡片、ECharts 图表、双栏布局等常见模式。

相关推荐
广白1 小时前
Git Tag 实战:从出包追溯到版本发布
前端·git·面试
计算机魔术师1 小时前
亚马逊封杀 Meta Muse AI 代购,一场关于「谁控场」的架构博弈
前端
夏天要喝冰可乐1 小时前
Antigravity + Blender MCP(上):打造3D 智慧仓储数字孪生
前端·webgl·three.js
糯糯酱1 小时前
Vue打包优化
前端
牧艺1 小时前
cos-design 4.0:91 个特效组件一次捅成 React / Vue / Web Components / Core
前端·vue.js·web components
沙蒿同学1 小时前
Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用
前端·javascript·后端
某亿1 小时前
为什么我放弃了 esbuild,改用 typescript 包做运行时编译
前端·electron
Blanche15001 小时前
优化 RAG 应用提升问答准确度
前端
颜进强1 小时前
09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙
前端·后端·ai编程