原文地址: http://www.linzichen.cn/article/1581289405333635072
在一些常见的 RBAC 系统中,对于角色和权限的管理是极其重要的。一个人可以拥有多个角色
,而一个角色又会被赋予多个权限
。不同的角色在登录后台系统后,看到的系统菜单也是不同的。在前后端不分离的项目中,后端可以整合 Spring Security
、Shiro
等安全框架对页面元素进行标签化管理。但是随着前后分离模式的普及,我们现常常把展示逻辑放到前端来完成。本文就详细聊一聊在 vue 中如何实现对于菜单的动态控制。
两种方案
目前项目中对于角色菜单的控制,常见的有两种方案。
1、前端记录所有路由,通过角色动态控制
思路:
前端在 router.js 中,定义出所有页面的路由,在每个 router 节点上,我们给增加一个角色属性,例如:
由上图发现,我们自定义了一个属性 roles
, 用户在登录系统后,后端返回的用户信息中,就有用户的角色。前端需要判断每个路由节点的 roles 数组中,是否包含这个角色,如果包含,就保留此router,否则就过滤掉。最后前端只需要将过滤完成后的路由展示在页面上即可。
优缺点:
此种方法优点是比较简单易实现,在角色固定的情况下可以考虑采取。但缺点是不灵活,如果后期增加了新的角色,我们需要修改代码。所以此种方式不是本文聊的重点
2、后端返回路由数据,前端动态生成
此方案也是目前项目中最常用的,但是需要跟后端有较强的约定性。
2.1 思路分析
由于路由是动态生成的,所以我们先看一下如果要生成一个路由,需要哪些条件,我们一个常见的路由节点如下:
我们发现,一个基本的路由节点,包含 path
、name
、component
、meta
属性,如果是多级菜单,那么还需要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-template
,gitee地址。
前端项目页面组件位置如下:
需求是我们希望红框中的路由是动态加载的。
2.2.1 定义公共路由
在 router/index.js
中已经帮我们写了很多路由信息,为了跟上图路由保持一直,简化为以下(path为*的路由一定放最后):
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
中:
而在 src/permission.js
文件中,框架配置了 路由守卫
,里面的逻辑也不复杂,这里简单说一下。
去查看用户登录的逻辑,发现会在登录成功后,在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 中获取。
至此,我们动态生成路由的代码就已经实现了。
常见问题
1. 用 require 而不用 import
我们在根据字符串生成路由组件的时候,要用 require 而不是 import ,否则会报以下错误信息:
2. 侧边栏消失
在点击动态生成的路由时,页面可以正常跳转,但是测试边不见了。是因为 动态生成的路由没有作为 Layout 组件的子组件,应该添加到 Layout 组件的 children 数组中。
3. 页面无内容
在点击二级菜单的时候,页面是空白的,没有任何内容。以上面案例为例,我们只需要在对应组件的父目录下,新建 index.vue 文件,写入 <router-view />
即可。
4. 二级菜单展示不全
这个问题不属于动态路由的问题,elementui 框架中,如果子菜单只有一个,那么就不会生成多级菜单的形式。