在企业级后台管理系统中,权限控制几乎是一个绕不开的话题。前端页面能不能看、能不能访问,通常不取决于写死的配置,而是取决于后端根据当前用户角色计算出的权限数据。这套数据一般以树形结构下发,前端拿到后要做两件事:一是把树渲染成侧边栏菜单,二是把叶子节点注册成路由。整个过程看似简单,实际做起来有不少细节容易踩坑,比如深层级菜单如何递归、动态路由刷新后丢失怎么办、没有权限的路径如何兜底跳转404等。本文结合具体代码,完整演示一套基于权限树的动态菜单与路由生成方案。

权限树的数据结构设计
在动手写代码之前,先明确后端会返回什么样的数据。权限树本质上是一个递归的节点集合,每个节点通常包含唯一标识、菜单名称、路径、图标、排序值以及子节点列表。下面是一个典型的返回结构:
[
{
id: 1,
name: "系统管理",
path: "/system",
icon: "setting",
sort: 1,
children: [
{
id: 11,
name: "用户管理",
path: "/system/user",
component: "system/user/index",
sort: 1,
children: []
},
{
id: 12,
name: "角色管理",
path: "/system/role",
component: "system/role/index",
sort: 2,
children: []
}
]
}
]</>有几个设计要点值得注意。第一,非叶子节点只作为菜单分组的容器,不对应实际页面;叶子节点则带有component字段,指向前端某个页面组件的标识。第二,前端一般维护一个「组件标识到组件实例」的映射表,而不是让后端直接返回组件路径字符串就拿来require,那样既不安全也不利于打包优化。第三,sort字段用于控制菜单显示顺序,渲染前最好统一按sort做一次排序。
另外一个常见做法是后端只返回叶子节点的权限码列表,菜单结构由前端自己维护。这种方式实现简单,但当权限规则变化时需要发版。本文采用的是后端下发完整权限树的方案,灵活性更高,新增页面只需在权限系统中配置,前端无需改动。
递归渲染多级菜单组件
拿到权限树之后,渲染侧边栏菜单是最直观的一步。核心思路是写一个递归组件:如果节点有children且长度大于0,就渲染成可展开的父级菜单,并递归渲染子节点;否则渲染成菜单项,点击后跳转到对应路由。以常见的antd组件库为例:
import { Menu } from "antd";
import { useNavigate, useLocation } from "react-router-dom";
function renderMenuItems(tree) {
return tree
.slice()
.sort((a, b) => a.sort - b.sort)
.map((node) => {
if (node.children && node.children.length > 0) {
return {
key: node.path,
icon: renderIcon(node.icon),
label: node.name,
children: renderMenuItems(node.children)
};
}
return {
key: node.path,
icon: renderIcon(node.icon),
label: node.name
};
});
}
export default function SideMenu({ menuTree }) {
const navigate = useNavigate();
const location = useLocation();
return (
<Menu
mode="inline"
items={renderMenuItems(menuTree)}
selectedKeys={[location.pathname]}
defaultOpenKeys={["/system"]}
onClick={({ key }) => navigate(key)}
/>
);
}这段代码中,renderMenuItems是一个纯函数,负责把权限树转换成Menu组件需要的items格式。递归的逻辑很清晰,父节点带children键,antd会自动渲染成SubMenu。如果不用组件库,自己写递归也完全可行,只是要记得处理展开收起的状态管理。
有一个容易被忽略的细节是选中态与展开态的同步。用户刷新页面时,浏览器地址停留在深层路径,比如/system/user,此时菜单需要自动展开/system这个父级。可以通过解析location.pathname,取它的一级前缀设置到defaultOpenKeys,或者用受控的openKeys配合onOpenChange来管理,避免非受控状态在刷新后丢失。
动态路由注册与刷新丢失问题
菜单只是入口,真正控制页面可访问性的是路由。React Router从v6开始不再推荐动态添加路由的旧写法,最佳实践是:登录后请求权限树,把树扁平化成路由配置数组,然后用useRoutes或<Routes>统一渲染。扁平化的过程就是把所有带component的叶子节点抽出来:
import { Navigate, useRoutes } from "react-router-dom";
import componentMap from "./componentMap";
function flattenRoutes(tree, result = []) {
tree.forEach((node) => {
if (node.children && node.children.length > 0) {
flattenRoutes(node.children, result);
} else if (node.component) {
result.push({
path: node.path,
element: componentMap[node.component]
});
}
});
return result;
}
export default function App() {
const [menuTree, setMenuTree] = useState(null);
useEffect(() => {
// 登录后请求权限树,实际项目中配合全局状态管理
fetchMenuTree().then(setMenuTree);
}, []);
if (!menuTree) return <div>加载中...</div>;
const routes = [
{ path: "/", element: <Navigate to={menuTree[0].path} /> },
...flattenRoutes(menuTree),
{ path: "*", element: <Navigate to="/404" /> }
];
return useRoutes(routes);
}componentMap是提前在前端维护的映射表,形如{ "system/user/index": UserList }。这样做的好处是打包工具能静态分析出所有可能的页面组件,配合React.lazy可以按需加载,首屏体积不会因为动态路由而失控。
刷新丢失是最常见的坑。因为权限树是异步请求的,刷新瞬间路由表还没生成,直接渲染Routes会匹配到通配路由跳到404。解决办法就是上面代码中的加载态判断:在menuTree为空时先渲染loading,等数据就绪后再调用useRoutes。要注意useRoutes必须无条件调用这类Hook规则的问题,所以判断逻辑要放在Hooks调用之前以组件拆分的形式处理,把routes的计算和渲染拆成一个子组件,父组件负责loading判断,这样既符合Hooks规则又不会出现路由闪跳。
路由守卫与按钮级权限的延伸
动态菜单和路由解决的是页面级权限,但实际项目里往往还需要路由守卫和按钮级权限。路由守卫可以通过一个包装组件实现,在渲染目标页面前检查用户登录态与权限树,不满足条件时重定向:
function RequireAuth({ children }) {
const token = localStorage.getItem("token");
if (!token) {
return <Navigate to="/login" replace />;
}
return children;
}
// 按钮级权限:判断权限码是否在用户权限集合中
function hasPermission(codes, code) {
return codes.includes(code);
}
{hasPermission(userCodes, "user:delete") && <Button danger>删除</Button>}这里需要强调一点:前端权限控制只能提升体验,不能替代后端校验。无论前端做得多严密,用户都可以直接调接口,所以后端必须对每个接口再做一次权限判断,前端权限只是用来隐藏不该看到的入口,属于纵深防御的第一层。
按钮级权限的通用做法是后端额外返回一个权限码数组(比如user:delete、role:edit),前端把它存入全局状态,封装一个hasPermission工具函数或PrivilegeButton组件,在需要控制的地方统一调用。如果项目更大,还可以把权限树请求、菜单状态、权限码集合全部收敛到一个Context或Redux切片中管理,避免多个组件重复请求。
总结一下整套流程:登录成功后请求权限树,全局状态缓存;侧边栏通过递归组件渲染菜单;路由层把树扁平化成配置数组,配合组件映射表和懒加载注册路由;外加RequireAuth守卫处理登录态,通配路由兜底404。这套结构在中小型后台项目中可以直接落地,权限变化时前端零改动,维护成本很低。