各位好,今天我们不谈那些虚头巴脑的架构图,也不搞那些看起来很高深其实很尴尬的名词堆砌。今天我们要聊的是那个让无数前端开发者半夜惊醒,也让甲方爸爸拍着桌子喊“我要加功能”的核心技术点——权限控制。
特别是针对咱们这个“房东管理平台”的项目,想象一下:一个拥有“管理员”权限的人,正在前台浏览普通租客的房产信息,这画面是不是有点像开着法拉利去送外卖,虽然能开,但总觉得哪里不对劲?
为了防止这种“权限越狱”现象,我们需要一套严密的防守体系。而这套体系的核心,就是大名鼎鼎的 RBAC(基于角色的访问控制)。今天,我们就来手把手地拆解一下,如何用 React 实现基于 RBAC 的动态菜单生成,以及前后端的双重校验。
第一部分:先别急着写路由,先搞清楚“人设”
在代码开始之前,我们必须得先定义好数据模型。这就像咱们租房一样,得先知道你是租客(普通用户)、管家(经理),还是房东本人(超级管理员)。
1. 数据模型设计(后端给的“剧本”)
假设我们的后端 API 非常配合,它会在用户登录成功后,返回一个“剧本”,告诉前端这个账号有哪些戏份。
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"username": "李管家",
"roles": ["manager"] // 李管家是经理,属于“管家”角色
},
"menus": [ // 这是关键!后端直接告诉前端你要显示哪些菜单
{
"id": "1",
"path": "/dashboard",
"name": "工作台",
"icon": "dashboard",
"component": "Dashboard",
"meta": {
"title": "工作台",
"icon": "dashboard"
}
},
{
"id": "2",
"path": "/properties",
"name": "房源管理",
"icon": "home",
"component": "PropertyList",
"meta": {
"title": "房源管理",
"icon": "home"
},
"children": [ // 这里还有子菜单,说明咱们支持多级菜单
{
"id": "2-1",
"path": "list",
"name": "房源列表",
"component": "PropertyList",
"meta": {
"title": "房源列表",
"icon": "list"
}
},
{
"id": "2-2",
"path": "add",
"name": "新增房源",
"component": "PropertyAdd",
"meta": {
"title": "新增房源",
"icon": "add"
}
}
]
},
{
"id": "3",
"path": "/finance",
"name": "财务管理",
"icon": "money",
"component": "Finance",
"meta": {
"title": "财务管理",
"icon": "money"
}
}
]
}
}
注意到了吗?这个 JSON 结构非常有讲究。它不仅告诉了我们有哪些菜单,还告诉了我们路由的层级关系。这就是我们构建动态路由的基石。
2. 前端的角色定义
在代码层面,我们需要一个简单的方式来定义角色。别搞得太复杂,搞个枚举或者简单的对象就行。
// src/utils/role.ts
export enum RoleEnum {
SUPER_ADMIN = 'super_admin', // 老板
MANAGER = 'manager', // 经理
STAFF = 'staff', // 普通员工
}
// 定义路由元数据,用来存放路由的自定义属性
export interface RouteMeta {
title: string;
icon?: string;
roles?: RoleEnum[]; // 比如这个路由只有超级管理员能看
}
第二部分:后端路由权限校验——“看门狗”
很多初学者只做前端的动态菜单,觉得只要前端不显示按钮,权限就控制住了。这就好比你在卧室门口装了个锁,但没锁卧室门,小偷直接从窗户爬进来了。
所以,后端校验是底线,前端校验是面子。
我们需要在每个路由的组件加载前,或者接口调用前,去检查用户有没有权限。
1. Axios 拦截器(全局卫士)
我们要在 React 项目的入口文件,配置一个 Axios 实例,并挂载 request 和 response 拦截器。
// src/utils/request.ts
import axios from 'axios';
import { message } from 'antd';
import { useNavigate } from 'react-router-dom';
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 1. 携带 Token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
const res = response.data;
// 这里可以根据业务状态码进行判断,比如 401 无权限,403 被禁止访问
if (res.code !== 200) {
if (res.code === 401) {
message.error('登录已过期,请重新登录');
localStorage.removeItem('token');
// 这里的跳转逻辑最好抽离,避免循环依赖
window.location.href = '/login';
} else if (res.code === 403) {
message.error('您没有权限访问该资源');
}
return Promise.reject(new Error(res.message || 'Error'));
}
return res;
},
(error) => {
message.error('网络错误');
return Promise.reject(error);
}
);
export default service;
2. 接口权限校验示例
假设我们要实现“财务报表”功能。如果普通员工(Staff)强行通过浏览器地址栏输入 /finance 访问这个页面,后端得像个铁面无私的保安一样把他拦住。
// 示例接口:获取房源列表
export function getProperties(params) {
return request({
url: '/api/properties',
method: 'get',
params,
});
}
在对应的组件中,我们甚至可以加一层前端二次校验(虽然不绝对,但能提升用户体验)。不过真正的防守在拦截器里。
第三部分:动态路由生成器——“把数据变成路”
这是最核心的技术环节。我们要做的不是硬编码 <Route path="/dashboard" component={Dashboard} />,而是根据后端返回的 menus 数据,动态生成路由配置。
1. 路由类型定义
首先,我们需要一个 RouteObject 类型,匹配 react-router-dom 的 RouteProps,并加上我们的 meta。
// src/types/router.ts
import { RouteObject } from 'react-router-dom';
import { RouteMeta } from './role';
export interface AppRouteObject extends Omit<RouteObject, 'meta' | 'children'> {
path: string;
name: string;
component: React.LazyExoticComponent<React.ComponentType<any>>;
meta?: RouteMeta;
children?: AppRouteObject[];
redirect?: string;
}
2. 递归构建路由
我们的后端菜单数据是一个树形结构,所以我们需要一个递归函数来处理它。
// src/router/generator.ts
import React, { lazy } from 'react';
import { AppRouteObject } from '@/types/router';
import { RoleEnum } from '@/utils/role';
// 模拟动态加载组件,实际项目中替换为 import.meta.glob 或 webpack magic comments
const loadModule = (componentName: string) => {
return lazy(() => import(`@/views/${componentName}.tsx`));
};
// 核心递归函数
export function generateRoutes(menus: any[]): AppRouteObject[] {
return menus
.filter(menu => {
// 1. 权限过滤:如果菜单定义了 roles,用户必须拥有其中一个角色才能看到
// 假设当前用户角色存储在 store 中,这里简化处理,假设 userRole 是全局变量
if (menu.meta?.roles) {
return menu.meta.roles.includes(userRole);
}
return true;
})
.map(menu => {
const route: AppRouteObject = {
path: menu.path,
name: menu.name,
element: loadModule(menu.component), // 懒加载组件,按需加载,性能关键!
meta: menu.meta,
};
// 2. 递归处理子菜单
if (menu.children && menu.children.length > 0) {
route.children = generateRoutes(menu.children);
}
// 3. 如果有 redirect,设置一下
if (menu.redirect) {
route.redirect = menu.redirect;
}
return route;
});
}
这段代码的逻辑解读:
- filter: 像个筛子,先把不符合当前用户角色要求的菜单筛掉。比如
meta.roles是['super_admin'],但当前用户是staff,这个菜单直接消失,连渲染的机会都没有。 - map: 把剩下的菜单项转换成 React Router 需要的
RouteObject。 - lazy: 这一步是性能优化的关键。不要把所有页面打包到一个 JS 文件里,我们要让用户只有点开菜单时才加载对应的页面代码。这就是“动态加载”的精髓。
3. 路由注册(沙箱模式)
动态生成的路由不能直接丢到 App.tsx 里的 <Routes> 里,那样会一直存在,没有动态性。我们需要创建一个“路由容器”组件。
// src/components/TabBarLayout/index.tsx
import { Outlet, useRoutes, Navigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Layout, Menu } from 'antd';
import { generateRoutes } from '@/router/generator';
import { RoleEnum } from '@/utils/role';
const { Sider } = Layout;
const TabBarLayout: React.FC = () => {
const [routes, setRoutes] = useState<any[]>([]);
const [selectedKey, setSelectedKey] = useState('');
// 模拟获取用户信息(实际应从 Redux 或 Context 获取)
const userRole = RoleEnum.MANAGER;
useEffect(() => {
// 假设 menus 是从 API 获取的
const mockMenus = [
// ... 上一节的 JSON 数据
];
// 生成路由配置
const generatedRoutes = generateRoutes(mockMenus);
setRoutes(generatedRoutes);
}, [userRole]);
// 默认重定向到第一个菜单
const defaultRedirect = routes[0]?.path;
return (
<Layout style={{ height: '100vh' }}>
<Sider width={200} style={{ background: '#fff' }}>
{/* 菜单渲染逻辑 */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={routes.map(item => ({
key: item.path,
label: item.meta?.title,
}))}
onClick={({ key }) => setSelectedKey(key)}
/>
</Sider>
<Layout style={{ padding: '24px' }}>
{/* 这里是路由出口,所有动态生成的路由都会在这里渲染 */}
<Outlet />
</Layout>
</Layout>
);
};
第四部分:路由守卫——“最后的防线”
虽然我们在 generateRoutes 里做了权限过滤,但用户体验还得再提升一下。
1. 权限守卫组件
万一用户绕过了前端菜单,直接在浏览器输入 URL 呢?我们需要一个 PrivateRoute 或者 GuardRoute 来拦截。
// src/components/GuardRoute/index.tsx
import { Navigate } from 'react-router-dom';
import { RoleEnum } from '@/utils/role';
interface GuardRouteProps {
children: React.ReactNode;
requiredRoles?: RoleEnum[]; // 这个路由必须具备的角色
}
const GuardRoute: React.FC<GuardRouteProps> = ({ children, requiredRoles }) => {
const currentRole = RoleEnum.MANAGER; // 获取当前用户角色
// 如果没有配置 requiredRoles,或者当前角色在 requiredRoles 中,则放行
if (!requiredRoles || requiredRoles.includes(currentRole)) {
return <>{children}</>;
}
// 否则,重定向到 403 页面或者首页
return <Navigate to="/403" replace />;
};
2. 在路由配置中使用
// src/router/generator.ts (修改版)
export function generateRoutes(menus: any[]): AppRouteObject[] {
return menus
.filter(menu => {
// ... (权限过滤逻辑同上)
})
.map(menu => {
let element = loadModule(menu.component);
// 如果菜单配置了角色,用 GuardRoute 包裹
if (menu.meta?.roles && menu.meta.roles.length > 0) {
element = <GuardRoute requiredRoles={menu.meta.roles}>{element}</GuardRoute>;
}
const route: AppRouteObject = {
path: menu.path,
name: menu.name,
element,
meta: menu.meta,
};
// ... 递归逻辑不变
return route;
});
}
这样,即使通过 URL 访问,如果权限不足,也会被 GuardRoute 拦截并重定向。
第五部分:实战演练——从登录到菜单渲染的完整流
为了让大家彻底明白,我们把整个流程串起来。
第一步:登录
用户输入账号密码。
// 登录 API 调用
const handleLogin = async (values) => {
const res = await loginApi(values);
localStorage.setItem('token', res.data.token);
// 登录成功后,通常需要获取用户信息
fetchMenusAndRoles();
}
第二步:获取菜单数据
const fetchMenusAndRoles = async () => {
// 1. 获取菜单树
const menuRes = await getMenusApi();
// 2. 获取当前用户角色(或者直接从 menuRes 里取)
setUserRole(menuRes.data.user.roles[0]);
// 3. 生成路由
const routes = generateRoutes(menuRes.data.menus);
// 4. 将路由存入路由器
setRoutes(routes);
};
第三步:渲染
App.tsx 变成了这样:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from 'antd';
import { routes, LayoutRoute } from './router/generator';
import Login from '@/views/Login';
const App = () => {
return (
<BrowserRouter>
<Routes>
{/* 公开路由 */}
<Route path="/login" element={<Login />} />
<Route path="/403" element={<div>403 Forbidden</div>} />
{/* 动态路由容器 */}
<Route element={<LayoutRoute />}>
{routes.map(route => (
<Route key={route.path} path={route.path} element={route.element} />
))}
</Route>
{/* 兜底路由,如果访问了未定义的路径,回到首页 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
};
export default App;
第六部分:递归菜单组件——菜单树的艺术
上面的代码里,我们只渲染了一级菜单。如果用户点击“房源管理”,二级菜单(列表、新增)应该出现在侧边栏里。这就需要用到递归组件。
1. 菜单项组件
// src/components/Sidebar/MenuItem.tsx
import { Menu } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom';
import type { MenuProps } from 'antd';
interface MenuItemProps {
item: any; // 对应后端的菜单节点
level?: number; // 当前层级,用于缩进
}
const MenuItem: React.FC<MenuItemProps> = ({ item, level = 0 }) => {
const location = useLocation();
const navigate = useNavigate();
// 递归渲染子菜单
const renderChildren = () => {
if (!item.children || item.children.length === 0) return null;
return (
<Menu.ItemGroup key={`group-${item.id}`} title={item.name}>
{item.children.map(child => (
<MenuItem key={child.id} item={child} level={level + 1} />
))}
</Menu.ItemGroup>
);
};
// 判断当前路径是否匹配(用于高亮)
const isSelected = location.pathname.startsWith(item.path);
const onClick: MenuProps['onClick'] = (e) => {
navigate(e.key);
};
return (
<Menu.Item
key={item.path}
icon={item.meta?.icon} // 假设 icon 组件存在
style={{ paddingLeft: `${level * 24}px` }} // 层级缩进
className={isSelected ? 'selected-menu-item' : ''}
onClick={onClick}
>
{item.meta?.title || item.name}
{renderChildren()}
</Menu.Item>
);
};
2. 侧边栏布局
// src/components/Sidebar/index.tsx
import { Menu } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom';
import { generateRoutes } from '@/router/generator';
import { MenuItem } from './MenuItem';
const Sidebar = () => {
const location = useLocation();
const [routes, setRoutes] = useState<any[]>([]);
useEffect(() => {
// 假设从 store 获取菜单
const menuData = getStoreMenu();
const routes = generateRoutes(menuData);
setRoutes(routes);
}, []);
return (
<div style={{ height: '100vh', background: '#001529' }}>
<div style={{ padding: '16px', color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
房东管家平台
</div>
<Menu
theme="dark"
selectedKeys={[location.pathname]}
mode="inline"
items={routes.map(route => ({
key: route.path,
label: <MenuItem item={route} />,
}))}
/>
</div>
);
};
这里的关键在于 MenuItem 组件内部再次调用了 MenuItem。这就是“递归”的力量——一个组件自己调用自己,完美解决了无限级菜单的问题。
第七部分:代码分割与性能优化——别让用户等太久
当我们使用 lazy(() => import('@/views/xxx')) 时,Webpack 或 Vite 会自动进行代码分割。这意味着,当用户点击“新增房源”菜单时,浏览器才去下载那个页面的 JS 文件。
1. 动态路由的时机
我们绝不应该在应用初始化(App.tsx mount)时就加载所有路由。这会极大地拖慢首屏加载速度。
正确的做法是:在用户登录后,拿到菜单数据,生成路由,然后才去挂载路由。
// App.tsx 修改版
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from 'antd';
import { useState, useEffect } from 'react';
import { generateRoutes } from './router/generator';
import { getMenusAndRoles } from './api/user';
const App = () => {
const [routes, setRoutes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. 登录逻辑,获取 Token 和用户信息
const initApp = async () => {
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
return;
}
// 2. 获取动态菜单
const { menus, roles } = await getMenusAndRoles();
// 3. 生成路由
setRoutes(generateRoutes(menus));
setLoading(false);
};
initApp();
}, []);
if (loading) {
return <div>Loading...</div>;
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{/* 只有当 routes 有值时,才渲染 Layout */}
{routes.length > 0 && (
<Route element={<LayoutRoute />}>
{routes.map(route => (
<Route key={route.path} path={route.path} element={route.element} />
))}
</Route>
)}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
};
第八部分:常见“坑”与避坑指南
作为专家,我得给你们提个醒。实现这个功能,90% 的人都能写出来,但只有 10% 的人能用好。
坑 1:循环依赖
在 lazy 导入组件时,如果组件里又 import 了 request 或者 store,很容易出现循环引用。解决办法是把 request 和 store 放在顶层导出,或者使用 React.lazy 包裹时不要在同一个文件里写复杂的逻辑。
坑 2:子菜单路径拼接错误
后端返回的子菜单路径可能是 /properties/list,也可能是 list。在 React Router v6 中,如果你在父路由写 path="/properties",子路由写 path="list",它会自动拼接成 /properties/list。
但是,如果你在父路由写 path="/properties",子路由写 path="/list",那它就会变成 /list,导致 404。
解决方法: 前端在渲染路由前,统一规范后端返回的路径格式,确保相对路径(不带 /)的一致性。
坑 3:刷新页面丢失路由状态
如果你把路由生成的逻辑写在 useEffect 里,刷新页面后,由于 useEffect 重新执行,routes 数组会被重置,导致页面空白。
解决方法: 将 menus 数据持久化到 localStorage,在 useEffect 里优先读取 localStorage。
坑 4:路由守卫和动态菜单的冲突
如果你在 GuardRoute 里做了路由校验,那 generateRoutes 里的 filter 逻辑是不是多余了?
回答: 不多余。generateRoutes 是为了性能,只渲染用户看得到的东西。GuardRoute 是为了安全,防止有人直接访问 URL。两者配合才是完美。
结语
好了,各位,今天的讲座就到这里。我们今天从RBAC 的数据模型讲到了动态路由的递归生成,又深入到了React 的懒加载与路由守卫。
这套机制就像是给我们的房东管理平台穿上了一层防弹衣。前台(菜单)让你看你想看的,后台(API)只给你能用的,而路由守卫则是那个死守门口的保安,谁也别想硬闯。
记住,动态菜单不是简单的循环渲染,它是数据与视图的桥梁;权限控制不是简单的 if/else,它是业务安全的基石。 希望大家在自己的项目中,能灵活运用这些技巧,打造出让甲方爸爸挑不出毛病、让黑客无从下手的完美系统!
下次见!