前端权限开发从设计到实践

c812890a2125908a6abb50b0a10ba637.jpeg

关于本文

作者:守夜人xhttps://juejin.cn/post/7259210874446692411

1.权限控制的方案选择。

做后台项目区别于做其它的项目,权限验证与安全性是非常重要的,可以说是一个后台项目一开始就必须考虑和搭建的基础核心功能。在后台管理系统中,实现权限控制可以采用多种方案:

权限方案类型描述
基于角色的访问控制(Role-Based Access Control,RBAC)是一种广泛采用的权限控制方案。系统中定义了不同的角色,每个角色具有一组权限,而用户被分配到一个或多个角色。通过控制用户角色的分配,可以实现对用户访问系统中不同功能和资源的权限控制。
基于权限的访问控制(Permission-Based Access Control)这种方案将权限直接分配给用户,而不是通过角色来管理。每个用户都有自己的权限列表,控制用户对系统中各项功能和资源的访问。
基于资源的访问控制(Resource-Based Access Control,RBAC)这种方案将权限控制与资源本身关联起来。系统中的每个资源都有自己的访问权限,用户通过被授予资源的访问权限来控制其对资源的操作。
层次结构权限控制(Hierarchical Access Control)这种方案基于资源和操作的层次结构来进行权限控制。系统中的资源和操作被组织成层次结构,用户被授予访问某个层次及其子层次的权限。
基于规则的访问控制(Rule-Based Access Control)这种方案使用预定义的规则来确定用户对系统中功能和资源的访问权限。规则可以基于用户属性、环境条件或其他因素进行定义。

这里我选择了基于角色的访问控制(Role-Based Access Control,RBAC) 这是因为RBAC提供了一种灵活且易于管理的方式来控制用户对系统功能和资源的访问,也是目前最主流的前端权限方案选择。

在RBAC中,系统中的功能和资源被组织成角色,而用户则被分配到不同的角色。每个角色都有一组权限,定义了该角色可以执行的操作和访问的资源。通过给用户分配适当的角色,可以实现对用户的权限控制。

RBAC的好处之一是它简化了权限管理的复杂性。管理员只需管理角色和分配角色给用户,而不需要为每个用户单独定义权限。当需要对用户的权限进行修改时,只需调整其角色的权限即可。

此外,RBAC还支持灵活的权限组合,允许创建具有不同权限组合的角色,以适应不同用户的需求。它也便于扩展,可以随着系统的发展和需求的变化而调整和添加角色。

2.RBAC下的权限字段设计与管理模型

1.用户权限授权

是对用户身份认证的细化。可简单理解为访问控制,在用户身份认证通过后,系统对用户访问菜单或按钮进行控制。也就是说,该用户有身份进入系统了,但他不一定能访问系统里的所有菜单或按钮,而他只能访问管理员给他分配的权限菜单或按钮。
主要包括:

  • Permission(权限标识、权限字符串):针对系统访问资源的权限标识,如:用户添加、用户修改、用户删除。

  • Role (角色):可以理解为权限组,也就是说角色下可以访问和点击哪些菜单、访问哪些权限标识。

权限标识或权限字符串校验规则:

  • 权限字符串:指定权限串必须和菜单中的权限标识匹配才可访问

  • 权限字符串命名规范为:模块:功能:操作,例如:sys:user:edit

  • 使用冒号分隔,对授权资源进行分类,如 sys:user:edit 代表 系统模块:用户功能:编辑操作

  • 设定的功能指定的权限字符串与当前用户的权限字符串进行匹配,若匹配成功说明当前用户有该功能权限

  • 还可以使用简单的通配符,如 sys:user:*,建议省略为 sys:user(分离前端不能使用星号写法)

  • 举例1 sys:user 将于 sys:user 或 sys:user: 开头的所有权限字符串匹配成功

  • 举例2 sys 将于 sys 或 sys: 开头的所有权限字符串匹配成功 这种命名格式的好处有:

  1. 可读性和可理解性:使用模块、功能和操作的格式可以直观地表达权限的含义。每个部分都有明确的作用,模块表示特定的模块或子系统,功能表示模块内的某个功能或页面,操作表示对功能进行的具体操作。通过这种格式,权限名称可以更容易地被开发人员、管理员和其他人员理解和解释。

  2. 可扩展性和灵活性: 通过使用模块、功能和操作的格式,可以轻松地扩展和管理权限。每个模块、功能和操作都可以被单独定义和控制。当系统需要增加新的功能或操作时,可以根据需要添加新的权限字符串,而不需要修改现有的权限规则和代码。

  3. 细粒度的权限控制: 这种格式支持细粒度的权限控制,可以针对特定的功能和操作进行权限管理。通过将权限名称拆分为模块、功能和操作,可以精确地定义哪些用户或角色具有访问或操作特定功能的权限。

  4. 避免权限冲突: 使用模块、功能和操作的格式可以避免权限之间的冲突。不同模块、功能和操作的权限名称是唯一的,这样可以避免同名权限之间的混淆和冲突。

2.权限管理模型

关键数据模型如下:

  • 用户:登录账号、密码、角色

  • 角色:角色名称、角色权限字符、对应菜单、对应菜单下的权限

  • 菜单:菜单名称、菜单URL、菜单类型

  • 用户角色关系:用户编码、角色编码

  • 角色菜单关系:角色编码、菜单编码

关系图如下:

rust
复制代码
【用户】  <---多对多--->  【角色】  <---多对多--->  【菜单/权限】

3.实现思路与步骤

前端权限一般分为路由级权限和按钮级权限,这里我们先实现页面路由级的权限功能,按钮级的会在后面讲到。大致的思路如下图:

216637b61ef04eefb434f50bc3591a80.png 上图是用户从登录-->路由守卫-->权限验证-->构建路由表-->跳转目标页面的一个简单的正向流程,可以看到,权限验证构建路由表这两步是发生在路由守卫这一步里,而实际上在开发设计阶段,我们还要做的准备有:

  1. 与后端确认权限字段及类型

  2. 确定路由表信息是由前端还是后端生成

为了方便理解,我们就按照这个正向流程来逐步实现,期间需要用到或者提前考虑设计到的内容,包括以上需要准备的两点,我会补充在步骤当中。

1.登录

登录成功后,获取到token,将token存到本地的sessionStorage里。

action.js
复制代码
  async login({ commit }, userInfo) {
    const { data } = await login(userInfo)
    const accessToken = 'Bearer ' + data.access_token
    if (accessToken) {
      sessionStorage.getItem(tokenTableName)
    } else {
      message.error(`登录接口异常,未正确返回${tokenName}...`)
    }
  }

接着,进行路由跳转到首页

login.vue
复制代码
import { useRouter } from 'vue-router';
const router = useRouter(); 
router.push('/');

如果考虑到页面token失效后,重新登陆后返回原先的路由地址,则需要在路由守卫中添加redirect字段用来存储当前的路由地址

permissions.js
复制代码
 next({ path: '/login', query: { redirect: to.fullPath }, replace: true })

然后在登录页中,监听路由对象中的这个值,如果有值,那么在刚刚路由跳转时,就跳转到该路由地址,而非/首页,另外,还需要考虑到403,404页面。所以,添加了这些逻辑后,部分代码如下:

js
复制代码
//login.vue
<script setup>
import { ref, watch, useRouter } from 'vue';
import { useRouter } from 'vue-router';
const redirect = ref('/');
const router = useRouter();

watch(
  () => router.currentRoute.value.query.redirect,
  (redirectValue) => {
    redirect.value = redirectValue || '/';
  },
  { immediate: true }
);

function handleRoute() {
        return redirect.value === '/404' || redirect.value === '/403'
          ? '/'
          : redirect.value
      },
      
 async handleSubmit() {
        loading.value = true
        try {
          await login(form.value)
          loading.value = false
          router.push(handleRoute());
        } catch {
         loading.value = false
        }
      },      
</script>
2.路由守卫与权限校验

当进行路由跳转时,会进入到路由守卫中,在路由守卫中:

  1. 路由守卫会首先判断有没有token,如果没有,先判断要去的路由地址是否包含在路由白名单内,如果要去的路由地址也不在路由白名单内,就会让用户跳转到登录页重新登陆。

  2. 如果有token,会先判断跳转的目标地址是否是登录页,如果是,则重新跳转到默认首页

  3. 此时,我们就需要对用户权限进行校验,首先,判断当前用户是否拥有角色信息,如果没有,就要获取用户的角色信息。

3.获取角色信息与权限信息

调取用户信息接口获取用户角色信息和权限信息,代码如下:

js
复制代码
 //store/user
 // 获取用户信息
  GetInfo({ commit, dispatch }) {
    return new Promise((resolve, reject) => {
      getUserInfo()
        .then(async (response) => {
          const result = response.data
          if (result.roles && result.roles.length > 0) {
            // 验证返回的roles是否是一个非空数组
            commit('SET_ROLES', result.roles)
            //permissions就是对应的用户权限信息
            commit('SET_PERMISSION', result.permissions)
          } else {
            // 如果当前用户没有角色,则赋值一个默认角色
            commit('SET_ROLES', ['ROLE_DEFAULT']) 
          }
          resolve(result)
          // GetInfo一旦失败就说明这个token不是过期就是丢失了,直接走catch并让调用方跳转路由
          if (!response.success) {
            reject(response.errorMsg)
          }
        })
        .catch((error) => {
          reject(error)
        })
    })
  },

根据之前上文提到的权限管理模型,从之间的关系是多对多,因此,role(角色)permssions(权限)的类型应为数组,其中的权限permssion这个字段的格式规范也在上文提及。在与后端约定好后,后端返回的信息如图:

fe9c5cdf3a204fc09881ec2a9b5b2927.png

ecf381e95499baf73b59eeddc2b4f3d4.png

4.谁来生成动态路由表?

在成功获取到用户信息,拿到用户角色和对应权限后,此时,就需要根据权限生成对应的路由表了,到这里,我们需要思考一个问题:
路由表是由后端提供还是由前端提供?
A:前端根据权限生成路由表
B:后端生成路由表给前端

没错!答案是C:路由表可以由后端提供或由前端提供。 165c7eef1757e03626798e0ad85a1927.jpeg

两者的优劣分别是:

  1. 后端提供路由表:

  • 前后端耦合度高:由于路由表由后端提供,前端开发人员可能需要与后端开发人员密切协作,增加了协调和沟通的成本。

  • 前端依赖后端:前端应用程序可能需要等待后端提供路由表后才能进行开发和测试,增加了开发的时间和依赖性。

  • 安全性高:后端负责验证和控制权限,可以确保只有授权的用户能够访问特定的路由和功能。

  • 隐藏敏感信息:后端可以根据用户的角色和权限隐藏不应该被访问的敏感路由和数据。

  • 适用于复杂的权限规则:后端可以使用更复杂的逻辑和规则来处理权限控制,例如基于用户角色、用户组、权限等的复杂控制逻辑。

  • 优点:

  • 缺点:

前端提供路由表:

  • 安全性较低:前端提供的路由表容易受到篡改和绕过,安全性相对较低。

  • 隐藏敏感信息的难度增加:前端无法直接隐藏敏感路由和数据,需要依赖后端接口的授权验证来保护敏感信息。

  • 前后端解耦:前端可以独立开发和维护路由表,减少了与后端的依赖性和协调成本。

  • 更好的用户体验:前端可以根据用户的角色和权限动态展示路由和功能,提供更灵活和个性化的用户体验。

  • 优点:

  • 缺点:

最终,考虑到后台管理系统的安全性优先级最高,我选择了由后端存储,并生成返回路由表信息。确定好了之后,你就会遇到一个坑:后端提供的路由表无法直接在前端路由中添加使用 这是因为,后端存储的路由表的结构只一个JSON对象。怎么解决,我们放到后面讲。

5.公共路由和动态路由

我们现在把整个路由分为两个部分,分别是公共路由动态路由(私有路由)

公共路由

公共路由顾名思义就是无论当前用户是什么角色,都会出现的路由部分。一般这样的路由分别有:首页驾驶舱、登录注册页、403页、404页。 这部分路由是在前端项目中提前写死的。
以我的项目为例,我在src/config下创建一个publicRouter.js文件,然后里面放入基础路由信息:

javascript
复制代码
//基础公共路由
export const constantRouterMap = [
{
 path: '/',
 name: '',
 component: () => import('@/layout'),
 redirect: '/homePage',
 children: [
   {
     path: '/homePage',
     name: 'homePage',
     component: () => import('@/views/homePage/index.vue'),
   },
 ],
},
{
 path: '/login',
 component: () => import('@/views/login'),
},
{
 path: '/403',
 name: '403',
 component: () => import('@/views/403'),
},
{
 path: '/:pathMatch(.*)*',
 component: () => import('@/views/404'),
}]

这里面都是路由懒加载的写法,这个没啥好说的。需要注意的是在vue3中,vue-router的版本是4.x以上,在跳转404页面时,如果你的写法是

css
复制代码
{
 path: "/404",
 name: "notFound",
 component:  () => import('@/views/404')
}

这样写会提示报错,这是因为vue-router4.x的版本官方推荐引入了这种新写法,配置一个通配符路由,匹配所有未被其他具体路由匹配的路径。其中 :pathMatch 是参数名,而 (.*)* 是参数的匹配模式,它使用正则表达式来匹配任意路径。

动态路由(私有路由)

上文提到的后端返回给前端当前用户的路由表信息,其实就是私有路由,这部分的路由是动态的。那么我们只需要把公共路由+动态路由=当前角色用户完整路由表。然鹅,后端返回给前端的动态路由是无法直接添加到当前页面路由中的,这是因为在浏览器的前后端请求当中,信息的返回体都是以JSON字符串的数据交换格式进行传输,而JSON字符串是不支持函数的。为什么要提到函数形式呢?
因为当我们在路由表使用懒加载的写法时,component的值是一个异步函数。也只有异步函数才能实现对路由的异步懒加载。import()会返回一个promise,这个 promise 最终会加载对应的组件模块。在格式上表现为一个func函数,因此,我们无法将compnent的值传给后端存储
但是办法总比困难多,我们可以将对应的路径字符串传给后端存储,然后再通过后端返回的路径字符串转换成这种箭头函数的写法。
除了这个需要转换以外,我们还要考虑一些问题:

  1. 比如,转换后的路由结构要符合router中的路由格式,否则会在调用router.addRoutes时报错,添加失败。

  2. 添加一些其他自定义属性(例如添加导航栏菜单图标、某个路由的显隐、路由缓存、跳转外链接、...),以满足特定需求。
    以我项目中的的路由示例,直接上代码看:

json
复制代码
{
    "path": "/",  //path,路由的路径,即访问该路由时的 URL 路径。
    "name": "homePage",//name,路由的名称,用于在代码中标识路由。通常用于编程式导航。
    "hidden": false,//hidden,是否隐藏路由,在Vue Router 4.0 中被废弃。
    "component": "Layout"//路由对应的组件,可以是通过懒加载方式导入的异步组件,或直接引入的同步组件。
    "meta": {//meta,路由元信息,可以用于存储一些额外的信息,比如页面标题、权限等
        "title": "首页",   //路由标题
        "icon": "icon-Home", //路由图标
        "target": "", //是否跳转新页签
        "permission": "homePage",//权限
        "keepAlive": false//是否缓存
    },
    "redirect": "/homePage",//重定向
    "fullPath": "/",//完整的url路径,会带上?后面的参数
    "children": [//子路由
        {
            "path": "/homePage",
            "name": "homePage",
            "meta": {
                "title": "首页",
                "icon": "",
                "target": "",
                "permission": "homePage",
                "keepAlive": false
            },
            "fullPath": "/homePage"
        }
    ]
}

考虑完这些之后,我们就开始拿着后端返回的路由表信息动手转换吧!先看看后端返回给前端的路由表格式内容

e57ea877f5919b43e17fa68a65123c1e.png

b4ef58295259f987f2489b8bd4cf018e.png

f7b3253103f91e0beafc2d57e21b109c.png 这里你只需要注意component这个字段的值就行了。
可能有人会问:“那我给后端传的路由表是什么格式的,也是这样吗?”
答案是:当然不是!RBAC权限方案中,用户可以自己配置权限,也就是会有对应的用户管理,角色管理,菜单(权限)管理这三个基本的模块,给后端传的是对应模块的修改配置内容。前端是不需要关心传给后端什么格式。 有点啰嗦了,总之,就是你指着上文这三张图片,让后端给你生成出来就完事了。后端要是说办不到,那就是后端的问题。
d5ffd2a4a71da85e74ccf0aceded2528.jpeg

6.获取动态路由(私有路由)并转换

接下来,这一步是整个前端权限中最重要的一步。在src/router下,我们新建一个generatorRouters.js文件。里面的内容是:

js
复制代码
//getCurrentUserNav获取动态路由的接口
import { getCurrentUserNav } from '@/api/user'
//validURL是一个正则判断方法,用来校验当前字符串是否符合url外链格式
import { validURL } from '@/utils/validate'
// 前端路由表
const constantRouterComponents = {
  // 基础页面 layout 必须引入
  Layout: () => import('@/layout'),
  403: () => import('@/views/403'),
  404: () => import('@/views/404'),
}


/**
 * 动态生成菜单
 * @param token
 * @returns {Promise<Router>}
 */
export const generatorDynamicRouter = (token) => {
  return new Promise((resolve, reject) => {
    getCurrentUserNav()
      .then((res) => {
       //接收后端返回的路由表,结构是个数组
        let menuNav = res.data
        //将路由表存到本地临时缓存中
        //这一步是为了刷新的时候不需要再调接口,增加用户体验的,没这方面需求可以不用写
        sessionStorage.setItem('generateRoutes', JSON.stringify(menuNav))
        //转化路由格式
        const routers = generator(menuNav)
        resolve(routers)
      })
      .catch((err) => {
        reject(err)
      })
  })
}

/**
 * 格式化树形结构数据 生成 vue-router 层级路由表
 *
 * @param routerMap //当前路由表route
 * @param parent//当前路由表route的父级component
 * @returns {*}
 */
export const generator = (routerMap, parent) => {
  return routerMap.map((item) => {
    const { title, show, hideChildren, hiddenHeaderContent, icon, hidden } =
      item.meta || {}
    if (item.component) {
      // Layout ParentView 组件特殊处理
      //这里是对父组件/布局组件的处理,因为有可能出现嵌套布局和多种布局的情况,这个时候需要进行处理。
      if (item.component === 'Layout') {
        item.component = 'Layout'
      } else if (item.component === 'ParentView') {
        item.component = 'BasicLayout'
        item.path = '/' + item.path
      }
    }
    if (item.isFrame === 0) {
      item.target = '_blank'
    }
    const currentRouter = {
      // 如果路由设置了 path,则作为默认 path,否则 路由地址 动态拼接生成如 /dashboard/workplace
      path: item.path || `${(parent && parent.path) || ''}/${item.key}`,
      // 路由名称,建议唯一
      // name: item.name || item.key || '',
      name: item.name || item.key || '',
      // 该路由对应页面的 组件 :方案1
      // 该路由对应页面的 组件 :方案2 (动态加载)
      //这里就是将component的字符串值转换成懒加载的异步函数
      //同时,如果当前路径并没有对应的组件,catch捕获报错,然后跳转到404页,这一步很重要,否则
      component:
        constantRouterComponents[item.component || item.key] ||
        (() =>
          import(`@/views/${item.component}`).catch(() =>
            import('@/views/404')
          )),
      hidden: item.hidden,
      // redirect: '/' + item.path || `${parent && parent.path || ''}/${item.key}`,
      // meta: 页面标题, 菜单图标, 页面权限(供指令权限用,可去掉)
      meta: {
        title: title,
        icon: icon,
        hiddenHeaderContent: hiddenHeaderContent,
        target: validURL(item.path) ? '_blank' : '',
        permission: item.name,
        keepAlive: false,
        hidden: hidden,
      },
      //适配本框架的跳转路径
    }
    // 是否设置了隐藏菜单
    if (show === false) {
      currentRouter.hidden = true
    }
    // 修正路径,而antdv-pro的pro-layout要求每个路径需为全路径
    //这一步的修正路径也是因人而异,正常情况下按照我这样拼接就没问题了
    if (!constantRouterComponents[item.component || item.key]) {
      if (parent && parent.path && parent.path !== '/') {
        currentRouter.path = `${parent.path}/${item.path}`
      }
    }
    // 是否设置了隐藏子菜单
    if (hideChildren) {
      currentRouter.hideChildrenInMenu = true
    }
    // 重定向
    item.redirect && (currentRouter.redirect = item.redirect)
    //添加fullPath
    currentRouter.fullPath = currentRouter.path
    // 是否有子菜单,并递归处理
    if (item.children && item.children.length > 0) {
      currentRouter.children = generator(item.children, currentRouter)
    }
    return currentRouter
  })
}
generatorDynamicRouter方法(promise嵌套)

上面的代码有点多,不要觉得麻烦,其实就两个方法,第一个方法generatorDynamicRouter会调取后端接口,获取后端返回的私有路由表信息,返回的是个promise对象,因为需要调接口请求后端数据,并且其他方法依赖这个方法的接口返回值,所以是个异步函数。之所以这么写的另外一个原因,是因为在它之前还有个promise异步方法包着它。
我们都知道:在嵌套的 Promise 中,内部的 Promise 会先执行,然后再执行外部的 Promise。这是由于 JavaScript 的异步执行机制所决定的。 所以这种套娃式的目的只有一个——就是确保外层的异步方法在调用内部异步方法时,能够保证拿到内部异步方法请求成功之后的返回值(也就是将异步方法变成同步方法,这也是Promise主要的作用之一,面试常问的)。

而最外层的异步方法,是放在路由守卫当中直接调用的,由于我们是动态异步加载路由,那么执行的方法肯定也是异步。具体的原因是因为:
如果动态加载路由的方法不是异步的,那么在路由守卫中使用它将导致它立即执行并返回一个 Promise,而不是在路由导航过程中延迟加载。这样会使得在路由守卫中的逻辑无法按预期执行,因为在组件加载完成之前,它依赖的组件可能还没有被加载,导致出现错误或不一致的状态。

从后端获取路由配置往往涉及到网络请求和异步操作,不能保证立即返回所有路由信息。当你从后端获取到路由配置后,需要将其添加到 Vue Router 中,以便在前端应用程序中进行动态路由。在这个过程中,你需要使用异步操作来确保在获取到路由配置后再添加到路由中。

generator方法(递归转化)

言归正传,generatorDynamicRouter方法调取后端接口,拿到后端返回路由表信息后,会紧接着调用第二个generator方法,并将路由表信息当做参数传入。而generator是一个递归方法。这个也很好理解,因为路由信息里都会包含children子路由,需要层层递归,generator内部会对每一个路由进行逐层转化,每一步都在代码中标有注释,我就不再过多赘述了。我们只需要知道:generator的最终目的就是将后端返回的路由表结构转化成符合前端路由格式的路由(听起来有点绕)。
附上格式化好之后的路由信息: 8c35cd29f5c92a4e679b122ecf870a48.png 再对比下格式化之前的样子: 4178aa6ee88fdbd296c9b61f6dc8f031.png 可以看到component从字符串变成了函数,path路径也得到了拼接补充。

7.存储格式化好的动态路由及导航栏菜单信息

上文提到在generatorDynamicRouter异步方法之外还有一层异步方法,其实我是放在了vuex的store下某个模块中的actions里(默认大家都会用vuex)。具体代码如下

js
复制代码
const actions = {
  GenerateRoutes({ commit }) {
    let sidebarList = []
    return new Promise((resolve) => {
      generatorDynamicRouter().then((routers) => {
        //sidebarList是侧边栏渲染数据
        sidebarList = routers
        //routers就是我们要存储到vuex中的动态路由
        commit('set_routers', routers)
        //侧边栏数据存到vuex中
        commit('set_sidebarList', sidebarList)
        resolve()
      })
    })
  },
}
8.添加动态路由

好了,到这一步,我们就拿到了准备好的公共路由私有路由。这个时候,我们再回到路由守卫,回顾一下之前的流程:

  1. 路由守卫先判断token

  2. 没有token去登录拿token(或者是直接去了白名单里)

  3. 拿到token后判断有没有角色信息,没有角色信息就去请求角色信息(调接口)

  4. 拿到角色信息后,再去获取对应角色信息的路由表(调接口)

  5. 将后端返回的路由表信息转换成前端能添加的路由表信息格式

  6. 添加路由

  7. 跳转对应路由页面

路由守卫的代码如下,直接在src/config目录下新建文件permissions.js

js
复制代码

import router from '@/router'
import store from '@/store'
import getPageTitle from '@/utils/pageTitle'
import getInfoRouter from '@/router/getInfoRouter'
import { loginInterception, recordRoute, routesWhiteList } from '@/config'
router.beforeEach(async (to, from, next) => {
  let hasToken = store.getters['user/accessToken']
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      //权限校验
      if (store.getters['user/roles'].length === 0) {
        try {
            //获取用户信息,包括角色信息和权限信息
           await store.dispatch('user/GetInfo')
            //获取动态路由表信息,并对路由表进行格式转化,然后存到vuex中
           await store.dispatch('async-router/GenerateRoutes')
            //从vuex中拿到动态路由表数据
           let accessRoutes = store.getters['async-router/addRouters']
            //循环添加路由
           accessRoutes.forEach((route) => {
            //isHttp方法用来判断是否是外链,如果是外链,就不添加到当前路由表中
            if (!isHttp(route.path)) {
              router.addRoute(route) // 动态添加可访问路由表
            }
          })
          //刷新跳转
          next({ ...to, replace: true })
        } catch (e) {
          //登出
          await store.dispatch('user/Logout')
          //记录登出前的路由地址
          next({ path: '/login', query: { redirect: to.fullPath } })
        }
      } else {
        next()
      }
    }
  } else {
    //判断是否在白名单中
    if (routesWhiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      //记录跳转到登录页之前的路由地址
      next({ path: '/login', query: { redirect: to.fullPath }, replace: true })
    }
  }
})
router.afterEach((to) => {
  //浏览器页签标题
  document.title = getPageTitle(to.meta.title)
})

在vue-router4.x版本中,往当前的路由router对象中动态添加路由时,需要使用官方提供的addRoute方法,不能直接对router对象进行修改,这是因为vue-router必须是要vue在实例化之前就挂载上去的。addRoute方法传入的参数是单个路由对象,所以写法上我们需要对动态路由数组进行for循环,在内部调用addRoute方法,从而达到成功添加多个动态路由的目的。这里附上官方API地址链接:router.vuejs.org/zh/api/inte…

9.遇到的坑

这里面有几个坑需要注意下:

  1. 第一个坑就是在vue3支持的vue-router4.0版本之前,也就是vue2中,动态添加路由的方式支持的是addRoutes,它的参数是格式是一个数组。而到了4.0后,addRoutes这个方法被废弃,取而代之的是addRoute,它的参数则是一个路由对象。这两个方法无论是在传参类型还是添加相同path时的覆盖逻辑都不相同。

  2. 第二个坑就是无论是用控制台还是打断点的方式,或者是用Vue.js devtools的谷歌插件,都无法在路由守卫中获取到添加完成后的最新router对象,也就是说你会在调试addRoute加载后的动态路由表时,发现与之前为添加路由时的路由表是一样的,从而无法判断是否动态路由添加成功(就很蛋疼)

  3. 第三个坑是 next({ ...to, replace: true })这个方法,当添加好动态路由后,如果不这么写next,会白屏。在addRoute()之后第一次访问被添加的路由会白屏,这是因为刚刚addRoute()就立刻访问被添加的路由,然而此时addRoute()没有执行结束,因而找不到刚刚被添加的路由导致白屏。因此需要从新访问一次路由才行。
    具体原因见文章链接:blog.csdn.net/qq_41912398…

  4. 还有一个关于vuex的问题,从之前的代码里不难看出,我们将用户信息,角色、权限、动态路由等都存在了vuex中。由于vuex的特性,用户会在刷新页面后,vuex里的数据会被清空,这个时候就会导致页面直接跳转到登录页。这肯定是不能接受的,因此,我们需要对vuex里的数据在刷新的时候做持久化处理。逻辑也很简单,监听页面刷新事件,beforeunload这个方法恰好就能做到,在页面刷新的时候,将vuex中的数据缓存到浏览器本地临时缓存中,然后再在页面初始化的时候,从本地临时缓存中取出存入vuex对象中。附上代码:(项目根目录下的的App.vue文件中,这里是vue2的写法)

js
复制代码
 created() {
      //在页面加载时读取sessionStorage里的状态信息
      sessionStorage.getItem('userMsg') &&
        this.$store.replaceState(
          Object.assign(
            this.$store.state,
            JSON.parse(sessionStorage.getItem('userMsg'))
          )
        )
      //在页面刷新时将vuex里的信息保存到sessionStorage里
      window.addEventListener('beforeunload', () => {
        sessionStorage.setItem('userMsg', JSON.stringify(this.$store.state))
      })
    },

这里再附上src/router目录下的index.js代码,方便大家理解路由这块:

js
复制代码
import { createWebHistory, createRouter, useRouter } from 'vue-router'
import { constantRouterMap } from '@/config/router.config.js'
import { generator } from '@/router/generatorRouters'

const router = createRouter({
  history: createWebHistory(),
  routes: constantRouterMap,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  },
})
//这里是我为了刷新的时候不调取获取路由信息的接口,做的本地缓存,因为刷新一次就要获取一遍用户信息和
//动态路由表信息太影响用户体验了,尤其是在网速差的情况下。没这方面需求的可以不用管
let asyncRouterMap = []
function handleLocalRouter() {
  if (sessionStorage.getItem('generateRoutes')) {
    asyncRouterMap = asyncRouterMap.concat(
      JSON.parse(sessionStorage.getItem('generateRoutes'))
    )
    const generatedRoutes = generator(asyncRouterMap)
    generatedRoutes.push({
      path: '/:pathMatch(.*)*',
      redirect: '/404',
      hidden: true,
    })
    generatedRoutes.forEach((route) => {
      router.addRoute(route)
    })
  }
}
handleLocalRouter()
export default router
10.结束与优化

当用户要退出时,也就是登出,我们只需要调用登出接口,并在接口成功返回后,重置vuex中的用户信息(token,角色roles,权限permission),同时清空本地缓存的数据就行了。附上代码:

js
复制代码
  /**

   * @description 退出登录
   * @param {*} { dispatch }
   */
  Logout({ commit, dispatch, state }) {
    return new Promise((resolve, reject) => {
      logout()
        .then(async () => {
          await dispatch('resetAll')
          //清空导航tab页签
          this.dispatch('tagsBar/delAllVisitedRoutes', { root: true })
          resolve()
        })
        .catch((error) => {
          reject(error)
        })
        .finally(() => {})
    })
  },
 /**

   * @description 重置accessToken、roles、permission等
   * @param {*} { commit, dispatch }
   */
  async resetAll({ dispatch, commit }) {
    await dispatch('setAccessToken', '')
    commit('SET_ROLES', [])
    commit('SET_PERMISSION', [])
    sessionStorage.clear()
  },

好,至此,整个RBAC的前端权限方案设计到实现就已经宣告完成,其实还有很多需要优化的地方,比如把用户路由表信息缓存到本地临时缓存中,这样用户刷新的时候就不会因为vuex的特性需要再去请求一边接口。还比如

js
复制代码
         await store.dispatch('user/GetInfo')
           await store.dispatch('async-router/GenerateRoutes')
           let accessRoutes = store.getters['async-router/addRouters']
           accessRoutes.forEach((route) => {
            //用来判断是否是外链
            if (!isHttp(route.path)) {
              router.addRoute(route) // 动态添加可访问路由表
            }
          })

这段代码可以抽离出来,单独封装。另外还有侧边导航栏的处理,这个我没讲,一方面是相对简单,我们获取到格式化之后的动态路由数据的同时,其实也就是获取到了导航栏的菜单信息。 另一方面,每个人的项目页面布局方式不一样,导航栏的设计也会不一样。在此基础上,针对性的对数据进行修饰和改造即可。
不啰嗦了,我写的比较匆忙,也有些地方我可能没有考虑到,看到这里的小伙伴,或者对着文章实践的小伙伴们,欢迎你们提出自己的意见和看法,我会加以订正,不足之处,还请海涵~~ 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值