如果大家写过后台管理系统的项目,那么动态路由一定是绕不开的,如果想偷懒的话,就把所有路由一开始都配置好,然后只根据后端返回的菜单列表渲染就好菜单就好了,但是这样的隐患就是我在地址栏输入的地址的时候,也会进入这个页面,不偷懒的方法就是本文要介绍的,真动态路由了,当然不会仅仅只是介绍使用数据怎么换成动态路由添加就好了,会从登录获取token后请求菜单列表…最后注册完成,这一系列流程完整的实现一次,相信对于第一次接触这个案例的朋友会有帮助
前提提要
- 本文有些东西我不会详细的说,比如后端部分,前端代理啊,基于 element-ui 的递归菜单封装等其他组件使用等等,我不会在做额外的赘述了,后端这个流程包裹这些封装,后面我会单独开一篇文章来说明
- 前端 vue 项目结构部分也不会太过详细的说明,所以观看本文还是需要一定的基础,至少知道vue的基础语法、用过 vue-router 和 vuex 吧,要求还是不高的
需求分析
-
在实现我们这个需求,不难想到主要就是完成登录,通过登录获取到正确的菜单列表,通过菜单列表进行渲染
-
但是完成这个步骤的话,我们还需要捋一下页面的关系,按照我们的开发时态来说,我们启动一个项目之后,会通过 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()
-
现在我们在来看看效果,如图:
结语
这里只是给大家展示一种思路,具体的实现需要根据自己的业务来定,但是整体的流程都是差不多的
如果对于这个递归菜单,和后端部分这个实现登录逻辑部分,可以查看我后续发布的其他文章,或者如果我没忘记的话,我会来这里补上查看链接