Bootstrap

vue动态生成路由及常见问题

原文地址: http://www.linzichen.cn/article/1581289405333635072

在一些常见的 RBAC 系统中,对于角色和权限的管理是极其重要的。一个人可以拥有多个角色,而一个角色又会被赋予多个权限。不同的角色在登录后台系统后,看到的系统菜单也是不同的。在前后端不分离的项目中,后端可以整合 Spring SecurityShiro等安全框架对页面元素进行标签化管理。但是随着前后分离模式的普及,我们现常常把展示逻辑放到前端来完成。本文就详细聊一聊在 vue 中如何实现对于菜单的动态控制。

两种方案

目前项目中对于角色菜单的控制,常见的有两种方案。

1、前端记录所有路由,通过角色动态控制

思路:

前端在 router.js 中,定义出所有页面的路由,在每个 router 节点上,我们给增加一个角色属性,例如:

roles.png

由上图发现,我们自定义了一个属性 roles, 用户在登录系统后,后端返回的用户信息中,就有用户的角色。前端需要判断每个路由节点的 roles 数组中,是否包含这个角色,如果包含,就保留此router,否则就过滤掉。最后前端只需要将过滤完成后的路由展示在页面上即可。

优缺点:

此种方法优点是比较简单易实现,在角色固定的情况下可以考虑采取。但缺点是不灵活,如果后期增加了新的角色,我们需要修改代码。所以此种方式不是本文聊的重点

2、后端返回路由数据,前端动态生成

此方案也是目前项目中最常用的,但是需要跟后端有较强的约定性。

2.1 思路分析

由于路由是动态生成的,所以我们先看一下如果要生成一个路由,需要哪些条件,我们一个常见的路由节点如下:

router.png

我们发现,一个基本的路由节点,包含 pathnamecomponentmeta 属性,如果是多级菜单,那么还需要children数组属性,在 children中又是同样属性的路由节点对象。所以我们需要跟后端约定好,返回的路由中必须要存在这些字段。

其中有个特殊的字段 component,这个代表 path 路径指向的页面组件。所以 后端在返回这个字段的时候,其值必须要跟前端组件的路径位置保持一致。例如前端有个组件的路径是 @/views/user/index.vue,其中,/user/index 是标记我们这个组件的唯一位置,所以后端返回的 component值中,必须存在 /user/index,这样前端才能根据这个路径,找到对应的组件。

在跟后端约定好规则后,接下来我们看一下具体的实现步骤:

1、在 router.js 中,定义公共的路由。比如登录、首页、404 等路由,因为这些页面是所有角色都会存在的,所以就没有必要后端返回了。

2、用户登录后,随即会跳转首页。所以我们需要在跳转页面之前,调用后端接口,获取该用户的路由信息。而监听路由跳转,一般是在 路由守卫中实现。

3、在路由守卫中,获取用户路由信息,动态生成路由数据,并与 router.js 定义的公共路由数据进行合并。

4、将合并后的路由存储到 vuex或者 sessionStoreate 中。

5、将存储的路由数据遍历展示到页面上。

2.2 代码实现

项目模板是基于花裤衩大神的 vue-admin-templategitee地址

前端项目页面组件位置如下:

views.png

需求是我们希望红框中的路由是动态加载的。

2.2.1 定义公共路由

router/index.js中已经帮我们写了很多路由信息,为了跟上图路由保持一直,简化为以下(path为*的路由一定放最后):

constant.png

2.2.2 获取路由接口数据

假设后端定义 getRouters 为获取路由接口,那么它返回的数据应该是:

[
    {
        "path":"/order",
        "component":"order/index",
        "name":"Order",
        "meta":{
            "title":"订单管理"
        }
    },
    {
        "path":"/user",
        "component":"user/index",
        "name":"User",
        "meta":{
            "title":"用户管理"
        },
        "children":[
            {
                "path":"/user/list",
                "component":"user/components/list",
                "name":"UserList",
                "meta":{
                    "title":"用户列表"
                }
            },
            {
                "path":"/user/consume",
                "component":"user/components/consume",
                "name":"UserConsume",
                "meta":{
                    "title":"消费记录"
                }
            }
        ]
    }
]

2.2.3 动态生成路由

我们从原框架代码的逻辑中不难看出,用户在登录成功后,会跳转到首页,在 views/login/index.vue中:

login.png

而在 src/permission.js 文件中,框架配置了 路由守卫,里面的逻辑也不复杂,这里简单说一下。

路由守卫.png

去查看用户登录的逻辑,发现会在登录成功后,在cookie中存储一条token。框架这里通过 getToken()方法来获取用户token。
如果存在token信息,且如果当前路径是login登录页的话,会强制给回到首页;如果不是login路径,则会从 vuex 中获取用户的基本信息,如果 vuex 中存在则直接放行,否则会重新调用获取用户信息的方法,如果途中发生异常,则会强制返回loing页面。if (hasGetUserInfo) 判断是为了避免重复获取用户信息,因为每次路由跳转都会走这段逻辑,如果不加判断,则每次都会去后端请求。
如果不存在token信息,且访问的路径是非白名单路径,则强制退出返回 login页面。

由以上逻辑可知,我们获取用户路由信息的时机应该在跳转主页面之前,且vuex 中的数据刷新页面后就没有了,页面刷新后会根据当前地址栏的路径进行路由跳转,也会走这段代码,所以应该在这里写我们的逻辑。

src/permission.js 文件代码:

import router from './router'
import store from './store'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
import { getRouters } from '@/api/user'
import { constantRoutes } from '@/router'

NProgress.configure({ showSpinner: false }) // NProgress Configuration

const whiteList = ['/login'] // no redirect whitelist

router.beforeEach(async(to, from, next) => {
  // start progress bar
  NProgress.start()

  // set page title
  document.title = getPageTitle(to.meta.title)

  // determine whether the user has logged in
  const hasToken = getToken()

  if (hasToken) {
    if (to.path === '/login') {
      // if is logged in, redirect to the home page
      next({ path: '/' })
      NProgress.done()
    } else {
      // 我们把最终的路由信息存储在 vuex 中,
      // 这里加判断的原因也是为了避免每次路由跳转时都会重新获取一次
      // 且页面刷新后vuex数据会消失
      if (store.getters.routers.length) {
        // 如果存在路由信息,则直接放行
        next() ;
      } else {
        // 如果不存在,则重新获取
        // getRouters 是定义在 api 中的接口,需要 import 进来
        getRouters(hasToken).then(res => {
          // 生成动态路由节点
          const dynamicRouters = handleRouter(res)
          // 我们需要把动态生成的路由作为 Layout 组件的子路由,而Layout组件在常量路由数组中
          // 倒数第二个元素,所以 constantRoutes[constantRoutes.length - 2] 目的是获取Layout路由节点,
          // 并将动态路由合并到 Layout 的子节点中
          constantRoutes[constantRoutes.length - 2].children.push(...dynamicRouters)
          // 将最终的路由信息保存到 vuex 中,保存完成后,再添加到 router 对象中。
          store.dispatch('router/setRouters', constantRoutes).then(() => {
            router.addRoutes(store.getters.routers)
            
          })
          next()
        })
      }
      
      // const hasGetUserInfo = store.getters.name
      // if (hasGetUserInfo) {
      //   next()
      // } else {
      //   try {
      //     // get user info
      //     await store.dispatch('user/getInfo')

      //     next()
      //   } catch (error) {
      //     // remove token and go to login page to re-login
      //     await store.dispatch('user/resetToken')
      //     Message.error(error || 'Has Error')
      //     next(`/login?redirect=${to.path}`)
      //     NProgress.done()
      //   }
      // }
      
    }
  } else {
    /* has no token*/

    if (whiteList.indexOf(to.path) !== -1) {
      // in the free login whitelist, go directly
      next()
    } else {
      // other pages that do not have permission to access are redirected to the login page.
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

/**
 * 组装动态路由函数
 * @param {*} routerList 
 * @returns 最终的动态路由数组
 */
const handleRouter = (routerList) => {
  const routers = []
  for (const router of routerList) {
    const node = {
      path: router.path,
      component: (resolve) =>  require([`@/views/${router.component}.vue`], resolve),
      name: router.name,
      meta: router.meta
    }
    // 如果当前路由节点存在子路由,需要递归组装数据
    if (router.children && router.children.length) {
      node.children = handleRouter(router.children)
    } 
    routers.push(node)
  }
  return routers
}

router.afterEach(() => {
  NProgress.done()
})

2.2.4 关于route的state

此框架的 vuex 是模块化管理,所以我们在 src/store/modules中新建一个路由的 router.js文件:


const state = {
    routers: []
}
const mutations = {
    SET_ROUTERS: (state, routers) => {
        state.routers = routers
    }
}
const actions = {
    setRouters( { commit }, routers ) {
        return new Promise((resolve) => {
            commit('SET_ROUTERS', routers)
            resolve()
        })
    }
}
export default {
    namespaced: true,
    state,
    mutations,
    actions
}

src/store/index.js中管理 router 的 state:

import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import app from './modules/app'
import settings from './modules/settings'
import user from './modules/user'
import router from './modules/router'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    app,
    settings,
    user,
    router
  },
  getters
})

export default store

因为我们最终菜单获取的路由数据是从 vuex 获取的,所以我们需要在 src/store/getters.js中对外提供路由数据:

const getters = {
  sidebar: state => state.app.sidebar,
  device: state => state.app.device,
  token: state => state.user.token,
  avatar: state => state.user.avatar,
  name: state => state.user.name,
  routers: state => state.router.routers
}
export default getters

2.2.5 从vuex中获取路由,并展示在页面上

根据框架代码,我们发现有关菜单侧边栏的内容,都在 src/layout/components/Sidebar/index.vue组件中,我们把其计算属性中的 routes 改为从 vuex 中获取。

sidebar.png

至此,我们动态生成路由的代码就已经实现了。

demo.gif

常见问题

1. 用 require 而不用 import

我们在根据字符串生成路由组件的时候,要用 require 而不是 import ,否则会报以下错误信息:

notFound.png

2. 侧边栏消失

在点击动态生成的路由时,页面可以正常跳转,但是测试边不见了。是因为 动态生成的路由没有作为 Layout 组件的子组件,应该添加到 Layout 组件的 children 数组中。

3. 页面无内容

在点击二级菜单的时候,页面是空白的,没有任何内容。以上面案例为例,我们只需要在对应组件的父目录下,新建 index.vue 文件,写入 <router-view /> 即可。

4. 二级菜单展示不全

这个问题不属于动态路由的问题,elementui 框架中,如果子菜单只有一个,那么就不会生成多级菜单的形式。

;