使用React Router实现前端的权限访问控制

woshihedayu 2024-10-25 14:03:01 阅读 64

前段时间学习了React Router,发现没有Vue里面的路由功能强大,没有直接提供路由中间件,不能像Vue里面一样在路由配置上设置任意的额外属性,但是可以通过一些技巧来实现这些功能。

1、配置菜单

后台管理系统一般都会在左侧显示菜单,右侧显示页面,本例中使用Ant Design组件当然也不例外。虽然umi里面已经集成了很多功能,但是有些地方用起来不够灵活,比如路由配置高阶组件,不能传递prop;每一个权限码都要配置相同的函数,等等。所以,我更喜欢用Vite+React来搭建项目。

废话不多说,菜单配置的代码如下

<code>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>}>code>

<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">code>

<Button type="primary" icon={ -- --><PlusCircleOutlined />} onClick={ () => { code>

dialogRef.current?.openDialog('新增用户')

}

}>新增</Button>

</Access>

其中,user:save代码按钮的权限码,只有拥有这个权限的用户才能看到这个按钮。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。