关于登录授权和安全性这部分的内容,这里不做讨论
用户访问权限,其实是一个中后台很常规的问题,说到中后台不得不提到最佳实践,ant-design-pro。我自己开始接触 pro 的时候,是 v1 要升 v2 的时候了。它是通过权限组件 Authorized 来进行权限校验的,通过比对现有权限与准入权限,决定相关元素的展示。然后通过路由数据生成菜单数据,再结合准入权限,来达到整个权限管理体系。这在很多的项目中实践,也得到了很好的践行。
用这个方案需要几个前置条件,前端需要知道各个页面的准入权限,菜单数据通过路由数据生成,到底是需要控制菜单数据还是控制路由数据,有点分不清楚。
但是对于我自己来说,总觉得很难达到“舒适”的使用体验。也在 pro 的 issues 区看到了很多相关的问题和需求,但基本上都没有得到很好的答复。比如当涉及到“如何从服务端获取菜单数据进行权限校验”时,就很难处理。到 pro v2 结合 Umi ,主要有两种方式处理。
pro v2 的实现
方案一,结合 Umi 的运行时,在 patchRoutes
中动态修改了路由数据,从官网的例子来看,似乎处理了这个问题,但是,其实这里是一个错误的引导。
let extraRoutes; export function patchRoutes({ routes }) { merge(routes, extraRoutes); } export function render() { fetch('/api/routes').then((res) => { extraRoutes = res.routes }) }
配置式中,需要将项目所有的可用路由提前配置,这里编辑的只是运行时的路由,简而言之,修改的不是编译时的路由,很多人把这个搞混了。比如在 routes 中配置了页面 A、B,然后在这里增加了页面C,在 dev 的时候,发现可用,效果也符合预期。但是当执行 umi build
之后,发现没有页面 C。
约定式,会默认编译所有的页面,所以不存在上述的问题,但是约定式生成的路由,与实际可用菜单差异较大,在这里需要做很大的操作成本,并且需要时刻注意,只能修改 routes ,不能返回新的对象。
方案二,也是我比较认可的方案,通过把路由和菜单数据分割开,我的理解是路由数据是传递给 Umi 做页面编译需要的数据,菜单数据是根据业务需要进行编辑和维护的。两份数据确实有冗余部分,但是将两份数据统一维护,要耗费的成本太高。所以我干脆将菜单数据,放到 models 中维护,通过在 models 里面发起请求,在 layout 中消费的方式实现动态菜单功能。
Umi 3 中的实现
同样有从服务端获取菜单数据的问题,参考上面的方案。在最新的 Umi 3 中,通过结合 @umijs/plugin-plugin-initial-state 、 @umijs/plugin-access 和 @umijs/plugin-layout 一起使用,进行权限校验。通过 @umijs/plugin-initial-state 获取初始化数据, 在 @umijs/plugin-access 中通过 src/access
定义的方法,对路由数据进行标记 unaccessible
,最后在 @umijs/plugin-layout 中对标记了 unaccessible
的菜单进行过滤。
先不说分三个插件库有多难维护,在 access 插件中使用 splice
修改路由数据,在 layout 插件中通过 const _routes = require('@@/core/routes').routes;
引用被修改后的数据,这种同事无法维护的代码。仅仅不支持约定式使用这一点,我就无法接受。
我自己项目中的尝试
对我来说权限校验,无非就是有什么菜单,能不能访问?上面提到的三个插件库,只用到一个方法类 runtimeUtil 和 一个过滤组件 WithExceptionOpChildren 保留 access 插件的 src/access
文件。
// src/access.ts export default function (initialState: { currentUser?: API.CurrentUser | undefined }) { const { currentUser } = initialState; return { canAdmin: currentUser && currentUser.access === 'admin', }; }
在 layout 中,发起请求服务端的菜单,或者本地的菜单,然后通过 runtimeUtil 和 transformRoute 对菜单数据进行操作。
import React, { FC } from 'react'; import ProLayout from '@ant-design/pro-layout'; import { useIntl, } from 'umi'; import { transformRoute } from '@umijs/route-utils'; import accessFactory from '@/access'; // 以下两个引用是伪代码 import { WithExceptionOpChildren } from '@umijs/plugin-layout/component/Exception/index.tsx'; import { traverseModifyRoutes } from '@umijs/plugin-access/utils/runtimeUtil'; const BasicLayout: FC = ({ children, location }) => { const pathName = location.pathname; const intl = useIntl(); // 这里传入初始化数据,如果有使用 plugin-initial-state 可以直接传入 initialState const access = accessFactory({ currentUser: { access: 'admin' } }) // 这个数据可以是本地写死,或者来自服务端接口,如果是配置式的还可以直接使用route.routes const serveMenuData = [ { path: '/', name: 'index', icon: 'smile', }, { path: '/ListTableList', name: 'list', icon: 'smile', hideInMenu: true, access: 'canAdmin' }, ] const accrssMenu = traverseModifyRoutes(serveMenuData, access); const { menuData, breadcrumb } = transformRoute(accrssMenu, true, intl.formatMessage); const currentPathConfig = breadcrumb.get(pathName) return ( <ProLayout menuDataRender={() => menuData} > <WithExceptionOpChildren currentPathConfig={currentPathConfig}> {children} </WithExceptionOpChildren> </ProLayout> ); }; export default BasicLayout;
上面的使用主要做了几件事情
1、向 accessFactory 中传入 initState 数据,得到我们需要的权限对象 access 2、通过 traverseModifyRoutes 方法修改我们的初始菜单数据
3、将修改后的菜单数据,通过 transformRoute 方法转换,过滤掉不展示的菜单
4、使用 breadcrumb.get(pathname) 获取当前路由的菜单数据
5、通过 WithExceptionOpChildren 拦截匹配 currentPathConfig
通过上面的方法,可以处理几个常见问题
1、动态菜单
2、路由守卫(当菜单不现实,但是直接通过url访问,会被拦截)
3、关键的几个数据可以随意传入,方便结合其他插件使用。
通过翻阅 Umi@3 插件库的源码和文档,最终整理了上述的方案,在我们项目中实践,不知道有没有其他未知的问题,如果经过一段时间的实践,没有其他问题的话,应该会直接整理成一个新的 Umi 插件。