教你用 React Hook + TS 实现 RBAC 权限控制
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情
前言
相信大部分开发后台系统的同学们都有接触过 RBAC(权限-角色管理) ,如果使用的是一些后台模版进行开发那么可能已经内置集成好了,这一点在 VUE 的各种后台模板做的非常健全。而如果使用 React 实现 RBAC 功能的同学一般定制化需求比较高,所以实现的方式也是五花八门。
最近我也基于我们的项目出了一个前端实现 RBAC 的方案并做了相关的实现,虽然只是比较初期的实现,并不是特别复杂的场景,但是也希望能为大家的技术实现提供灵感。
方案设计
首先,前端对于的用户权限的限制一般体现在以下两个方面:
路由守卫:当用户访问角色权限不允许访问的路由时,直接展示一个无权限的页面,或是跳转至登陆页面等逻辑。
渲染判断:当用户能够访问页面时,我们需要对页面内某些交互进行限制,比如菜单栏隐藏某些菜单项,某些按钮需要禁用等等。
下面我就讲讲我是从哪几个点如何实现这两项功能的。
权限数据的请求,存储,获取及更新
一般来说,后端会为前端提供获取用户角色权限的接口,前端直接进行请求后进行相应处理即可,后端会返回我们一个 json 数据,比较常见的权限数据结构如下:
"user": {
"order": {
"get": true,
"post": true,
"put": false,
"delete": false
},
"userInfo": {
"get": true,
"post": true,
"put": true,
"delete": false
}
},
"log": {
"application": {
"get": true,
"post": false,
"put": false,
"delete": false
}
// ...
}
// ...
但是权限数据是要用于判断权限进行 UI 的动态渲染及路由守卫的,因此权限数据应该是要能 响应式更新 的,在 react 中也就是 state(状态),并且这个 state 是需要在各个组件间非常方便的获取到的,所以我们有以下三种选择:
- 状态管理库,如 Redux ,Mobx,jotai 等等
- React context
- 自定义 React Hook
由于原本我的项目中没有使用状态管理库,权限信息又比较简单。所以这里我选择的是使用的是自定义 Hook 的方式实现,通过 swr + 自定义 hook 进行数据的请求和存储。
import axios from 'axios';
import useSWR, { useSWRConfig } from 'swr';
import _ from 'lodash';
import useUser from './useUser';
type Flat<T, P extends string = ''> = {
[K in keyof T as T[K] extends boolean
? K extends string
? P extends ''
? K
: `${P}.${K}`
: never
: K extends string
? keyof Flat<T[K], P extends '' ? K : `${P}.${K}`>
: never]: boolean;
};
type Path = keyof Flat<AccessDataType>;
const useAccess = (id: string) => {
const { mutate } = useSWRConfig();
const { data, error } = useSWR<{
payload: Payload;
warning?: string;
}>(`/getAccess/${id}`, (url: string) =>
axios.get(url).then((res) => res.data)
);
const getAccess = (path: Path): boolean => _.get(data, path, false);
const reloadAccess = () => mutate(`/getAccess/${id}`);
return {
accessInfo: data?.payload,
warning: data?.warning,
isLoading: !error && !data,
isError: error,
reloadAccess,
};
};
export default useAccess;
这里实现了一个 useAccess
的自定义 hook ,通过 axios 发送请求,通过 swr 缓存及更新数据,在 swr 中处理后,数据会帮我们处理为state,当数据变化我们也可以执行相应的 effect 了。
getAccess
getAccess
通过 lodash 的 get 函数来帮助我们快速的获取权限信息中的某一个权限,使用示例如下:
var object = { 'a': [{ 'b': { 'c': 3 } }] };\
_.get(object, 'a[0].b.c');\
// => 3\
_.get(object, ['a', '0', 'b', 'c']);\
// => 3\
_.get(object, 'a.b.c', 'default');\
// => 'default'
重点在于这里的类型,在代码首部我定义了一个 Flat 类型,它能将我们传入的对象范型递归转换为 所有对象字段的模版字符串类型的联合类型
type data = {
a: {
b: boolean;
c: boolean;
};
d: boolean;
};
type Path = keyof Flat<data>;
// type Path = "d" | "a.b" | "a.c"
这样就可以在执行 getAccess
方法时,为我们提示权限信息所有的字段,避免因为传入错误的参数导致获取的权限数据错误。
reloadAccess
reloadAccess 通过执行 swr 的mutate
方法实现权限数据重新请求,swr 判断到执行 mutate 传入的参数 key与执行 useSWR 传入的参数相同时,如果后端返回的数据与上一次的不同, 会帮我们自动更新 state ,这样我们页面和组件中所有依赖权限信息进行动态渲染组件就会拿到新的权限信息并重新渲染。
路由守卫
根据路径获取权限
在我们的项目中使用的是 next.js 框架,我们的路由是基于文件系统的,不需要单独维护一个routeMap
文件来实现路径和页面的关系指定。但要实现路由守卫的话,还是需要去实现一个 routeMap 的。这里展示一下 routeMap 的数据结构:
export type RouteProps = {
name: string; // 菜单名称
icon?: React.ReactNode; // 菜单图标
path: string; // 路由路径
access?: string[]; // 访问权限
hidden?: boolean; // 是否在菜单中隐藏
child?: RouteProps; // 子路由数组
}[];
routeMap 就是一个树形结构的路由数据,其中 access 字段就是如果咱们访问这个页面时,需要去获取一下这个权限,如果没有这个权限那么就不允许用户进入这个页面。这一步我们也通过一个自定义 hook 来实现:
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import useAccess from 'hooks/useAccess';
import _ from 'lodash';
import route from 'helper/route';
export type RouteProps = {
name: string;
icon?: React.ReactNode;
path: string;
access?: string[];
hidden?: boolean;
child?: RouteProps;
}[];
// Flattening the routing object
const flatRoute = (routeMap: RouteProps): RouteProps =>
_.flatMapDeep(routeMap, (item) => [item, ...flatRoute(item.child || [])]);
const useRouteGuard = () => {
const router = useRouter();
const { pathname } = router;
const { accessData, isLoading } = useAccess();
const isAllow = useMemo(() => {
const routeAccess = flatRoute(route).find(
(item) => item.path === pathname
)?.access;
return routeAccess
? routeAccess?.find((item) => {
if (_.get(accessData, item)) {
return true;
}
return false;
})
: true;
}, [accessData, pathname, route]);
return { isLoading, isAllow };
};
export default useRouteGuard;
useRouteGuard
这个 hook 用于帮我们判断页面是否允许进入,通过将当前用户访问的路径与 routeMap 中的 path 进行对应就能获取到对应 access 字段,再去权限信息里获取权限并从 hook 中返回。
拦截页面
知道了用户能否进入页面,就需要对页面的渲染进行判断处理了,这一步大家根据业务进行自己的处理就 OK 了,我的处理方式是在页面最外层组件中写一个判断:
{isAllow ? children : <Page403 />}
你也可以直接通过路由方法进行跳转到指定页面或者返回首页面。
元素动态渲染
页面中的元素如果需要根据权限信息动态渲染,可以直接使用前面实现的 useAccess hook 获取指定位置的数据。
import useAccess from 'hooks/useAccess';
export default Page() => {
const { getAccess } = useAccess();
return {
<Button
color="white"
variant="ghost"
isDisabled={!getAccess('user.order.put')}
>
更新数据
</Button>
}
}
而且代码提示非常齐全:
响应拦截
有一种情况是,当你正在使用后台的过程中,角色等级比你高的人将你的权限关闭,这时前端还没有进行 UI 上的更新,如果你执行了某些无权限的操作后端将会给你返回 用户权限不足 的提示,一般状态码为 403,这时我们需要及时更新一下我们的权限数据,实现方式如下:
import axios from 'axios';
import { mutate } from 'swr';
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
if (error.response.status === 403) {
mutate('/getAccess');
showToast({
title: 'You do not have permission to perform this action.',
status: 'error',
});
}
}
);
可以直接使用 axios 提供的 interceptors 将所有请求的响应进行拦截,当遇到了 403 状态码时通过 mutate
更新一下内存中的权限数据,此时 swr 还会帮我们重新渲染 UI ,就可以避免前端内存数据与数据库不匹配的情况了。
总结
这篇文章介绍了一下前端在 RBAC 中应该做的事情以及实现的方式,当然目前的实现方式相对简单,不适合很复杂的系统,抽象程度比较低,但希望实现的过程可以给到大家帮助。如果觉得文章写的不错不妨点个赞支持一下!👍
respect!
转载自:https://juejin.cn/post/7142803399916912648