本章学习目标
- 理解后台系统的分层架构设计
- 从零实现一个轻量级状态管理方案
- 掌握 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 图表、双栏布局等常见模式。