【路由&菜单&权限】一体化设计
【路由&菜单&权限】一体化设计
在中后台项目中,路由、导航菜单、路由权限是最基本的问题。我在工作中见到了各种各样的解决方案, 但都不太理想。在我看来,路由、菜单、权限这三者是强耦合关系,应该放在一起解决。
一、设计思路
路由和菜单在结构上本就是统一的,最多在路由配置上加一个 hiddenInMenu
字段,就能直接过滤过菜单配置。
一个路由下面可能有多个权限,比如一个列表页通常会包含:【查看列表、删除条目、编辑条目、查看详情】这些功能权限。然后在权限设计时遵循这两个原则:
-
如果配置了多条权限,只要有一个生效,就能访问该路由
-
如果子路由可访问,则上级路由也可以访问
基于以上分析,可以设计出最终的配置类型:
/** 统一维护路由的唯一键 */
export enum ROUTE_KEYS {
// ...
}
/** 功能权限Key */
export enum PERMISSIONS {
// ...
}
export interface IRoute {
/** 路由配置的唯一键 */
key: ROUTE_KEYS;
/** 页面组件的路径,如 @/pages/login */
component: string;
/** 页面路径 */
path: string;
/** 子路由 */
routes?: IRoute[];
}
export interface MenuItem extends IRoute {
/** 菜单名称 */
name: string;
/** 是否在菜单中隐藏该页面 */
hidden?: boolean;
/** 子菜单 */
routes?: MenuItem[];
/** 路由权限,只要包含其中一个权限就能访问该路由,不配置则不做限制 */
permissions?: PERMISSIONS[];
}
export const SOURCE_MENUS: MenuItem[] = [
{
key: ROUTE_KEYS.FIRST_PAGE,
path: '/first',
name: '首页
routes: [
{
key: ROUTE_KEYS.SECOND_PAGE,
name: '二级页面',
path: '/first/second',
permissions: [
PERMISSIONS.FOO,
PERMISSIONS.BAR,
],
},
],
},
]
二、生成可访问路由
应用加载后,应该第一时间获取用户信息和权限,然后结合全量路由配置 SOURCE_MENUS
和用户权限 permissions
(假设接口返回的用户权限是一个 PERMISSIONS[]
数组)过滤出可访问路由 allowedMenus。为了便于快速查询,还可以创建一个字典项 allowedMenuRecords。
import { cloneDeep, isArray } from 'lodash-es';
export type RouteRecords = Record<ROUTE_KEYS, MenuItem>;
/**
* 递归地过滤单个路由项及其子路由项
* @param item 要过滤的路由项
* @param permisMap 用户权限映射
* @returns 如果有权限,则返回路由项;否则返回 undefined
*/
export function loopFilterMenu(
item: MenuItem,
permisMap: Record<string, true>,
): RouteItem | undefined {
// 检查当前路由项是否有权限
if (!item?.permissions || item.permissions.some((perm) => permisMap[perm])) {
// 如果有权限或没有指定权限,则处理子路由项
if (item.routes) {
item.routes = item.routes.filter((child) =>
loopFilterMenu(child, permisMap),
);
// 如果没有子路由项被允许,则当前路由项也不应被包含在结果中
if (!item.routes.length) return undefined;
}
return item;
}
return undefined;
}
/**
* 根据用户权限过滤出可访问的路由
* @param routes 所有菜单
* @param permissions 用户权限
*/
export function getAllowedMenus(
permissions: PERMISSIONS[],
routes: MenuItem[] = cloneDeep(SOURCE_MENUS),
): RouteItem[] {
if (!isArray(routes)) return [];
if (!isArray(permissions) || !permissions.length) {
return routes;
}
// 将权限转为Map用于快速查询
const permisMap: Record<string, true> = {};
for (let i = permissions.length - 1; i >= 0; i--) {
permisMap[permissions[i]] = true;
}
// 过滤所有路由项
return routes.filter((route) => loopFilterMenu(route, permisMap));
}
/** 将路由数组按key维护为map */
export function buildRouteRecords(arr: MenuItem[], res = {} as RouteRecords) {
if (isArray(!arr)) return;
for (let i = arr.length - 1; i >= 0; i--) {
const item = arr[i];
res[item.key] = item;
if (item.routes?.length) {
buildRouteRecords(item.routes, res);
}
}
return res;
}
// 获取到用户权限userPermissions后生成可访问的路由
const allowedMenus = getAllowedMenus(userPermissions);
// 基于key创建可访问路由map
const allowedMenuRecords = buildRouteRecords(allowedMenus);
三、一级菜单
根据上面生成的 allowedMenus
可以很简单的完成一级路由:
function buildMenus(list?: RouteItem[]) {
return list
?.filter((x) => !x.hidden)
.map((x) => ({
label: x.name,
key: x.key,
path: x.path,
// 如果不需要展示次级菜单,可以移除 children
children: buildMenus(x.routes),
}));
}
const items = buildMenus(allowedMenus);
然后在项目中直接获取当前路由Key来设置当前高亮的菜单
不同的技术栈获取当前路由的方式不太一样,这里以 Umi 4.x 的项目为例:
import { useSelectedRoutes } from 'umi';
import { isArray } from 'lodash-es';
/**
* 获取当前的路由配置,默认只返回末级,可传入level获取指定层级
*/
export function useCurrentRoute(level?: number) {
const selectedRoutes = useSelectedRoutes();
const currentRoute = useMemo=(() => {
const index = level ?? selectedRoutes.length - 1;
const route = isArray(selectedRoutes) ? selectedRoutes[index] : null;
return route?.route || null;
}, [level, selectedRoutes]);
return {
/** 当前路由配置 */
currentRoute,
};
}
四、次级菜单
如果二级、三级菜单和一级菜单没在同一个组件内,可以封装一个高阶组件 CommonSubLayout,获取当前路由信息,然后从 allowedMenuRecords
中查到当前路由的子路由
import React, { useMemo } from 'react';
import { useSelectedRoutes } from 'umi';
/** 获取子路由的最小路由层级 */
export const MIN_ROUTE_LEVEL = 2;
function CommonSubLayout({
children,
/** 路由层级,不小于2 */
level = 3,
}) {
const selectedRoutes = useSelectedRoutes();
// 根据项目的状态管理方案获取 allowedMenuRecords
const allowedMenuRecords = {};
/**
* 根据路由层级返回对应的可访问菜单
* @param lv 层级,不小于2
*/
const getSubRoutes = useCallback(
(lv: number = MIN_ROUTE_LEVEL) => {
let xlevel = Number(lv) || 0;
// 确保level不小于2
xlevel = xlevel <= MIN_ROUTE_LEVEL ? MIN_ROUTE_LEVEL : xlevel;
if (!allowedMenuRecords || selectedRoutes?.length < xlevel) {
return undefined;
}
// 获取一级菜单
const parentMenu = selectedRoutes[xlevel - 1]?.route as ISelectedRoute;
// 获取当前菜单下有可访问的二级路由
return allowedMenuRecords[parentMenu?.key]?.routes;
},
[allowedMenuRecords, selectedRoutes],
);
const lv = useMemo(() => {
// 确保level不小于2
const propLv = Number(level) || 0;
return propLv <= MIN_ROUTE_LEVEL ? MIN_ROUTE_LEVEL : propLv;
}, [level]);
/** 当前路由层级下的子路由 */
const subRoutes = useMemo(() => getSubRoutes(lv), [getSubRoutes, lv]);
const defaultSubRoutePath = subRoutes?.[0]?.path;
/** 基于子路由生成菜单,会过滤掉hidden */
const items = useMemo(() => {
return subRoutes?.length
? subRoutes
.filter((x) => !x.hidden)
.map((x) => {
// ...
})
: undefined;
}, [subRoutes]);
// 当路由指向当前文件,则重定向到子路由
if (selectedRoutes.length < lv + 1) {
return defaultSubRoutePath ? (
// 重定向
<Navigate to={defaultSubRoutePath} />
) : (
// 路由无权限
<ErrorResult errorCode="403" />
);
}
// 为子组件注入 items
return React.isValidElement(children) ? (
React.cloneElement(children, { items })
) : (
<></>
);
}
export default React.memo(CommonSubLayout);
如此一来,我们就基本实现【路由 + 菜单 + 权限 】的一体化配置, 只需要维护一个全量路由配置 SOURCE_MENUS
就能通过上述方案自动生成可访问的菜单。
五、路由拦截
虽然上面的内容可以很好的过滤出可访问的菜单,但还不能处理【直接通过URL访问无权限路由】的场景。所以最后还需要做一个全局路由权限检查。
前文已经实现了 useCurrentRoute
和 allowedMenuRecords
,所以权限校验的逻辑就很简单:
const { currentRoute } = useCurrentRoute();
// 根据项目的状态管理方案获取 allowedMenuRecords
const allowedMenuRecords = {};
/** 是否可访问当前页面 */
const isAllowed: boolean = useMemo(() => {
if (!currentRoute) return false;
// 如果没有配置权限,则不做拦截
if (!currentRoute.permissions?.length) return true;
// 校验当前路由key是否可访问
const key = currentRoute.key;
return Boolean(key && allowedMenuRecords?.[key]);
}, [allowedMenuRecords, currentRoute]);
只需要根据项目情况将这部分逻辑放到合适的地方就行,一般来说可以放到全局路由守卫,或者项目根节点。在每次路由切换的时候都做一次校验即可。