最近在工作中碰到了一些管理系统的开发需求,其中就包括了整个管理系统从0到1的项目搭建工作。显然,无论你的管理系统面向的是什么类型的客户,或者需要实现什么类型的功能,你都应该在初始项目搭建时,清晰的知道,权限管理功能是最为基础也是最为重要的功能之一。
而今天,就让我们来谈谈,在权限管理中扮演着极为重要角色的一员,权限路由管理。
前言
RBAC
在开始具体的代码实现之前,我们先来好好的回顾一下到底什么是权限管理,以及如何进行权限管理。
通俗的来讲,当不同的用户登录系统的时候,即使是同一个系统,由于身份的不同,他们所能看到的页面
和所能进行的操作
都是不尽相同的,而这些区别则是所谓的权限
,当然,权限本身更多时候是管理者所需要操心的事情。但是,怎么对这些用户以及这些用户的权限进行管理,则是开发者需要操心的事情。
在如今绝大部分的管理系统中,都采用了著名的RBAC权限控制模型来进行权限功能的管理,即:基于角色的权限控制。通过角色关联用户,角色关联权限的方式间接赋予用户权限。
很容易可以想到,在用户和权限中间添加了一层角色,大幅度增了权限管理的安全性和效率性。
实现思路
回到正题,权限路由实则就是让服务端来告诉前端,当前这个用户拥有哪些页面的访问权限。换句话来说,当用户访问我们的系统时,他的角色就决定了他能看到哪些页面。
大概的流程为:
从上述的流程图可以得知,前端在路由管理这块貌似跟RBAC没有任何关系。事实上的确如此,因为RBAC的角色权限模型通常维护在服务端,在用户通过登录态调用接口的时候,服务端就已经根据其用户信息,来得到其对应的角色,最后再告诉前端他的具体权限了,前端自然而然就不用操心这些事情了。
代码实现
让我们进入到具体的代码实现,以React技术栈为例子(Vue基本也大同小异,甚至利用VueRouter的addRoutes会更为简单),前端的路由库基本都是通过React-Router来进行管理。
路由定义
正常在开发的时候,我们会在routes.tsx
中去定义我们全部的页面路由以及访问路径:
routes.tsx
import {
createBrowserRouter,
} from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: (
<div>
<h1>Hello World</h1>
</div>
),
},
{
path: "about",
element: <div>About</div>,
},
]);
export default router;
然后在页面的根组件中进行引入:
index.ts
import * as React from "react";
import { createRoot } from "react-dom/client";
import {
RouterProvider,
} from "react-router-dom";
import router from './routes'
createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />
);
但是,在动态路由的场景下,我们在开发时并不清楚哪些页面会展现,也不清楚页面与页面之间的具体层级结构。换句话来说,所有的页面结构全部由服务端下发。
假设,服务端返回的数据结构为:
vbnet
const mockRes = [
{
// 页面的唯一Key
key: 'Home',
// 页面url
url: '/home',
chidrens: [
key: 'User',
url: '/user'
]
}
]
此时,我们期待用户只能看到/home
和/home/user
两个页面所对应的组件。
在我们重新组织路由相关的代码结构之前,我们先用一个Store来管理需要储存的共享状态(这里使用的是Zustand进行状态管理 juejin.cn/post/727416..., 当然Redux或者useReducer也都可以 ):
store/index.ts
/**
* 通用状态
* @ loginStatus 是否已登录
* @ permissionRoute 用户路由权限信息
* @ action 状态更新操作
*/
interface CommonStoreProps {
loginStatus: boolean;
permissionRoute: RouteObject[];
action: {
updatePermissionRoute: (routes: RouteObject[]) => void;
updateLoginStatus: (status: boolean) => void;
}
}
export useRoutesStore = create<RoutesStoreProps>()((set) => ({
loginStatus: false,
permissionRoute: [],
action: {
updateLoginStatus: (status) => set(state => {
return { loginStatus: status }
}),
updatePermissionRoute: (routes) => set(state => ({ permissionRoute: routes}))
}
}))
export const getLoginStatus = () => useCommonStore(state => state.loginStatus);
export const getPermissionRoute = () => useCommonStore(state => state.permissionRoute);
export const getCommonAction = () => useCommonStore(state => state.action)
然后,我们新增路由的映射文件map.tsx
,在里面根据服务端返回的key
定义好每个页面的映射关系,同时维护好一些不需要权限的公共页面(例如登录页等),以及兜底的404页面。
map.tsx
import React, { lazy } from "react";
/**
* 动态路由
*/
export const asyncRoutes = {
Home: lazy(() => import("@/pages/Home")),
User: lazy(() => import("@/pages/User")),
};
/**
* 公开路由
*/
export commonRoutes = {
{
path: "/login",
Component: lazy(() => import("@/pages/login")),
},
}
/**
* 404页面
*/
export const notFoundRoutes = {
path: "*",
Component: lazy(() => import("@/pages/notFound")),
};
最后,我们将routes.tsx
改为一个hooks,实现一个工厂函数,在页面初始化的时候默认返回公开路由,当服务端返回权限列表之后,动态转换成React-Router
所需要的路由结构,最终呈现页面。
routes.tsx
import { useState, useEffect } from "react";
import { getAction, getPermissionRoute, getLoginStatus } from '@/store'
import { commonRoutes, notFoundRoutes } from './map'
import { formatPermissionRoutes } from './utils';
const useRouter = () => {
// 接口的loading状态
const [loading, setLoading] = useState<boolean>(false)
// 更新store权限路由的方法
const { updatePermissionRoute } = getCommonAction()
// 订阅当前权限路由,权限路由更新时,重新生成新的全局路由
const permissionRoute = getCommonPermissionRoute()
const loginStatus = getCommonLoginStatus()
const init = async () => {
setLoading(true);
const permissionRoutes = await fetchRoutes();
// 利用递归的工厂函数(后续会说),将服务端的路由结构转为`React-Router`的结构
const finallRoutes = formatPermissionRoutes(permissionRoutes);
// 更新store里的路由信息,方便其他组件使。,注意,需要接口返回才能把404的兜底路由更新进去,否则一开始就会显示404页面
updatePermissionRoute([
...routes,
notFoundRoutes
]);
}
/**
* 根据默认路由和动态路由,生成路由组件
*/
const Router = () => useRoutes([...permissionRoute, ...commonRoutes]);
/**
* 当用户登录态发生改变时,重新调用接口
*/
useEffect(() => {
if(loginStatus) {
initRoutes()
} else {
// 判断是否处在公开路由下,否则跳转到登录页
if(!withCommonRoute()) {
jumoToLogin()
}
}
}, [loginStatus])
}
页面呈现
在大部分的管理系统开发时,主要的页面结构都会以侧边栏和右侧内容区域为基础样式,这里我利用antd的Layout
组件实现了基本的主体框架,利用React-Router的Outlet
组件,我们的路由就会呈现在Content区域。
ts
import React, { FC } from "react";
import { Outlet } from "react-router-dom";
import { Layout } from "antd";
const { Content } = Layout;
const BasicLayout: FC = () => {
return (
<Layout>
{/* 侧边栏 */}
<Sider/>
{/* 主要内容 */}
<Content>
<Outlet></Outlet>
</Content>
</Layout>
)
}
应该怎么组织我们的工厂函数呢?其实很简单,一个递归就搞定了。
ini
import { asyncRoutes, commonRoutes } from "../routes";
import BasicLayout from "@/layout";
export const formatPermissionRoutes = (routes: ResponseRoutesType[]): RouteObject[] => {
if (!routes.length) {
return [];
}
return [{
element: <BasicLayout />,
path: "/",
children:[
...recursion(routes)
],
}];
};
export const recursion = (
routes: ResponseRoutesType[],
formatRoutesList: RouteObject[] = []
): RouteObject[] => {
if (!routes || !routes.length) {
return [];
}
routes.forEach((item) => {
formatRoutesList.push({
path: item.url,
Component: asyncRoutes[item.key],
children: recursionRoutes(item.chidrens),
});
});
return formatRoutesList;
};
最后,只需要利用我们的Hooks,在接口返回后展现我们的入口组件即可。
App.tsx
import { Suspense } from 'react'
import { BrowserRouter } from 'react-router-dom'
import useRoutes from './routes'
function App(){
const { loading, Router } = useRoutes()
return (
<Suspense>
{loading ? (
loading...
) : (
<BrowserRouter>
<Router />
</BrowserRouter>
)}
</Suspense>
)
}
大功告成!
总结
通过上述代码,我们利用React和React-Router提供的强大属性,在比较简短的代码里就实现了一套完整的权限路由体系。