如果大家写过后台管理系统的项目,那么动态路由一定是绕不开的,如果想偷懒的话,就把所有路由一开始都配置好,然后只根据后端返回的菜单列表渲染就好菜单就好了,但是这样的隐患就是我在地址栏输入的地址的时候,也会进入这个页面,不偷懒的方法就是本文要介绍的,真动态路由了,当然不会仅仅只是介绍使用数据怎么换成动态路由添加就好了,会从登录获取token后请求菜单列表…最后注册完成,这一系列流程完整的实现一次,相信对于第一次接触这个案例的朋友会有帮助
在实现我们这个需求,不难想到主要就是完成登录,通过登录获取到正确的菜单列表,通过菜单列表进行渲染
但是完成这个步骤的话,我们还需要捋一下页面的关系,按照我们的开发时态来说,我们启动一个项目之后,会通过 http://localhost:8080/ 这样的一个地址在浏览中打开
打开这个地址之后,触发的是什么路径,是不是 /
,表示根路径,在后台管理系统中,一般这个跟路径我们会映射到什么组件上,是不是 layout 组件,比如这样的,如图:
但是这样的话就和我们的需求有点不一样了,我们要先登录啊,都没登录怎么能打开这个呢?所以一把来说,我们一般会要么把 ‘/’ 的路径触发时,重定向到 ‘/login’,或者在全局路由前置守卫中,通过登录的状态来决定是不是跳转到登录页,一般我们使用第二种,因为后台管理系统中,一定会有路由权限的判断,到时候一样会来改动这个,所以选择后者,至于实现部分,我们后面再看
完成了上述的操作之后,就是登录了,登录之后获取菜单列表数据,拿到之后我们就直接注册吗?
我们知道,这种菜单,往往会有一级、二级、三级等等不同级别的菜单,而是不是每个菜单都需要注册的呢,其实不然,我们需要注册的仅仅是需要展示的那一部分菜单,比如在我们的案例中,设备管理是一个一级菜单,但是存在子级菜单,那么此时这个设备管理菜单就是不需要注册的,如图:
所以这一点我们也需要做一下区分,但是具体注册那些呢?这些就还是要在前端先配置好,但是这个配置不会是直接配置到 route 中,是一个映射关系,比如定义了 a = 组件A,然后依次书写,把所有会展示的页面通过这样的方式,用一个文件存储起来,那么通过后端返回的菜单列表数据时,就可以进行一个对比,筛选,取出符合条件的数据,组装成一个适配业务的 route 进行注册
而通过这样的匹配,我们最后是可以得到一个数组的,[route1, route2, …],得到这个数组之后,使用 vueRouter的 addRoute 方法添加即可
这里需要注意的事情是,我所演示的案例中,所有的子组件都是在 main 区域显示的,所以我就不需要在去单独的关心这些子组件的层级关系了,但是如果某个项目中的,层级关系如图:
像这种或者更多层级的,就需要额外处理一下 children 属性了,但是方法都是差不的,无非就是数据处理的时候多处理一下,而且一般来说就是两层,最外层第一个 router-view 来展示一级路由(比如登录、404、layout),main 区域的 router-view 展示二级路由(比如 home、my、user…)
经过这个分析之后,我们就是能确定,我们要做的事情就是,把这些获取的菜单数据,来找到对应的组件,并把这些组件添加为 layout 组件的子组件,在 main 区域展示
根据上面的粗略的分析,第一步就是创建路由,这一步非常简单,我直接粘贴代码了,如下:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'hash',
routes: []
})
export default router
这就是一个最基础的结构了,而在这个需求中,至少有两个路由一定是静态的,一个是 login,一个是 layout,当然通常还有个一个任意路由,表示 404,这里我就不写了,大家有时间自己添加一下就好,如下:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'hash',
routes: [
{
path: '/',
name: 'layout',
component: () => import('@/layout')
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login')
}
]
})
export default router
添加两个静态路由非常简单吧,然后把这个在 main js 页面引入使用,我就不展示了
上面的配置如果我们直接在浏览器中打开 http://localhost:8080/ 这个地址,那么展示的就是 layout 组件,如果需要展示位 login 组件的话,我们就需要在全局前置路由守卫上动一下手脚了
也非常简单,一个用户登没登录,就是判断是否是存在了 token,如果有就是登录了,如果没有就是没有登录,根据这个,我们可以得出一张关系图,如图:
这只是一个简单的路由权限判断,具体的还需要根据业务来扩展,根据这个关系图,我们可以写出如下代码:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import store from '@/store'
const router = new VueRouter({
mode: 'hash',
routes: [
{
path: '/',
name: 'layout',
component: () => import('@/layout')
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login')
}
]
})
router.beforeEach((to, from, next) => {
const token = store.state.login.token
if (token) {
if (to.path === '/login') {
next(false)
} else {
next()
}
} else {
if (to.path === '/login') {
next()
} else {
next('/login')
}
}
})
export default router
实现这点的方法也不止一种,本文采用的是在 store 的 login 模块中完成登录,至于 axios 的封装或者基于 xhr 等等的请求方面,我这里不做解析了
store 的基础配置不做赘述了,直接粘贴代码,如下:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import login from './login'
const store = new Vuex.Store({
modules: { login }
})
export default store
至于 login 模块的话,书写也非常简单,编写登录函数,登录成功之后同步获取菜单数据,如下:
import { loginApi, menuApi } from '@/api'
import router from '@/router'
export default {
namespaced: true,
state: {
userInfo: {} || localStorage.getItem('user_info'),
token: '' || localStorage.getItem('token'),
menuList: [] || localStorage.getItem('menu_list')
},
mutations: {
SET_MENU_LIST(state, payload) {
state.menuList = payload
},
SET_USER_INFO(state, payload) {
state.userInfo = payload
localStorage.setItem('user_info', JSON.stringify(payload))
},
SET_TOKEN(state, payload) {
state.token = payload
localStorage.setItem('token', payload)
},
// 退出登录
LOG_OUT() {
localStorage.removeItem('token')
localStorage.removeItem('user_info')
localStorage.removeItem('menu_list')
// 刷新页面-因为路由权限的存在会导航到login,并且通过这个刷新可以避免重复添加路由
window.location.reload()
}
},
actions: {
async login({ commit }, payload) {
// 登录请求-获取token
const loginResp = await loginApi.reqLogin(payload)
if (loginResp?.errorCode !== 0) return
commit('SET_USER_INFO', loginResp.data.userInfo)
commit('SET_TOKEN', loginResp.data.token)
// 请求菜单列表
const menuListResp = await menuApi.reqGetMenuList()
localStorage.setItem('menu_list', JSON.stringify(menuListResp.data))
commit('SET_MENU_LIST', menuListResp.data)
// 跳转至首页
router.push('/home')
}
}
}
这部分代码还是非常简单的,在入口文件main.js 引用 store 和在登录界面收集表单数据提交调用这个 login 方法登录,大家就自己实现一下吧
现在我们获取到这个数据之后,表示我们可以完成两件事情,第一就是渲染侧边的菜单列表,第二就是根据这个来添加正确的动态路由
渲染菜单列表没有什么好说的,如果没有菜单栏的递归需求的话,菜单栏直接 cv 组件库的代码即可,需要递归的话就要自己封装一下了
要添加动态路由,需要有两个数据,一个是远程获取的菜单数据,一个是前端的映射的组件关系。远程数据已经有了,前端映射的组件关系,就看你自己的业务来配置了,还是非常简单的,把你前端需要展示的页面都在一个 js 文件引入就好了,如下:
export default [
{
name: 'home',
component: () => import('@/views/home')
},
{
name: 'my',
component: () => import('@/views/my')
},
{
name: 'device-add',
component: () => import('@/views/device/add')
},
{
name: 'device-list',
component: () => import('@/views/device/list')
},
{
name: 'user-add',
component: () => import('@/views/user/add')
},
{
name: 'user-list',
component: () => import('@/views/user/list')
}
]
具体需要多少配置项,就视个人业务而定,我这里使用 name 匹配,你也可以是 path 或者其他属性
在看一下远程的数据具体是什么样的,有助于理解,如下:
[
{
"id": 1,
"name": "home",
"path": "/home",
"nickname": "首页",
"type": 2,
"order": 1,
"parentId": 0,
"icon": "icon-tubiao_shouye-",
"children": null
},
{
"id": 2,
"name": "device",
"path": "/device",
"nickname": "设备管理",
"type": 1,
"order": 2,
"parentId": 0,
"icon": "icon-guanli",
"children": [
{
"id": 3,
"name": "device-list",
"path": "/device/list",
"nickname": "设备列表",
"type": 2,
"order": 1,
"parentId": 2,
"icon": "icon-xuanzeweixuanze",
"children": null
},
{
"id": 4,
"name": "device-add",
"path": "/device/add",
"nickname": "设备添加",
"type": 2,
"order": 2,
"parentId": 2,
"icon": "icon-xuanzeweixuanze",
"children": null
}
]
},
{
"id": 5,
"name": "my",
"path": "/my",
"nickname": "个人中心",
"type": 2,
"order": 3,
"parentId": 0,
"icon": "icon-xiazai",
"children": null
},
{
"id": 6,
"name": "user",
"path": "/user",
"nickname": "用户管理",
"type": 1,
"order": 4,
"parentId": 0,
"icon": "icon-yonghuguanli",
"children": [
{
"id": 7,
"name": "user-list",
"path": "/user-list",
"nickname": "用户列表",
"type": 2,
"order": 1,
"parentId": 6,
"icon": "icon-xuanzeweixuanze",
"children": null
},
{
"id": 8,
"name": "user-add",
"path": "/user-add",
"nickname": "用户添加",
"type": 2,
"order": 2,
"parentId": 6,
"icon": "icon-xuanzeweixuanze",
"children": null
}
]
}
]
剩下的就是递归遍历的找出组装出对应的 route 配置的事情了,那么我们需要有这样的一个函数,来帮助我们完成这件事情,代码如下:
import router from '@/router'
// 前端映射的组件关系配置
import routeConfig from '@/router/route-config'
export default function (menuList) {
const routeList = []
const deepMenu = menuList => {
for (const menu of menuList) {
if (menu.children && menu.children.length > 0) {
deepMenu(menu.children)
} else {
const item = routeConfig.find(item => item.name === menu.name)
if (!item) return
// 去掉第一项斜杠-子路由 path 属性不需要携带开头的 /
const path = menu.path.replace(/^\//, '')
// 路由元信息可以帮助我们完成一些其他操作的时候,需要的一些辅助数据
routeList.push({ ...item, path, meta: { title: menu.nickname } })
}
}
}
deepMenu(menuList)
for (const route of routeList) {
// 遍历添加路由
router.addRoute('layout', route)
}
}
有了这个方法之后,自然就是使用,如下:
import { loginApi, menuApi } from '@/api'
import router from '@/router'
import menuToRoute from '@/utils/menu-to-route'
export default {
namespaced: true,
state: {
userInfo: {} || localStorage.getItem('user_info'),
token: '' || localStorage.getItem('token'),
menuList: [] || localStorage.getItem('menu_list')
},
mutations: {
SET_MENU_LIST(state, payload) {
state.menuList = payload
// 调用菜单转路由方法
menuToRoute(payload)
},
SET_USER_INFO(state, payload) {
state.userInfo = payload
localStorage.setItem('user_info', JSON.stringify(payload))
},
SET_TOKEN(state, payload) {
state.token = payload
localStorage.setItem('token', payload)
},
// 退出登录
LOG_OUT() {
localStorage.removeItem('token')
localStorage.removeItem('user_info')
localStorage.removeItem('menu_list')
// 刷新页面-因为路由权限的存在会导航到login,并且通过这个刷新可以避免重复添加路由
window.location.reload()
}
},
actions: {
async login({ commit }, payload) {
const loginResp = await loginApi.reqLogin(payload)
if (loginResp?.errorCode !== 0) return
commit('SET_USER_INFO', loginResp.data.userInfo)
commit('SET_TOKEN', loginResp.data.token)
// 请求菜单列表
const menuListResp = await menuApi.reqGetMenuList()
localStorage.setItem('menu_list', JSON.stringify(menuListResp.data))
commit('SET_MENU_LIST', menuListResp.data)
// 跳转至首页
router.push('/home')
}
}
}
此时我们已经完成了整个效果的实现,当然还有一个问题,但是这个问题后面再说,先看一下效果,如图:
可以看到,不同的账户登录会因为角色不同展现的菜单也不同
现在我们这个看着没什么问题,是因为我们没有点击刷新,先看看问题,如图:
一旦刷新之后就会导致动态路由清空,但是又没有重新注册添加,自然就会找不到这个路由了,因此白屏就很正常了
解决也非常简单,在每次刷新的时候,都在重新注册一次动态路由就好了,所以在 store 的 login 模块多添加一个方法,如下:
import { loginApi, menuApi } from '@/api'
import router from '@/router'
import menuToRoute from '@/utils/menu-to-route'
export default {
namespaced: true,
state: {
userInfo: {} || localStorage.getItem('user_info'),
token: '' || localStorage.getItem('token'),
menuList: [] || localStorage.getItem('menu_list')
},
mutations: {
SET_MENU_LIST(state, payload) {
state.menuList = payload
menuToRoute(payload)
},
SET_USER_INFO(state, payload) {
state.userInfo = payload
localStorage.setItem('user_info', JSON.stringify(payload))
},
SET_TOKEN(state, payload) {
state.token = payload
localStorage.setItem('token', payload)
},
LOG_OUT() {
localStorage.removeItem('token')
localStorage.removeItem('user_info')
localStorage.removeItem('menu_list')
window.location.reload()
}
},
actions: {
async login({ commit }, payload) {
const loginResp = await loginApi.reqLogin(payload)
if (loginResp?.errorCode !== 0) return
commit('SET_USER_INFO', loginResp.data.userInfo)
commit('SET_TOKEN', loginResp.data.token)
const menuListResp = await menuApi.reqGetMenuList()
localStorage.setItem('menu_list', JSON.stringify(menuListResp.data))
commit('SET_MENU_LIST', menuListResp.data)
router.push('/home')
},
// 加载本地数据
async loadLocal({ commit }) {
const menuList = localStorage.getItem('menu_list')
if (menuList) {
commit('SET_MENU_LIST', JSON.parse(menuList))
}
}
}
}
loadLocal 这个方法还可以初始化一下其他你需要初始化的信息,包括但不限于这个菜单列表,其他是导出这个方法,让其他人使用,可以直接从这个模块使用,也可以其他地方导出,我这里就在 store/index.js 文件下导出,如下:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import login from './login'
const store = new Vuex.Store({
modules: { login }
})
// 导出方法
export function loadLocal() {
store.dispatch('login/loadLocal')
}
export default store
最后在 main.js 中调用此方法即可,导入和使用语句如下:
import { loadLocal } from './store'
loadLocal()
现在我们在来看看效果,如图:
这里只是给大家展示一种思路,具体的实现需要根据自己的业务来定,但是整体的流程都是差不多的
如果对于这个递归菜单,和后端部分这个实现登录逻辑部分,可以查看我后续发布的其他文章,或者如果我没忘记的话,我会来这里补上查看链接