前段时间学习了React Router,发现没有Vue里面的路由功能强大,没有直接提供路由中间件,不能像Vue里面一样在路由配置上设置任意的额外属性,但是可以通过一些技巧来实现这些功能。
1、配置菜单
后台管理系统一般都会在左侧显示菜单,右侧显示页面,本例中使用Ant Design组件当然也不例外。虽然umi里面已经集成了很多功能,但是有些地方用起来不够灵活,比如路由配置高阶组件,不能传递prop;每一个权限码都要配置相同的函数,等等。所以,我更喜欢用Vite+React来搭建项目。
废话不多说,菜单配置的代码如下
export type MenuConfig = {
computedMatch?: match<any>;
route?: MenuDataItem
location: {
pathname?: string;
};
}
export type MenuDataItem = {
/** @name 子菜单 */
children?: MenuDataItem[];
routes?: MenuDataItem[];
/** @name 在菜单中隐藏子节点 */
hideChildrenInMenu?: boolean;
/** @name 在菜单中隐藏自己和子节点 */
hideInMenu?: boolean;
/** @name 菜单的icon */
icon?: React.ReactNode;
/** @name 自定义菜单的国际化 key */
locale?: string | false;
/** @name 菜单的名字 */
name?: string;
/** @name 用于标定选中的值,默认是 path */
key?: string;
/** @name disable 菜单选项 */
disabled?: boolean;
/** @name disable menu 的 tooltip 菜单选项 */
disabledTooltip?: boolean;
/** @name 路径,可以设定为网页链接 */
path?: string;
/**
* 当此节点被选中的时候也会选中 parentKeys 的节点
*
* @name 自定义父节点
*/
parentKeys?: string[];
/** @name 隐藏自己,并且将子节点提升到与自己平级 */
flatMenu?: boolean;
/** @name 指定外链打开形式,同a标签 */
target?: string;
/**
* menuItem 的 tooltip 显示的路径
*/
tooltip?: string;
/**
* 组件
*/
component?: Promise<{ default: React.ComponentType<any> }>;
/**
* 权限码
*/
access?: string;
}
const menuConfig: MenuConfig = {
route: {
path: '/',
routes: [
{
key: '1',
name: '首页',
path: '/home',
icon: <HomeFilled />,
component: import('@/pages/home')
},
{
name: '系统管理',
path: '/system',
access: 'system:view',
icon: <SettingFilled />,
routes: [
{
name: '用户管理',
path: '/system/user',
icon: <ContactsFilled />,
access: 'user:view',
component: import('@/pages/system/user')
},
{
name: '角色管理',
path: '/system/role',
icon: <SmileFilled />,
access: 'role:view',
component: import('@/pages/system/role')
},
{
name: '权限管理',
path: '/system/authority',
access: 'ahthority:view',
routes: [
{
name: '菜单按钮管理',
path: '/system/authority/menu',
icon: <GoldenFilled />,
access: 'menuBtn:view',
component: import('@/pages/system/authority/menuBtn')
},
{
name: '接口权限管理',
path: '/system/authority/interface',
icon: <SecurityScanFilled />,
access: 'interface:view',
component: import('@/pages/system/authority/interface')
}
]
}
]
}
],
},
location: {
pathname: '/',
}
}
export default menuConfig
这里使用import()函数,动态导入组件,避免将来路由组件多了以后,在开头写大量的import语句,access表示权限码,用来控制菜单的隐藏和显示,并且可以将权限码传递给路由,设置路由的访问权限,后面会说到。
2、定义用户的全局状态
export type UserInfo = {
userName?: string | null,
avatar?: string | null,
authCodes?: Set<string> | null,
}
const userInfo: UserInfo = {
userName: '',
avatar: '',
authCodes: undefined,
}
const user = {
data: userInfo,
async requestUserInfo() {
const userInfoFromDB = await getUserInfo()
this.data = { ...userInfoFromDB, authCodes: new Set(userInfoFromDB.menuBtnCodes) }
localStorage.setItem(USER_INFO,JSON.stringify(userInfoFromDB))
}
}
export default {
user,
tab,
menu
}
这里将用户的用户名、头像、权限码,保存到了全局变量当中,其中authCodes代表权限码的Set集合,里面包含了菜单和按钮的权限码,方便后面进行校验,requestUserInfo函数用来请求后台接口,获取用户信息,并保存到全局变量和本地缓存当中,然后将user对象导出,tab和menu涉及到其他的功能,这里先不讨论。
3、获取用户信息
为主页所在的路由组件定义一个函数,作为loader,并在loader函数里面获取用户信息
export const loader = async ({request}:LoaderFunctionArgs) => {
const url=new URL(request.url)
if (url.pathname==import.meta.env.VITE_BASE_NAME) {
return redirect('/home')
}
if (currentAction==Action.INIT) {
await store.user.requestUserInfo()
store.menu.filterMenuConfig()
}else{
currentAction=Action.INIT
}
return { userInfo: store.user.data, tabsData: store.tab.data,menuConfig:store.menu.data }
}
其中,store对象保存了当前的全局状态,调用requestUserInfo()函数请求后台获取用户信息,并保存,然后需要将store对象里面的数据返回。代码中的其他逻辑涉及其他功能,这里先不讨论。
然后,使用useLoaderData()函数,在主页的路由组件里面获取到这些信息即可,后面可以进行显示和调用。
pro components组件库有很多高级组件,只要调用ProLayout组件,将loader获取的数据传入对应的prop即可,由于代码量庞大,这里不展开讨论。
const loaderData = useLoaderData() as { userInfo: UserInfo, tabsData: TabsData,menuConfig:MenuConfig}
const {userInfo,tabsData,menuConfig}=loaderData
4、定义权限校验函数
/**
* 检查权限
* @param access 权限码
* @returns true 有权限
* false 没有权限
*/
export function checkAuth(access?:string){
if (access) {
const authCodes=store.user.data.authCodes
if (!authCodes) {
const userStr=localStorage.getItem(USER_INFO)
if (userStr) {
const userInfo:SYSTEM_API.UserInfo =JSON.parse(userStr)
const menuCodes=new Set(userInfo.menuBtnCodes)
return checkAccess(access,menuCodes)
}else{
store.user.requestUserInfo().then(() => {
const menuCodes=store.user.data.authCodes
checkAccess(access,menuCodes)
})
}
}else{
return checkAccess(access,authCodes)
}
}else{
return true
}
}
function checkAccess(access:string,authCodes?:Set<string>|null){
if (authCodes?.has(access)) {
return true
}else{
return false
}
}
checkAuth是一个权限校验的函数,首先从全局状态当中获取用户权限码,如果为空,就从本地缓存中获取,如果本地缓存为空,就请求后台去获取,然后判断权限码的Set集合里面是否包含当前所需权限,返回true代表验证通过,返回false代表没有权限。
5、生成路由
let key=1
const createRoutes = (menus: MenuDataItem[] | undefined) => {
if (menus) {
const routes: RouteObject[] = []
for (const menu of menus) {
if (menu.path) {
const route: RouteObject = {path: menu.path}
if (menu.component) {
const module = menu.component
const Component = React.lazy(() => module)
route.element=(
<Suspense fallback={<ProSkeleton type="list"></ProSkeleton>}>
<MiddleWare tabKey={String(key)} title={menu.name} path={menu.path}><Component /></MiddleWare>
</Suspense>
)
menu.key=String(key)
key++
}else if (!menu.routes && !menu.children) {
route.element=(
<MiddleWare tabKey={String(key)} title={menu.name} path={menu.path}></MiddleWare>
)
menu.key=String(key)
key++
}
const children = createRoutes(menu.routes || menu.children)
if (children) {
route.children = children
}
route.loader=() => {
if (!checkAuth(menu.access)) {
throw new Response('Forbidden',{status:403})
}
return null
}
routes.push(route)
}
}
return routes
}
}
const menus = menuConfig.route?.routes
const routes = createRoutes(menus);
export {menuConfig}
const router = createBrowserRouter([
{
path: '/',
element: <Main />,
loader: mainLoader,
action:mainAction,
errorElement:<ErrorBoundary/>,
children: [
...(routes || [])
]
},
{
path: '/login',
element: <Login />,
errorElement:<ErrorBoundary/>
},
], {
basename: import.meta.env.VITE_BASE_NAME
})
export default router
这里面的逻辑比较复杂。
createRoutes是一个递归函数,用来循环递归遍历菜单,通过调用React.lazy()函数得到菜单中的组件对象,使用Suspense组件进行包裹才能正常显示。MiddleWare是我自定义的一个高阶组件,用来获取菜单信息,控制tab页的显示状态,这里不展开讨论。这里还为菜单对应的路由创建了loader,在loader函数里面调用前面定义的checkAuth,判断是否有权限访问对应的路由,如果没有权限,就抛出异常,显示错误页,也就是403页面。
在createBrowserRouter函数里面配置了主页和登录页的错误页,并将前面定义的loader函数在主页的路由配置当中进行配置,用于获取用户信息,将菜单生成的路由在主页的children配置中展开。
当然,也要在main.tsx中调用路由对象,才能使它生效
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>,
)
6、菜单权限校验
//检查菜单的权限,并做一次深拷贝,得到新的对象
function filterMenuConfig(){
const menus=menuConfig.route?.routes
if (menus) {
const menuConfigCopy:MenuConfig={
route:{
path:'/',
routes:filterMenus(menus)
},
location:{
pathname:'/'
}
}
menu.data=menuConfigCopy
}
}
function filterMenus(menus: MenuDataItem[]) {
const menusCopy:MenuDataItem[]=[]
for (const menu of menus) {
const menuCopy={...menu}
menusCopy.push(menuCopy)
if (!checkAuth(menuCopy.access)) {
menuCopy.hideInMenu = true
}
const children = menuCopy.routes || menuCopy.children
if (children) {
menuCopy.routes=filterMenus(children)
}
}
return menusCopy
}
const menu = {
data: menuConfig,
filterMenuConfig
}
export default {
user,
tab,
menu
}
这里定义了filterMenuConfig函数,对菜单的配置对象做了一次深拷贝,通过循环遍历和递归拷贝了里面的每一个对象,并且在这过程中调用前面定义的checkAuth,来检查每个菜单的权限,如果没有权限,就隐藏对应的菜单。之所以要做深拷贝,就是为了不破坏原先菜单配置里面的数据,方面用户退出的时候恢复菜单数据。
然后,可以在主页的loader里面调用filterMenuConfig函数,代码如第3步所示。
7、按钮权限校验
export function Access({children,auth}:{
children?:ReactNode
auth?:string
}){
if (checkAuth(auth)) {
return (
<>{children}</>
)
}
}
这里定义了一个高阶组件,用于对按钮的权限进行校验,组件内调用了前面定义的checkAuth函数,如果用户没有权限,就不会显示对应的按钮。
调用示例如下
<Access auth="user:save">
<Button type="primary" icon={<PlusCircleOutlined />} onClick={() => {
dialogRef.current?.openDialog('新增用户')
}
}>新增</Button>
</Access>
其中,user:save代码按钮的权限码,只有拥有这个权限的用户才能看到这个按钮。