后台管理系统是 React 技术栈最经典的应用场景之一,绝大多数前端工程师都会在工作中遇到这类需求。下面围绕权限路由、表格分页、表单联动三个核心模块,给出可以直接落地的实现方案。
权限路由:根据角色动态控制菜单与页面访问
后台系统通常存在不同角色(超级管理员、编辑、普通用户等),不同角色看到不同的侧边栏菜单,并且无权访问的页面需要拦截。
核心思路
- 定义路由表,为每个路由配置所需的权限标识(如
admin、editor)。 - 用户登录后存储角色信息(通常放在全局状态或本地缓存)。
- 侧边栏菜单根据权限过滤路由表生成可看到的菜单项。
- 路由守卫组件(或高阶组件)在页面渲染前检查权限,无权限则重定向到 403 或首页。
路由表定义(React Router v6 数据路由风格)
// routes.js
export const routes = [
{
path: '/dashboard',
element: <Dashboard />,
meta: { title: '仪表盘', roles: ['admin', 'editor'] },
},
{
path: '/users',
element: <UserList />,
meta: { title: '用户管理', roles: ['admin'] },
},
{
path: '/settings',
element: <Settings />,
meta: { title: '系统设置', roles: ['admin', 'editor'] },
},
{
path: '/403',
element: <Forbidden />,
meta: { hidden: true }, // 不在菜单中显示
},
{
path: '*',
element: <Navigate to="/dashboard" replace />,
},
];
权限守卫组件
// AuthGuard.jsx
import { useAuth } from '@/hooks/useAuth';
import { Navigate, useLocation } from 'react-router-dom';
function AuthGuard({ children, requiredRoles }) {
const { user } = useAuth(); // 从全局获取当前用户信息(含角色)
const hasPermission = requiredRoles
? requiredRoles.some(role => user.roles.includes(role))
: true;
if (!hasPermission) {
return <Navigate to="/403" replace />;
}
return children;
}
将需要权限保护的页面用 AuthGuard 包裹:
<Route
path="/users"
element={
<AuthGuard requiredRoles={['admin']}>
<UserList />
</AuthGuard>
}
/>
动态菜单生成
// Sidebar.jsx
import { useAuth } from '@/hooks/useAuth';
import { routes } from '@/routes';
import { NavLink } from 'react-router-dom';
function Sidebar() {
const { user } = useAuth();
const visibleRoutes = routes.filter(
route => !route.meta?.hidden && route.meta?.roles?.some(r => user.roles.includes(r))
);
return (
<nav>
{visibleRoutes.map(route => (
<NavLink to={route.path} key={route.path}>
{route.meta.title}
</NavLink>
))}
</nav>
);
}
这套方案实现了完整的权限路由闭环:既控制了菜单显示,也防止了直接输入 URL 越权访问。
表格分页:前后端配合的数据展示器
后台管理系统中表格是数据展示的主力,通常需要配合分页、排序、筛选功能。下面给出一个结合 React Hooks 和通用表格组件(如 Ant Design 或自定义)的分页逻辑封装。
封装一个请求 Hook
// useTableData.js
import { useState, useEffect } from 'react';
function useTableData(apiFn, defaultParams = {}) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [filters, setFilters] = useState({});
const fetchData = async () => {
setLoading(true);
try {
const { data: res } = await apiFn({
page: pagination.current,
pageSize: pagination.pageSize,
...filters,
});
setData(res.list);
setPagination(prev => ({ ...prev, total: res.total }));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [pagination.current, pagination.pageSize, filters]);
const handleTableChange = (pagination, filtersFromUI) => {
if (pagination) {
setPagination(prev => ({
...prev,
current: pagination.current || prev.current,
pageSize: pagination.pageSize || prev.pageSize,
}));
}
setFilters(filtersFromUI || {});
};
return { data, loading, pagination, fetchData, handleTableChange };
}
在组件中使用
function UserList() {
const { data, loading, pagination, handleTableChange } = useTableData(fetchUsersAPI);
const columns = [
{ title: '用户名', dataIndex: 'username' },
{ title: '邮箱', dataIndex: 'email' },
// ...
];
return (
<Table
dataSource={data}
columns={columns}
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
}}
onChange={handleTableChange}
/>
);
}
核心要点
- 后端必须返回总数,分页才有意义。
- 翻页、筛选等变化都触发同一个
fetchData,避免状态分散导致多请求。 - 取消竞态:如果数据请求较慢,用户快速切分页时可能产生覆盖问题。在
useEffect的清理函数中使用AbortController或在fetchData内用标志位忽略过期响应。
表单联动:省市区选择、条件动态校验等
后台表单常出现字段间的联动逻辑,例如“选择省份后加载城市列表”、“选择某个选项后显示额外的输入框”。以下是几个典型场景的实现。
场景一:级联选择(省份→城市)
function RegionSelector() {
const [provinces, setProvinces] = useState([]);
const [cities, setCities] = useState([]);
const [selectedProvince, setSelectedProvince] = useState(null);
const [selectedCity, setSelectedCity] = useState(null);
useEffect(() => {
fetchProvinces().then(setProvinces);
}, []);
// 监听省份变化,加载对应城市
useEffect(() => {
if (selectedProvince) {
fetchCities(selectedProvince.code).then(setCities);
} else {
setCities([]);
setSelectedCity(null);
}
}, [selectedProvince]);
return (
<>
<Select
options={provinces}
value={selectedProvince}
onChange={val => setSelectedProvince(val)}
/>
<Select
options={cities}
value={selectedCity}
onChange={val => setSelectedCity(val)}
disabled={!selectedProvince}
/>
</>
);
}
场景二:条件显示字段
“是否开发票”选择“是”时,才显示发票抬头输入框。
function OrderForm() {
const [needInvoice, setNeedInvoice] = useState(false);
return (
<Form>
<Form.Item label="是否开发票">
<Switch checked={needInvoice} onChange={setNeedInvoice} />
</Form.Item>
{needInvoice && (
<Form.Item label="发票抬头" name="invoiceTitle">
<Input />
</Form.Item>
)}
</Form>
);
}
场景三:动态校验规则
发票抬头在选择开发票时为必填,否则非必填。使用 React Hook Form 的 watch 动态设定校验:
import { useForm, Controller } from 'react-hook-form';
function OrderForm() {
const { control, watch } = useForm();
const needInvoice = watch('needInvoice');
return (
<form>
<Controller
name="needInvoice"
control={control}
render={({ field }) => <Switch {...field} />}
/>
<Controller
name="invoiceTitle"
control={control}
rules={{ required: needInvoice ? '请填写发票抬头' : false }}
render={({ field, fieldState }) => (
<Input {...field} placeholder={needInvoice ? '必填' : '可选'} />
)}
/>
</form>
);
}
综合实战建议
- 权限路由:建议结合 React Router v6 的
loader+redirect在路由层做权限验证,避免组件闪烁。同时菜单和路由配置可以放在后端,实现动态下发。 - 表格分页:尽量将分页参数与 URL query 同步(
useSearchParams),这样用户刷新页面后仍能停留在当前分页状态,体验更好。 - 表单联动:对于复杂的业务表单,优先使用 React Hook Form 等高性能表单库,其
watch、setValue等 API 能优雅处理联动逻辑。
这三个模块看似独立,实际上一套后台系统通常需要将它们组合使用:权限控制菜单和页面,表格分页展示数据,表单联动处理新增/编辑的交互。掌握了这些基础模式,就能应对八成以上的后台开发需求。