vue-router 动态路由权限分配

1,913 阅读4分钟

动态路由技术分享

鉴权的两种常规方式

  1. 后端生成当前用户相应的路由后由前端(用 Vue Router 提供的 API)addRoutes 动态加载路由。

  2. 前端写好所有的路由,后端返回当前用户的角色,然后根据事先约定好的每个角色拥有哪些路由对角色的路由进行分配。

两种鉴权方式的分析

  1. 第一种,完全由后端控制路由,但这也意味着如果前端需要修改或者增减路由都需要经过后端同学的同意。

  2. 第二种,相对于第一种,前端相对会自由一些,但是如果角色权限发生了改变就需要前后端一起修改,而且如果某些用户在前端修改了自己的角色权限就可以通过路由看到一些本不被允许看到的页面,虽然拿不到数据,但是有些页面还是不希望被不相关的人看到。

为什么使用动态路由?

  1. 前端鉴权不够灵活,线上版本每次修改权限页面,都需要重新打包项目。

  2. 后端会根据当前用户权限动态返回路由结构,前端不再需要考虑权限问题。

  3. 中小型项目前端鉴权明显更加好用,开发维护成本更低, 但是对于权限等级很多,并且比较大的项目,维护这一套鉴权路由,毫无疑问是一个大工程,并且面对频繁变更的需求,前端工程师工作量大大增加,这时候前端鉴权就不再是好的方案。

  4. 动态路由是一种新的思路,路由配置还是由前端完成,仅仅将数据状态交给了后端,不同角色的路由显示交给后端控制,前端不需要管理路由,最多只需要处理权限颗粒化的问题。

简单理解

个人认为 addRoutes 可以理解为向现有的路由后面添加新的路由,所以在 addRoutes 之前我们需要初始化一些不需要权限的路由页面,比如登录页、首页、404 页面等,这个过程很简单,就是向路由文件里面加入静态路由就行了。

需要前端设计后端路由表,确定前后端交互的数据格式。

实现思路

  1. 后端返回一个 json 格式的路由表。
  2. 因为后端传回来的字段是都是字符串格式的,vue-router需要一个组件对象,需要动态加载的方法,将字符串转换为组件对象。
  3. 利用 vue-router 的 beforeEach、addRoutes以及vuex 来配合实现效果。
  4. 左侧菜单栏根据拿到转换好的路由列表进行渲染。

实现方案

  1. 登录时获取 token 保存到本地,接着前端会携带 token 再调用获取用户信息的接口获取当前用户的角色信息。
  2. 前端再根据当前的角色计算出相应的路由表拼接到常规路由表后面。

动态路由访问过程流程图

image.png

代码实现

const whiteList = ['/login', '/register'] // 路由白名单,不会重定向
// 全局路由守卫
router.beforeEach(async(to, from, next) => {
  NProgress.start() //路由加载进度条
  // 设置 meta 标题
  document.title = getPageTitle(to.meta.title)
  // 判断 token 是否存在
  const hasToken = getToken()
  if (hasToken) {
    if (to.path === '/login') {
      // 有 token 跳转首页
      next({ path: '/' })
      NProgress.done()
    } else {
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          // 获取动态路由,添加到路由表中
          const { roles } = await store.dispatch('user/getInfo')
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
          router.addRoutes(accessRoutes)
          // 使用replace访问路由,不会在history留下记录,登录到dashbord时回退空白页面
          next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
        } catch (error) {
          next('/login')
          NProgress.done()
        }
      }
    }
  } else {
    // 无 token
    // 白名单不用重定向 直接访问
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      // 携带参数为重定向到前往的路径
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

动态路由数据格式处理

/**
 * @desc: 解析原始路由信息(路由之间通过pid确定上下级)并动态添加路由及跳转页面
 * @param {Array} menus - (从后端获取的)菜单路由信息
 * @param {String} to - 解析成功后需要跳转的路由路径
 * @example
 * // 引入parse_routes
 **/
const menus = [ // 由后端传入
  { "id": 1, "pid": 0, "path": "/receipt", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
  { "id": 2, "pid": 1, "path": "index", "name": "Receipt", "component": "receipt/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"收款管理\", \"icon\": \"receipt\"}" },
  { "id": 3, "pid": 0, "path": "/payment", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
  { "id": 4, "pid": 3, "path": "index", "name": "Payment", "component": "payment/index", "redirect": "", "hidden": "false", "meta": "{\"title\": \"付款管理\", \"icon\": \"payment\"}" },
  { "id": 5, "pid": 0, "path": "/crm", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
  { "id": 6, "pid": 5, "path": "index","name": "Crm", "component": "crm/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"客户管理\", \"icon\": \"people\"}" },
  { "id": 7, "pid": 0, "path": "/upload_product", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": ""},
  { "id": 8, "pid": 7, "path": "index","name": "productUpload", "component": "productUpload/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"测评商品上传\", \"icon\": \"upload\"}" }
]

重组路由对象

// 初始化路由信息对象
const menusMap = {}
menus.map(v => {
  const { path, name, component, redirect, hidden, meta } = v
  // 重新构建路由对象
  const item = {
    path,
    name,
    component: () => import(`@/views/${component}`),
    redirect,
    hidden: JSON.parse(hidden)
  }
  meta.length !== 0 && (item.meta = JSON.parse(meta))
  // 判断是否为根节点
  if (v.pid === 0) {
    menusMap[v.id] = item
  } else {
    !menusMap[v.pid].children && (menusMap[v.pid].children = [])
    menusMap[v.pid].children.push(item)
  }
})

// 将生成数组树结构的菜单
const routes = Object.values(menusMap)
// 默认路由拼接生成的路由(注意顺序)
const integralRoutes = defRoutes.concat(routes)

Router.options.routes = integralRoutes
Router.addRoutes(routes)
Router.push({ path: to })

需要注意的点

需要注意的是 通过 addRoutes 合并的路由 不会被 this.$router.options.routes 获取到,所以需要将获取的路由拼接到 this.$router.options.routes 上

侧边菜单栏渲染用拼接好的路由数据据即可

整体流程图解

image.png

坑点 1:跳转页面后 404

在成功动态添加路由后,改变地址栏或者刷新页面,你会发现页面跳到了404页面

解决:就是不在初始化路由的时候初始化 404 路由,而是在解析接收到的路由数据时拼接路由即可解决问题。

坑点 2: 刷新页面路由失效

解决了 404 的问题后,再次刷新页面会发现页面变空白了,这是因为刷新页面 router 实例会重新初始化到初始状态。

解决:我们在获取到后端数据的时候将之存入 vuex 和 浏览器缓存(比如 sessionStorage) 中。注意,这里是将获取到的数据直接存入,因为 sessionStorage 只能存字符串,而我们在转换格式的过程中是需要解析某些字段,例如 component, meta