Vue和React权限控制的那些事儿

5,648 阅读9分钟

自我介绍

看官们好,我叫JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。

前言

无论是后台管理系统,还是面向C端的产品,权限控制都是日常工作中常见的需求。在此梳理一下权限控制的那些逻辑,以及在Vue/React框架下是有什么样的解决方案。

什么是权限控制?

现在基本上都是基于RBAC权限模型来做权限控制

一般来说权限控制就是3种

  • 页面权限:说白了部分页面是具备权限的,没权限的无法访问

  • 操作权限:增删改查的操作会有权限的控制

  • 数据权限:不同用户看到的、数据是不一样的,比如一个列表,不同权限的查看这部分数据,可能有些字段是**脱敏的,有些条目无法查看详情,甚至部分条目是无法查看

那么对应到前端的维度,常见的就4种

  • 权限失效(无效)(token过期/尚未登录)

  • 页面路由控制,以路由为控制单位

  • 页面上的操作按钮、组件等的权限控制,以组件/按钮为最小控制单位

  • 动态权限控制,比如1个列表,部分数据可以编辑,部分部分不可编辑

image.png

⚠️注意: 本文一些方案 基于 React18 React-Router V6 以及 Vue3 Vue-Router V4

⚠️Umi Max 这种具备权限控制系统的框架暂时不在讨论范围内~~

权限标识

由于市面上各家实现细节不一样,这里只讨论核心逻辑思路,不考虑细节实现

无论框架如何,后端根据RABC角色权限这套逻辑下来的,会有如下类似的权限标识信息,可以通过专门的接口获取,或者跟登录接口放在一起。

image.png

然后根据这些数据,去跟路由,按钮/组件等,比对产生真正的权限

像这种权限标识一般都存在内存当中(即便存在本地存储也需要加密,不过其实真正的权限控制还是需要后端来控),一般都是全局维护的状态,配合全局状态管理库使用。

权限失效

这种场景一般是在发送某些请求,返回过期状态

或者跟后端约定一个过期时间(这种比较不靠谱)

通常是在 全局请求拦截 下写相关的处理逻辑,整理了一下相关的逻辑如下 image.png

路由级别权限控制

通常前端配好的路由可以分为 2 种:

一种是静态路由:即无论什么权限都会有的,比如登录页、404页这些

另一种是动态路由:虽然叫动态路由,其实也是在前端当中定义好了的。说它是动态的原因是根据后端的权限列表,要去做动态控制的

vue实现

在vue体系下,可以通过路由守卫以及动态添加路由来实现

动态路由

先配置静态路由表 , 不在路由表内的路由重定向到指定页(比如404)

在异步获取到权限列表之后,对动态部分的路由进行过滤之后得到有权限的那部分路由,再通过router.addRoute()添加到路由实例当中。

流程为:

(初始化时) 添加静态路由 --> 校验登录态(比如是否有token之类的) --> 获取权限列表(存到vuex / pinia) --> 动态添加路由(在路由守卫处添加)


rightsRoutesList // 来自后端的当前用户的权限列表,可以考虑存在全局状态库
dynamicRoutes // 动态部分路由,在前端已经定义好, 直接引入

// 对动态路由进行过滤,这里仅用path来比较
// 目的是添加有权限的那部分路由,具体实现方案自定。
const generateRoute = (rightsRoutesList)=>{
    //ps: 这里需要注意下(如果有)嵌套路由的处理
    return dynamicRoutes.filter(i=>
        rightsRoutesList.some(path=>path === i.path)
    )
}

// 拿到后端返回的权限列表
const getRightsRoutesList = ()=>{
    return new Promise(resolve=>{
        const store = userStore()
        if(store.rightsRoutesList){
           resolve(store.rightsRoutesList)
        }else{
            // 这里用 pinia 封装的函数去获取 后端权限列表
          const rightsRoutesList =  await store.fetchRightsRoutesList()
          resolve(rightsRoutesList)
        }
    }
}

let hasAddedDynamicRoute = false
router.beforeEach(async (to, from) => {
    if(hasAddedDynamicRoute){       
        // 获取
        const rightsRoutesList = await getRightsRoutesList()
        
        // 添加到路由示例当中
        const routes = generateRoute(rightsRoutesList)
        routes.forEach(route=>router.addRoute(route))
        // 对于部分嵌套路由的子路由才是动态路由的,可以
        router.addRoute('fatherName',route)        
        hasAddedDynamicRoute = true
    } 
    // 其他逻辑。。。略
    
    
    next({...to})
}

踩坑

通过动态addRoute去添加的路由,如果你F5刷新进入这部分路由,会有白屏现象。

image.png

因为刷新进入的过程经历了 异步获取权限列表 --> addRoute注册 的过程,此时跳转的目标路由就和你新增的路由相匹配了,需要去手动导航。

因此你需要在路由守卫那边next放行,等下次再进去匹配到当前路由

你可以这么写

router.beforeEach( (to,from,next) => {
     // ...其他逻辑
     
     // 关键代码
     next({...to})
})

路由守卫

一次性添加所有的路由,包括静态和动态。每次导航的时候,去对那些即将进入的路由,如果即将进入的路由是在动态路由里,进行权限匹配。

可以利用全局的路由守卫

router.beforeEach( (to,from,next) => {
    // 没有访问权限,则重定向到404
    if(!hasAuthorization(to)){
        // 重定向
        return '/404' 
    }
})

也可以使用路由独享守卫,给 权限路由 添加

    // 路由表
    const routes = [
    //其他路由。。。略
        
        // 权限路由
        { 
            path: '/users/:id',
            component: UserDetails, 
            // 定义独享路由守卫
            beforeEnter: (to, from) => {
                // 如果没有许可,则
                if(!hasAuthorization(to)){
                    // 重定向到其他位置
                    return '/404' 
                }
            }, 
        }, 
    ]

react实现

在react当中,一般先将所有路由添加好,再通过路由守卫来做权限校验

局部守卫loader

React-router 当中没有路由守卫功能,可以利用v6版本的新特性loader来做,给权限路由都加上对应的控制loader

import { redirect, createBrowserRouter, RouterProvider } from 'react-router-dom'


const router = createBrowserRouter([
  {
    // it renders this element
    element: <Team />,

    // when the URL matches this segment
    path: "teams/:teamId",

    // with this data loaded before rendering
    loader: async ({ request, params }) => {
       // 拿到权限
       const permission = await getPermission("teams/:teamId")
       // 没有权限则跳到404
       if(!permission){
           return redirect('/404')
       }
       return null
    },

    // and renders this element in case something went wrong
    errorElement: <ErrorBoundary />,
  },
]);

// 使用
function RouterView (){
    return (
        <RouterProvider router={router}/>
    )
}


包装路由(相当于路由守卫)

配置路由组件的时候,先渲染包装的路由组件

image.png

在包装的组件里做权限判断

function RouteElementWrapper({children, path, ...props }: any) {
    const [isLoading, setIsLoading] = useState<boolean>(false);
    useEffect(()=>{
        // 判断登录态之类的逻辑
        
        // 如果要获取权限,则需要setIsLoading,保持加载状态
        
        // 这里判断权限
        if(!hasAccess(path)){
           navigate('/404')
        }
    },[])
    // 渲染routes里定义好的路由
    return isLoading ? <Locading/> : children
}

需要特别说明的是:某个嵌套路由如果都是权限路由,只需要在父路由下控制权限即可。 比如你的路由权限是:/user/*,此时只需要在父路由下控制即可

[
    {
        path: "/user",
        element: <RouteElementWrapper>{lazyLoading(<User/>)}</RouteElementWrapper>
        children:[
            path:"/user/detail",
            element:lazyLoading(<UserDetail/>) // 这里无需继续控制
        ]
    }
]

按钮(组件)级别权限控制

组件级别的权限控制,核心思路就是 将判断权限的逻辑抽离出来,方便复用。

vue 实现

在vue当中可以利用指令系统,以及hook来实现

自定义指令

指令可以这么去使用

<template>
    <button v-auth='/site/config.btn'> 编辑 </button>
</template>

指令内部可以操作该组件dom和vNode,因此可以控制显隐、样式等。

示例

export default {
    install(app){
        app.directive('auth',AuthDirective)
    }
}

export const AuthDirective = {
    mounted(el, binding, vnode, prevVnode) {

        // 拿到组件对应的真实dom
        console.log(el);
        // 拿到 v-auth="这里的内容"
        console.log(binding.value)
        // 拿到组件实例
        console.log(vnode.context);
    },
}

hook

同样的利用hook 配合v-if 等指令 也可以实现组件级颗粒度的权限控制

<template>
    <button v-if='editAuth'> 权限编辑 </button>
    <div v-else>
        无权限时做些什么
    </div>
    <button v-if='saveAuth'> 权限保存 </button>
    <button v-if='removeAuth'> 权限删除 </button>
</template>
<script setup>
    import useAuth from '@/hooks/useAuth'
    // 传入权限
    const [editAuth,saveAuth,removeAuth] = useAuth(['edit','save','remove'])
</script>

hook里的实现思路:

从pinia获取权限列表 --> hook里监听这个列表,并且匹配对应的权限 --> 同时修改响应式数据。

示例代码

export default function useAuth(authTokens){
    let authTokensList = authTokens

    const store = userStore()

    // 返回的响应式数组

    const reactiveList = []

    // 存放权限和响应式数据

    const authRefMap = new Map()

    if(typeof authTokens == 'string'){

        authTokensList = [authTokens]

    }
    
    if(!Array.isArray(authTokensList)) return reactiveList

    authTokens.forEach(auth=>{

        const itemRef = ref(false)

        authRefMap.set(auth,itemRef)

        reactiveList.push(itemRef)

    })
    // 监听pinia的变化

    watch(store.authData,()=>{
        authTokens.forEach(auth=>{
            authRefMap.get(auth).value = store.authData.components.includes(auth)
        })
    })
    return reactiveList
}

react 实现

在React当中可以用高阶组件和hook的方式来实现

hook

定义一个useAuth的hook

主要逻辑是: 取出权限,然后通过useCallback/useMemo关联依赖,暴露出以及authKeys ,hasAuth函数

export function useAuth(){
    // 取出权限   ps: 这里从redux当中取
    const authData = useSelector((state:any)=>state.login)
    // 取出权限keys
    const authKeys = useMemo(()=>authData.auth.components ?? [],[authData])
    // 是否拥有权限
    const hasAuth = useCallback(
        (auths:string[]|string)=>(
            turnIntoList(auths).every(auth=>authKeys.includes(auth))
        ),
        [authKeys]
    )
    const ret:[typeof authKeys,typeof hasAuth] = [authKeys,hasAuth]
    return ret
}

使用

const ProductList: React.FC = () => {
    // 引入
    const [, hasAuth] = useAuth();
    // 计算是否有权限
    const authorized = useMemo(() => hasAuth("edit"), [hasAuth]);
    
    // ...略
    return (
        <>
            { authorized ? <button> 编辑按钮(权限)</button> : null}
        </>
    )
};

权限包裹组件

可以跟进一步,依据这个权限hook,封装一层包裹组件

const AuthWrapper:React.FC<{auth:string|string[],children:JSX.Element}> = ({auth, children})=>{
    const [, hasAuth] = useAuth();
    // 计算是否有权限
    const authorized = useMemo(() => hasAuth(auth), [hasAuth]);
    // 控制显隐
    return authorized ? children : null
}

使用

<AuthWrapper auth='edit'>
    <button> 编辑按钮(AuthWrapper) </button>
</AuthWrapper>

还可以利用renderProps特性

const AuthWrapper:React.FC<{auth:string|string[],children:JSX.Element}> = ({auth, children})=>{
    const [, hasAuth] = useAuth();
    // 计算是否有权限
    const authorized = useMemo(() => hasAuth(auth), [hasAuth]);
+    if(typeof children === 'function'){
+       return children(authorized)
+    }
    // 控制显隐
    return authorized ? children : null
}
<AuthWrapper auth='edit'>
    {
        (authorized:boolean)=> authorized ? <button> 编辑按钮(rederProps) </button> : null
    }
</AuthWrapper>

动态权限控制

这种主要是通过动态获取到的权限标识,来控制显隐、样式等。可以根据特定场景做特定的封装优化。主要逻辑其实是在后端处理。

结尾

可以看到在两大框架下实现权限控制时,思路和细节上还是稍稍有点不一样的,React给人的感觉是手上的积木更加零碎的一点,有些功能需要自己搭起来。相反Vue给人的感觉是面面俱到,用起来下限会更高。

最后

如果大家有什么想法和思考,欢迎在评论区留言~~。

另外:本人经验有限,如果有错误欢迎指正。