[前端] React中的权限组件设计问题小结

2158 1
王子 2022-10-21 16:06:17 | 显示全部楼层 |阅读模式
目录

    背景所谓的权限控制是什么?实现思路路由权限菜单权限


背景

权限管理是中后台系统中常见的需求之一。之前做过基于 Vue 的后台管理系统权限控制,基本思路就是在一些路由钩子里做权限比对和拦截处理。
最近维护的一个后台系统需要加入权限管理控制,这次技术栈是React,我刚开始是在网上搜索一些React路由权限控制,但是没找到比较好的方案或思路。
这时想到ant design pro内部实现过权限管理,因此就专门花时间翻阅了一波源码,并在此基础上逐渐完成了这次的权限管理。
整个过程也是遇到了很多问题,本文主要来做一下此次改造工作的总结。
原代码基于 react 16.x、dva 2.4.1 实现,所以本文是参考了ant-design-pro v1内部对权限管理的实现

所谓的权限控制是什么?

一般后台管理系统的权限涉及到两种:
    资源权限数据权限
资源权限一般指菜单、页面、按钮等的可见权限。
数据权限一般指对于不同用户,同一页面上看到的数据不同。
本文主要是来探讨一下资源权限,也就是前端权限控制。这又分为了两部分:
    侧边栏菜单路由权限
在很多人的理解中,前端权限控制就是左侧菜单的可见与否,其实这是不对的。举一个例子,假设用户guest没有路由/setting的访问权限,但是他知道/setting的完整路径,直接通过输入路径的方式访问,此时依旧是可以访问的。这显然是不合理的。这部分其实就属于路由层面的权限控制。

实现思路

关于前端权限控制一般有两种方案:
    前端固定路由表和权限配置,由后端提供用户权限标识后端提供权限和路由信息结构接口,动态生成权限和菜单
我们这里采用的是第一种方案,服务只下发当前用户拥有的角色就可以了,路由表和权限的处理统一在前端处理。
整体实现思路也比较简单:现有权限(currentAuthority)和准入权限(authority)做比较,如果匹配则渲染和准入权限匹配的组件,否则渲染无权限组件(403 页面)



路由权限

既然是路由相关的权限控制,我们免不了先看一下当前的路由表:
  1. {
  2.     "name": "活动列表",
  3.     "path": "/activity-mgmt/list",
  4.     "key": "/activity-mgmt/list",
  5.     "exact": true,
  6.     "authority": [
  7.         "admin"
  8.     ],
  9.     "component":  LoadableComponent(props),
  10.     "inherited": false,
  11.     "hideInBreadcrumb": false
  12. },
  13. {
  14.     "name": "优惠券管理",
  15.     "path": "/coupon-mgmt/coupon-rule-bplist",
  16.     "key": "/coupon-mgmt/coupon-rule-bplist",
  17.     "exact": true,
  18.     "authority": [
  19.         "admin",
  20.         "coupon"
  21.     ],
  22.     "component":  LoadableComponent(props),
  23.     "inherited": true,
  24.     "hideInBreadcrumb": false
  25. },
  26. {
  27.     "name": "营销录入系统",
  28.     "path": "/marketRule-manage",
  29.     "key": "/marketRule-manage",
  30.     "exact": true,
  31.     "component":  LoadableComponent(props),
  32.     "inherited": true,
  33.     "hideInBreadcrumb": false
  34. }
复制代码
这份路由表其实是我从控制台 copy 过来的,内部做了很多的转换处理,但最终生成的就是上面这个对象。
这里每一级菜单都加了一个authority字段来标识允许访问的角色。component代表路由对应的组件:
  1. import React, { createElement } from "react"
  2. import Loadable from "react-loadable"
  3. "/activity-mgmt/list": {
  4.     component: dynamicWrapper(app, ["activityMgmt"], () => import("../routes/activity-mgmt/list"))
  5. },
  6. // 动态引用组件并注册model
  7. const dynamicWrapper = (app, models, component) => {
  8.   // register models
  9.   models.forEach(model => {
  10.     if (modelNotExisted(app, model)) {
  11.       // eslint-disable-next-line
  12.       app.model(require(`../models/${model}`).default)
  13.     }
  14.   })
  15.   // () => require('module')
  16.   // transformed by babel-plugin-dynamic-import-node-sync
  17.   // 需要将routerData塞到props中
  18.   if (component.toString().indexOf(".then(") < 0) {
  19.     return props => {
  20.       return createElement(component().default, {
  21.         ...props,
  22.         routerData: getRouterDataCache(app)
  23.       })
  24.     }
  25.   }
  26.   // () => import('module')
  27.   return Loadable({
  28.     loader: () => {
  29.       return component().then(raw => {
  30.         const Component = raw.default || raw
  31.         return props =>
  32.           createElement(Component, {
  33.             ...props,
  34.             routerData: getRouterDataCache(app)
  35.           })
  36.       })
  37.     },
  38.     // 全局loading
  39.     loading: () => {
  40.       return (
  41.         <div
  42.           style={{
  43.             display: "flex",
  44.             justifyContent: "center",
  45.             alignItems: "center"
  46.           }}
  47.         >
  48.           <Spin size="large" className="global-spin" />
  49.         </div>
  50.       )
  51.     }
  52.   })
  53. }
  54. 复制代码
复制代码
有了路由表这份基础数据,下面就让我们来看下如何通过一步步的改造给原有系统注入权限。
先从src/router.js这个入口开始着手:
  1. // 原src/router.js
  2. import dynamic from "dva/dynamic"
  3. import { Redirect, Route, routerRedux, Switch } from "dva/router"
  4. import PropTypes from "prop-types"
  5. import React from "react"
  6. import NoMatch from "./components/no-match"
  7. import App from "./routes/app"
  8. const { ConnectedRouter } = routerRedux
  9. const RouterConfig = ({ history, app }) => {
  10.   const routes = [
  11.     {
  12.       path: "activity-management",
  13.       models: () => [import("@/models/activityManagement")],
  14.       component: () => import("./routes/activity-mgmt")
  15.     },
  16.     {
  17.       path: "coupon-management",
  18.       models: () => [import("@/models/couponManagement")],
  19.       component: () => import("./routes/coupon-mgmt")
  20.     },
  21.     {
  22.       path: "order-management",
  23.       models: () => [import("@/models/orderManagement")],
  24.       component: () => import("./routes/order-maint")
  25.     },
  26.     {
  27.       path: "merchant-management",
  28.       models: () => [import("@/models/merchantManagement")],
  29.       component: () => import("./routes/merchant-mgmt")
  30.     }
  31.     // ...
  32.   ]
  33.   return (
  34.     <ConnectedRouter history={history}>
  35.       <App>
  36.         <Switch>
  37.           {routes.map(({ path, ...dynamics }, key) => (
  38.             <Route
  39.               key={key}
  40.               path={`/${path}`}
  41.               component={dynamic({
  42.                 app,
  43.                 ...dynamics
  44.               })}
  45.             />
  46.           ))}
  47.           <Route component={NoMatch} />
  48.         </Switch>
  49.       </App>
  50.     </ConnectedRouter>
  51.   )
  52. }
  53. RouterConfig.propTypes = {
  54.   history: PropTypes.object,
  55.   app: PropTypes.object
  56. }
  57. export default RouterConfig
复制代码
这是一个非常常规的路由配置,既然要加入权限,比较合适的方式就是包一个高阶组件AuthorizedRoute。然后router.js就可以更替为:
  1. function RouterConfig({ history, app }) {
  2.   const routerData = getRouterData(app)
  3.   const BasicLayout = routerData["/"].component
  4.   return (
  5.     <ConnectedRouter history={history}>
  6.       <Switch>
  7.         <AuthorizedRoute path="/" render={props => <BasicLayout {...props} />} />
  8.       </Switch>
  9.     </ConnectedRouter>
  10.   )
  11. }
复制代码
来看下AuthorizedRoute的大致实现:
  1. const AuthorizedRoute = ({
  2.   component: Component,
  3.   authority,
  4.   redirectPath,
  5.   {...rest}
  6. }) => {
  7.   if (authority === currentAuthority) {
  8.     return (
  9.       <Route
  10.       {...rest}
  11.       render={props => <Component {...props} />} />
  12.     )
  13.   } else {
  14.     return (
  15.       <Route {...rest} render={() =>
  16.         <Redirect to={redirectPath} />
  17.       } />
  18.     )
  19.   }
  20. }
复制代码
我们看一下这个组件有什么问题:页面可能允许多个角色访问,用户拥有的角色也可能是多个(可能是字符串,也可呢是数组)。
直接在组件中判断显然不太合适,我们把这部分逻辑抽离出来:
  1. /**
  2. * 通用权限检查方法
  3. * Common check permissions method
  4. * @param { 菜单访问需要的权限 } authority
  5. * @param { 当前角色拥有的权限 } currentAuthority
  6. * @param { 通过的组件 Passing components } target
  7. * @param { 未通过的组件 no pass components } Exception
  8. */
  9. const checkPermissions = (authority, currentAuthority, target, Exception) => {
  10.   console.log("checkPermissions -----> authority", authority)
  11.   console.log("currentAuthority", currentAuthority)
  12.   console.log("target", target)
  13.   console.log("Exception", Exception)
  14.   // 没有判定权限.默认查看所有
  15.   // Retirement authority, return target;
  16.   if (!authority) {
  17.     return target
  18.   }
  19.   // 数组处理
  20.   if (Array.isArray(authority)) {
  21.     // 该菜单可由多个角色访问
  22.     if (authority.indexOf(currentAuthority) >= 0) {
  23.       return target
  24.     }
  25.     // 当前用户同时拥有多个角色
  26.     if (Array.isArray(currentAuthority)) {
  27.       for (let i = 0; i < currentAuthority.length; i += 1) {
  28.         const element = currentAuthority[i]
  29.         // 菜单访问需要的角色权限 < ------ > 当前用户拥有的角色
  30.         if (authority.indexOf(element) >= 0) {
  31.           return target
  32.         }
  33.       }
  34.     }
  35.     return Exception
  36.   }
  37.   // string 处理
  38.   if (typeof authority === "string") {
  39.     if (authority === currentAuthority) {
  40.       return target
  41.     }
  42.     if (Array.isArray(currentAuthority)) {
  43.       for (let i = 0; i < currentAuthority.length; i += 1) {
  44.         const element = currentAuthority[i]
  45.         if (authority.indexOf(element) >= 0) {
  46.           return target
  47.         }
  48.       }
  49.     }
  50.     return Exception
  51.   }
  52.   throw new Error("unsupported parameters")
  53. }
  54. const check = (authority, target, Exceptio) => {
  55.   return checkPermissions(authority, CURRENT, target, Exception)
  56. }
复制代码
首先如果路由表中没有authority字段默认都可以访问。
接着分别对authority为字符串和数组的情况做了处理,其实就是简单的查找匹配,匹配到了就可以访问,匹配不到就返回Exception,也就是我们自定义的异常页面。
有一个点一直没有提:用户当前角色权限 currentAuthority 如何获取?这个是在页面初始化时从接口读取,然后存到 store 中
有了这块逻辑,我们对刚刚的AuthorizedRoute做一下改造。首先抽象一个Authorized组件,对权限校验逻辑做一下封装:
  1. import React from "react"
  2. import CheckPermissions from "./CheckPermissions"
  3. class Authorized extends React.Component {
  4.   render() {
  5.     const { children, authority, noMatch = null } = this.props
  6.     const childrenRender = typeof children === "undefined" ? null : children
  7.     return CheckPermissions(authority, childrenRender, noMatch)
  8.   }
  9. }
  10. export default Authorized
复制代码
接着AuthorizedRoute可直接使用Authorized组件:
  1. import React from "react"
  2. import { Redirect, Route } from "react-router-dom"
  3. import Authorized from "./Authorized"
  4. class AuthorizedRoute extends React.Component {
  5.   render() {
  6.     const { component: Component, render, authority, redirectPath, ...rest } = this.props
  7.     return (
  8.       <Authorized
  9.         authority={authority}
  10.         noMatch={<Route {...rest} render={() => <Redirect to={{ pathname: redirectPath }} />} />}
  11.       >
  12.         <Route {...rest} render={props => (Component ? <Component {...props} /> : render(props))} />
  13.       </Authorized>
  14.     )
  15.   }
  16. }
  17. export default AuthorizedRoute
复制代码
这里采用了render props的方式:如果提供了component props就用component渲染,否则使用render渲染。

菜单权限

菜单权限的处理相对就简单很多了,统一集成到SiderMenu组件处理:
  1. export default class SiderMenu extends PureComponent {
  2.   constructor(props) {
  3.     super(props)
  4.   }
  5.   /**
  6.    * get SubMenu or Item
  7.    */
  8.   getSubMenuOrItem = item => {
  9.     if (item.children && item.children.some(child => child.name)) {
  10.       const childrenItems = this.getNavMenuItems(item.children)
  11.       // 当无子菜单时就不展示菜单
  12.       if (childrenItems && childrenItems.length > 0) {
  13.         return (
  14.           <SubMenu
  15.             title={
  16.               item.icon ? (
  17.                 <span>
  18.                   {getIcon(item.icon)}
  19.                   <span>{item.name}</span>
  20.                 </span>
  21.               ) : (
  22.                 item.name
  23.               )
  24.             }
  25.             key={item.path}
  26.           >
  27.             {childrenItems}
  28.           </SubMenu>
  29.         )
  30.       }
  31.       return null
  32.     }
  33.     return <Menu.Item key={item.path}>{this.getMenuItemPath(item)}</Menu.Item>
  34.   }
  35.   /**
  36.    * 获得菜单子节点
  37.    * @memberof SiderMenu
  38.    */
  39.   getNavMenuItems = menusData => {
  40.     if (!menusData) {
  41.       return []
  42.     }
  43.     return menusData
  44.       .filter(item => item.name && !item.hideInMenu)
  45.       .map(item => {
  46.         // make dom
  47.         const ItemDom = this.getSubMenuOrItem(item)
  48.         return this.checkPermissionItem(item.authority, ItemDom)
  49.       })
  50.       .filter(item => item)
  51.   }
  52.   /**
  53.    *
  54.    * @description 菜单权限过滤
  55.    * @param {*} authority
  56.    * @param {*} ItemDom
  57.    * @memberof SiderMenu
  58.    */
  59.   checkPermissionItem = (authority, ItemDom) => {
  60.     const { Authorized } = this.props
  61.     if (Authorized && Authorized.check) {
  62.       const { check } = Authorized
  63.       return check(authority, ItemDom)
  64.     }
  65.     return ItemDom
  66.   }
  67.   render() {
  68.     // ...
  69.     return
  70.       <Sider
  71.         trigger={null}
  72.         collapsible
  73.         collapsed={collapsed}
  74.         breakpoint="lg"
  75.         onCollapse={onCollapse}
  76.         className={siderClass}
  77.       >
  78.         <div className="logo">
  79.           <Link to="/home" className="logo-link">
  80.             {!collapsed && <h1>冯言冯语</h1>}
  81.           </Link>
  82.         </div>
  83.         <Menu
  84.           key="Menu"
  85.           theme={theme}
  86.           mode={mode}
  87.           {...menuProps}
  88.           onOpenChange={this.handleOpenChange}
  89.           selectedKeys={selectedKeys}
  90.         >
  91.           {this.getNavMenuItems(menuData)}
  92.         </Menu>
  93.       </Sider>
  94.   }
  95. }
复制代码
这里我只贴了一些核心代码,其中的checkPermissionItem就是实现菜单权限的关键。他同样用到了上文中的check方法来对当前菜单进行权限比对,如果没有权限就直接不展示当前菜单。
到此这篇关于React中的权限组件设计的文章就介绍到这了,更多相关React权限组件内容请搜索中国红客联盟以前的文章或继续浏览下面的相关文章希望大家以后多多支持中国红客联盟!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

中国红客联盟公众号

联系站长QQ:5520533

admin@chnhonker.com
Copyright © 2001-2025 Discuz Team. Powered by Discuz! X3.5 ( 粤ICP备13060014号 )|天天打卡 本站已运行