前端权限管理之 addRoutes 动态加载路由踩坑

这几天在开发后台管理系统的路由权限,在开始作以前,我查阅了很多资料,发现先后端分离的权限管理基本就如下两种方式:前端

  1. 后端生成当前用户相应的路由后由前端(用 Vue Router 提供的API)addRoutes 动态加载路由。
  2. 前端写好全部的路由,后端返回当前用户的角色,而后根据事先约定好的每一个角色拥有哪些路由对角色的路由进行分配。

两种方法的不一样

第一种,彻底由后端控制路由,但这也意味着若是前端须要修改或者增减路由都须要通过后端大大的赞成,也是我司目前采用的方式;vue

第二种,相对于第一种,前端相对会自由一些,可是若是角色权限发生了改变就须要先后端一块儿修改,并且若是某些(技术型)用户在前端修改了本身的角色权限就能够经过路由看到一些本不被容许看到的页面,虽然拿不到数据,可是有些页面仍是不但愿被不相关的人看到(虽然我我的jio得并无什么关系,可是无奈leader仍是偏向不想被看到不应看到的页面)。git

接下来我主要讲一下第一种方式得作法以及踩的一些坑。github

addRoutes 须要的数据格式

官方文档vuex

router.addRouteselement-ui

函数签名:后端

router.addRoutes(routes: Array<RouteConfig>)
复制代码

动态添加更多的路由规则。参数必须是一个符合 routes 选项要求的数组。api

前端初始化路由

我的认为 addRoutes 能够理解为往现有的路由后面添加新的路由,因此在 addRoutes 以前咱们须要初始化一些不须要权限的路由页面,好比登陆页、首页、404页面等,这个过程很简单,就是往路由文件里面加入静态路由就好了,这里就不赘述了。数组

接下来就是设计后端路由表,肯定先后端交互的数据格式。浏览器

设计后端路由表

字段名 说明
*id id
*pid 父级id
*path 路由路径
name 路由名称
*component 路由组件路径
redirect 重定向路径
hidden 是否隐藏
meta 标识

* 的为必有字段

接收后端生成的路由并解析

经过上面设计的路由表能够发现路由之间时是经过 pid 来肯定上下级的,因此在接收到后端传来的路由数据时咱们须要在前端解析成符合 addRoutes 入参的格式。

在接收到后端生成的路由后经过如下函数进行解析成相应的格式:

parse_routes.js

import Router from '@/router'

/**
 * @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\"}" }
 * ]
 * ParseRoutes(menus, '/payment/index')
 */
 
export default (menus, to = '/') => {
  // 初始路由
  const defRoutes = [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首页', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    },
    {
      path: '/404',
      name: '404',
      component: () => import('@/views/404'),
      hidden: true
    },
    {
      path: '*',
      redirect: '/404',
      hidden: true
    }
  ]

  // 初始化路由信息对象
  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 })
}

复制代码

渲染侧边栏菜单

在成功解析数据以后就须要渲染侧边栏了,我这里参考的是大佬(PanJiaChen)的 element-ui-admin,具体能够参考大佬的代码,这里也再也不赘述了。

若是坚持看到了这里,那么恭喜你,基本就能够经过 addRoutes 动态加载路由了。

接下来就开始讲我在使用 addRoutes 的过程当中遇到的一些坑。(读者内心os: mmp,终于进入正题了~)

重点难点1:跳转页面后404

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

根据咱们上面的路由配置:

[
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首页', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    },
    {
      path: '/404',
      name: '404',
      component: () => import('@/views/404'),
      hidden: true
    },
    {
      path: '*',
      redirect: '/404',
      hidden: true
    }
  ]
复制代码

你会发现咱们在这里面初始化了404路由,因此在路由没有找到强匹配的地址时,就会跳转到404页面。

解决的方法不少,咱们这里只讲一种。

解决方案

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

parse_routes.js

...
// 将生成数组树结构的菜单并拼接404路由
  const routes = Object.values(menusMap).concat(notFoundRoutes)
复制代码

重点难点2:刷新页面路由失效

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

解决方案

咱们在获取到后端数据的时候将之存入 vuex 和 浏览器缓存(我用的是 sessionStorage) 中。注意,这里是将获取到的数据直接存入,由于 sessionStorage 只能存字符串,而咱们在转换格式的过程当中是须要解析某些字段,例如 component, hidden等。

actions.js

...
const menus = data.data.menus
// 将获取到的数据存入 sessionStorage 和 vuex 中
sessionStorage.setItem('_c_unparseRoutes', JSON.stringify(menus))
commit('GET_ROUTES', menus) // 解析函数
ParseRoutes(menus)
复制代码

而后在 App.vue 中的钩子函数 created() 或者 mounted() 中检测 vuex 中的数据是否为空且 sessionStorage 中是否有存入关的数据,并监听页面刷新。

App.vue

...
created() {
  const unparseRoutes = JSON.parse(sessionStorage.getItem('_c_unparseRoutes'))
  if (this.localRoutes.length === 0 && unparseRoutes) {
    const toPath = sessionStorage.getItem('_c_lastPath')
    ParseRoutes(unparseRoutes, toPath) // 解析函数
  }
  // 监听页面刷新
  window.addEventListener('beforeunload', () => {
    sessionStorage.setItem('_c_lastPath', this.$router.currentRoute.path)
  })
}
复制代码

解析函数(完整版)

import Router from '@/router'

/**
 * @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\"}" }
 * ]
 * ParseRoutes(menus, '/payment/index')
 */
 
export default (menus, to = '/') => {
  // 初始路由
  const defRoutes = [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首页', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    }
  ]
  // 404路由
  const notFoundRoutes = [
    { path: '/404', name: '404', component: () => import('@/views/404'), hidden: true },
    { path: '*', redirect: '/404', hidden: true }
  ]
  // 初始化路由信息对象
  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)
    }
  })

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

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

复制代码

写在最后,以上就是我这两天在写权限管理时使用 addRoutes 动态加载路由的方法以及时遇到的一些坑。

第一次写这么长的文章,若是内容有什么不对,望海涵并指出!若是有什么更好的建议也请多多指出!!

若是有喜欢的老铁记得双击加点赞~(开个玩笑)

相关文章
相关标签/搜索