Vue.js - 动态加载用户菜单样例(解决F5刷新后Store数据丢失问题)
通常来说,用户在登录成功后会在首页左侧或者上方显示一个用户菜单。而这个菜单数据是根据用户的角色动态加载的,即不同身份的用户登录成功后看到的菜单是不一样的。下面通过样例演示如何实现菜单的加载与展示。

菜单渲染操作在 Home.vue 组件中完成,并且菜单点击后里面的子路由视图会进行切换:



一、后端部分
(1)后端接口实现比较容易,先根据登录用户的 id 查询该用户具有的角色,在根据角色信息查看对应的 Menu,最后将 Menu 返回到前端。
这里的服务端使用 Java 实现,关于如何通过数据库进行用户认证,以及获取当前登录用户的 id,可以参考我之前写的文章:
(2)这里假设后端接口为 /sysmenu,返回的数据格式如下:
(1)这里菜单一共有两级,结构上采用嵌套的形式,关于如何查询树形结构的数据,可以参考我之前写的文章:
(2)每个菜单节点主要关注这几个属性:
(2)每个菜单节点主要关注这几个属性:
- name:菜单名称
- component:菜单对应的 vue 模块名(客户端会根据这个名字记载实际的 component 组件)
- path:菜单对应的 vue 模块路径
[ { "id":2, "path":"/home", "component":"Home", "name":"人员管理", "iconCls":"fa fa-user-circle-o", "children":[ { "id":null, "path":"/emp/basic", "component":"EmpBasic", "name":"基本资料", "iconCls":null, "children":[ ], "meta":{ "keepAlive":false, "requireAuth":true } } ], "meta":{ "keepAlive":false, "requireAuth":true } }, { "id":5, "path":"/home", "component":"Home", "name":"统计管理", "iconCls":"fa fa-bar-chart", "children":[ { "id":null, "path":"/sta/all", "component":"StaAll", "name":"综合信息统计", "iconCls":null, "children":[ ], "meta":{ "keepAlive":false, "requireAuth":true } }, { "id":null, "path":"/sta/pers", "component":"StaPers", "name":"人事信息统计", "iconCls":null, "children":[ ], "meta":{ "keepAlive":false, "requireAuth":true } } ], "meta":{ "keepAlive":false, "requireAuth":true } }, { "id":6, "path":"/home", "component":"Home", "name":"系统管理", "iconCls":"fa fa-windows", "children":[ { "id":null, "path":"/sys/basic", "component":"SysBasic", "name":"基础设置", "iconCls":null, "children":[ ], "meta":{ "keepAlive":false, "requireAuth":true } }, { "id":null, "path":"/sys/log", "component":"SysLog", "name":"日志管理", "iconCls":null, "children":[ ], "meta":{ "keepAlive":false, "requireAuth":true } } ], "meta":{ "keepAlive":false, "requireAuth":true } } ]
二、前端部分
1,初始路由设置(router/index.js)
系统初始路由只有一个 /home (即进入首页面)。等后面菜单完毕后,会根据菜单项自动添加对应的路由以及模块。import Vue from 'vue' import Router from 'vue-router' import Home from '@/components/Home' Vue.use(Router) export default new Router({ routes: [ /* { path: '/', name: 'Login', component: Login, hidden: true },*/ { path: '/home', name: 'Home', component: Home } ] })
2,创建 store 用来保存菜单数据(store/index.js)
首先在 store 中创建一个 routes 数组,这个是一个空数组,后面我们将会把服务端返回的 JSON 格式的菜单数据保存在 store 中,然后各个 Vue 页面根据 store 中的数据来渲染菜单。import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { routes: [] }, mutations: { initMenu(state, menus){ state.routes = menus; } } });
3,菜单初始化工具类(utils/menuUtils.js)
(1)该工具类主要用于初始化菜单,其中一个重要的工作是将服务器返回的 JSON 格式的数据转成 router 需要的格式。因为服务端返回的 component 是一个字符串,而 router 中需要的却是一个组件,我们要在这里动态加载相应的组件:import axios from 'axios'; // 请求菜单数据并初始化 export const initMenu = (router, store)=> { // 首先判断 store 中数据是否存在,如果存在,则说明这次跳转是正常的跳转 // 而不是用户按F5键或者直接在地址栏输入某个地址进入的,这时直接返回,不必执行菜单初始化 if (store.state.routes.length > 0) { return; } // 若 store 中不存在菜单数据,则需要初始化数据 axios.get("/sysmenu").then(resp=> { if (resp && resp.status == 200) { // 将服务器返回的 JSON 格式的数据转成 router 需要的格式 var fmtRoutes = formatRoutes(resp.data); // 将准备好的数据动态添加到路由中 router.addRoutes(fmtRoutes); // 同时也将数据存到 store 中 store.commit('initMenu', fmtRoutes); } }) } // 将服务器返回的 JSON 转为 router 需要的格式 export const formatRoutes = (routes)=> { let fmRoutes = []; routes.forEach(router=> { let { path, component, name, meta, iconCls, children } = router; if (children && children instanceof Array) { // 如果有子节点则递归转换 children = formatRoutes(children); } let fmRouter = { path: path, // 根据服务器返回的 component 动态加载需要的组件 component(resolve){ if (component.startsWith("Home")) { require(['../components/' + component + '.vue'], resolve) } else if (component.startsWith("Emp")) { require(['../components/emp/' + component + '.vue'], resolve) } else if (component.startsWith("Sta")) { require(['../components/statistics/' + component + '.vue'], resolve) } else if (component.startsWith("Sys")) { require(['../components/system/' + component + '.vue'], resolve) } }, name: name, iconCls: iconCls, meta: meta, children: children }; fmRoutes.push(fmRouter); }) return fmRoutes; }
(2)红框部分为项目里菜单对应的各个组件位置:

4,项目主入口代码(main.js)
main.js 中除了将前面定义的 route、store 引入外,还需要开启一个路由全局守卫,在每次访问某个页面前都去加载一次菜单数据:1,为什么每次访问页面前都需要加载一次菜单数据?
通常情况下,我们只在登录成功之后请求一次菜单资源,然后将 JSON 数据保存在 store 中,以便下一次使用。但是这样会有一个问题:
要解决这个问题有如下两种方案:
通常情况下,我们只在登录成功之后请求一次菜单资源,然后将 JSON 数据保存在 store 中,以便下一次使用。但是这样会有一个问题:
- 假如用户登录成功之后,单击 Home 页的某一个按钮,进入某一个子页面中,然后按一下 F5 键进行刷新,这个时候就会出现空白页面,因为按 F5 键刷新之后 store 中的数据就没了。
要解决这个问题有如下两种方案:
- 方案一,不要将菜单资源保存到 store 中,而是保存到 localStorage 中,这样即使按 F5 键刷新之后数据还在。由于菜单资源是非常敏感的,因此不建议将其保存到本地,故舍弃。
- 方案二,直接在每一个页面的 mounted 方法中都加载一次菜单资源。但这种做法工作量有点大,而且也不易维护,这里可以使用路由中的导航守卫来简化这个方案的工作量。
import Vue from 'vue'; import App from './App'; import router from './router'; import store from './store'; import {initMenu} from './utils/menuUtils' Vue.config.productionTip = false; import ElementUI from 'element-ui'; import 'element-ui/lib/theme-chalk/index.css'; Vue.use(ElementUI); // 配置一个全局前置守卫 router.beforeEach((to, from, next)=> { // 首先判断目标页面是不是Login,若是Login页面,则直接通过,因为登录页不需要菜单数据 if (to.name == 'Login') { next(); return; } // 判断当前用户是否已经登录,否则跳回登录页 // ........ // 先初始化菜单数据 initMenu(router, store); // 再进入下一个页面 next(); } ) new Vue({ el: '#app', router, store, components: { App }, template: '<App/>' })
5,主视图代码(Home.vue)
菜单渲染操作在 Home.vue 组件中完成,并且菜单点击后里面的子路由视图会进行切换:
<template> <div> <el-container class="home-container"> <el-header class="home-header"> <span class="home_title">动态菜单 DEMO</span> </el-header> <el-container> <el-aside width="180px" class="home-aside"> <div style="display: flex;justify-content: flex-start;width: 180px;text-align: left;"> <el-menu style="background: #ececec;width: 180px;" unique-opened router> <!-- 遍历routes数据,根据routes中的数据渲染出el-submenu和el-menu-item --> <template v-for="(item,index) in this.routes" v-if="!item.hidden"> <el-submenu :key="index" :index="index+''"> <template slot="title"> <i :class="item.iconCls" style="color: #20a0ff;width: 14px;"></i> <span slot="title">{{item.name}}</span> </template> <el-menu-item width="180px" style="padding-left: 30px;width: 170px;text-align: left" v-for="child in item.children" :index="child.path" :key="child.path">{{child.name}} </el-menu-item> </el-submenu> </template> </el-menu> </div> </el-aside> <el-main> <el-breadcrumb separator-class="el-icon-arrow-right"> <el-breadcrumb-item :to="{ path: '/home' }">首页</el-breadcrumb-item> <el-breadcrumb-item v-text="this.$router.currentRoute.name"></el-breadcrumb-item> </el-breadcrumb> <keep-alive> <router-view v-if="this.$route.meta.keepAlive"></router-view> </keep-alive> <router-view v-if="!this.$route.meta.keepAlive"></router-view> </el-main> </el-container> </el-container> </div> </template> <script> export default{ methods: { }, data(){ return { } }, computed: { // 在计算属性中返回 routes 数据 routes(){ return this.$store.state.routes } } } </script> <style> .home-container { height: 100%; position: absolute; top: 0px; left: 0px; width: 100%; } .home-header { background-color: #20a0ff; color: #333; text-align: center; display: flex; align-items: center; justify-content: space-between; box-sizing: content-box; padding: 0px; } .home-aside { background-color: #ECECEC; } .home_title { color: #fff; font-size: 22px; display: inline; margin-left: 8px; } .el-submenu .el-menu-item { width: 180px; min-width: 175px; } </style>
6,运行效果
(1)默认访问 /home 首页显示效果如下:
(2)点击左侧菜单栏自动切换右侧组件,并且无论当前在那个页面,按下 F5 刷新后菜单都不会丢失。

