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

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

1、配置菜单


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

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

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代码按钮的权限码,只有拥有这个权限的用户才能看到这个按钮。

版权声明:如无特殊标注,文章均来自网络,本站编辑整理,转载时请以链接形式注明文章出处,请自行分辨。

本文链接:https://www.shbk5.com/dnsj/72913.html