松哥之前写了两篇文章和大家分享了 TienChin 项目中的菜单数据问题,还没看过的小伙伴请戳这里:
- Vue 里,多级菜单要如何设计才显得专业?
- TienChin 项目动态菜单接口分析
这两篇文章主要是和大家说明了后端如何根据当前登录用户,动态生成一个菜单 JSON。
那么现在的问题就是,当前端收到后端返回来的菜单 JSON 之后,该如何将之渲染出来?这就是我们目前所面临的问题了。
TienChin 项目基于 RuoYi 脚手架来完成,所以本文的分析你也可以看作是对 RuoYi-Vue3
项目的分析。
1. 整体思路
首先我们来梳理下整体上的实现思路,首先一点:整体思路和 vhr 一模一样。
考虑到有的小伙伴可能已经忘记 vhr 中前端动态菜单的实现思路了,因此本文再和大家分析一下。
为了确保在所有的 .vue
文件中都能访问到到菜单数据,所以选择将菜单数据存入 vuex 中,vuex 是 vue 中一个存储数据的公共地方,所有的 .vue
文件都可以从 vuex
中读取到数据。存储在 vuex
中的数据本质上是存在内存中,所以它有一个特点,就是浏览器按 F5 刷新之后,数据就没了。所以在发生页面的跳转的时候,我们应该去区分一下,是用户点击了页面上的菜单按钮之后发生了页面跳转还是用户点击了浏览器刷新按钮(或者按了 F5)发生了跳转。
为了实现这一点,我们需要用到 vue 中的路由导航守卫功能,对于我们 Java 工程师而言,这些可能听起来有点陌生,但是你把它当作 Java 中的 Filter 来看待就好理解了,实际上我们视频中和小伙伴们讲解的时候就是这么类比的,将一个新事物跟我们脑海中一个已有的熟悉的事物进行类比,就很容易理解了。
vue 中的导航守卫就类似一个监控,它可以监控到所有的页面跳转,在页面跳转中,我们可以去判断一下 vuex 中的菜单数据是否还在,如果还在,就说明用户是点击了页面上的菜单按钮完成了跳转的,如果不在,就说明用户是点击了浏览器的刷新按钮或者是按了 F5 进行页面刷新的,此时我们就要赶紧去服务端重新加载一下菜单数据。
---xxxxxxxxxxxxxxxxxx---
整体上的实现思路就是这样,接下来我们来看看一些具体的实现细节。
2. 实现细节
2.1 加载细节
首先我们来看看加载的细节。
小伙伴们知道,单页面项目的入口是 main.js
,路由加载的内容在 src/permission.js 文件中,该文件在 main.js 中被引入,src/permission.js 中的前置导航守卫内容如下:
router.beforeEach((to, from, next) => {
NProgress.start()
if (getToken()) {
to.meta.title && useSettingsStore().setTitle(to.meta.title)
/* has token*/
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (useUserStore().roles.length === 0) {
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
useUserStore().getInfo().then(() => {
isRelogin.show = false
usePermissionStore().generateRoutes().then(accessRoutes => {
// 根据roles权限生成可访问的路由表
accessRoutes.forEach(route => {
if (!isHttp(route.path)) {
router.addRoute(route) // 动态添加可访问路由表
}
})
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
useUserStore().logOut().then(() => {
ElMessage.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
// 在免登录白名单,直接进入
next()
} else {
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
NProgress.done()
}
}
})
我跟大家捋一下这个前置导航守卫中的思路:
- 首先调用 getToken 方法,这个方法实际上是去 Cookie 中拿认证 Token,也就是登录成功后后端返回给前端的那个 JWT 字符串。
- 如果 getToken 方法有返回值,说明用户已经登录了,那么进入到 if 分支中,如果 getToken 没拿到值,说明用户未登录,未登录的话,又分为两种情况:i:访问的目标地址处于免登录白名单中,那么此时直接访问即可;ii:访问的目标地址不在白名单中,那么此时就跳转到登录页面去,跳转的时候同时携带一个 redirect 参数,这样方便在登录成功之后,再跳转回访问的目标页面。这个免登录访问的白名单,是一个在 src/permission.js 文件中定义的变量,默认有四个路径,分别是
['/login', '/auth-redirect', '/bind', '/register']
。 - 如果 getToken 拿到了值,说明用户已经登录了,此时又分情况:如果用户访问的路径是登录页面,那么就给他重定向到项目首页(也就是在已经登录的情况下,不允许用户再次访问登录页面);如果用户访问的路径不是登录页面,那么首先判断 vuex 中的 roles 是否还有值?如果有值,说明当前就是用户点击了一个菜单按钮进行跳转的,那么直接跳转就行了;如果没有值,说明用户是按了浏览器的刷新按钮或者是 F5 按钮刷新进行的页面跳转,那么此时首先调用 getInfo 方法(位于 src/store/modules/user.js 文件中)去服务端重新加载当前用户的基本信息、角色信息以及权限信息,然后再调用 generateRoutes 方法(位于 src/store/modules/permission.js 文件中)去服务端加载路由信息,并将加载到的路由信息放入到 router 对象中(前提是这个路由对象不是一个 http 链接,就是普通的路由地址)。
这就是动态路由的加载整体思路。
在第三步骤中,涉及到两个方法,一个是 getInfo 还有一个 generateRoutes,这两个方法也都比较关键,我们再来稍微看下。
2.2 getInfo
首先这个加载用户信息的方法位于 src/store/modules/user.js
文件中,换言之,这些用户的基本信息加载到之后,是存储在 vuex 中的,如果刷新浏览器这些数据就会丢失:
getInfo() {
return new Promise((resolve, reject) => {
getInfo().then(res => {
const user = res.user
const avatar = (user.avatar == "" || user.avatar == null) ? defAva : import.meta.env.VITE_APP_BASE_API + user.avatar;
if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
this.roles = res.roles
this.permissions = res.permissions
} else {
this.roles = ['ROLE_DEFAULT']
}
this.name = user.userName
this.avatar = avatar;
resolve(res)
}).catch(error => {
reject(error)
})
})
},
方法的逻辑其实倒没啥好说的,结合服务端返回的 JSON 格式,应该就很好理解了(部分 JSON):
{
"permissions":[
"*:*:*"
],
"roles":[
"admin"
],
"user":
"userName":"admin",
"nickName":"TienChin健身",
"avatar":"",
}
}
另外再强调下,之前在 vhr 中,我们是将请求封装成了一个 api.js 文件,里边有常用的 get、post、put 以及 delete 请求等,然后在需要使用的地方,直接去调用这些方法发送请求即可,但是在 TienChin 中,脚手架的封装是将所有的请求都提前统一封装好,在需要的时候直接调用封装好的方法,连请求地址都不用传递了(封装的时候就已经写死了),所以小伙伴们看上面的 getInfo 方法只有方法调用,没有传递路径参数等。
2.3 generateRoutes
generateRoutes 方法则位于 src/store/modules/permission.js 文件中,这里值得说道的地方就比较多了:
generateRoutes(roles) {
return new Promise(resolve => {
// 向后端请求路由数据
getRouters().then(res => {
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const defaultData = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
const defaultRoutes = filterAsyncRouter(defaultData)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
asyncRoutes.forEach(route => { router.addRoute(route) })
this.setRoutes(rewriteRoutes)
this.setSidebarRouters(constantRoutes.concat(sidebarRoutes))
this.setDefaultRoutes(sidebarRoutes)
this.setTopbarRoutes(defaultRoutes)
resolve(rewriteRoutes)
})
})
}
首先大家看到,服务端返回的动态菜单数据解析了三次,分别拿到了三个对象,这三个对象都是将来要用的,只不过使用的场景不同,下面结合页面的显示跟大家细说。
- 首先是调用 filterAsyncRouter 方法,这个方法的核心作用就是将服务端返回的 component 组件动态加载为一个 component 对象。不过这个方法在调用的过程中,后面还有两个参数,第二个是 lastRouter 在该方法中并无实质性作用;第三个参数则主要是说是否需要对 children 的 path 进行重写。小伙伴们知道,服务端返回的动态菜单的 path 属性都是只有一层的,例如一级菜单系统管理的 path 是 system,二级菜单用户管理的 path 则是 user,那么用户管理最终访问的 path 就是
system/path
,如果第三个参数为 true,则会进行 path 的重写,将 path 最终设置正确。 - 所以这里的 sidebarRoutes 和 defaultRoutes 只是能用于菜单渲染(因为这两个里边的菜单 path 不对),而最终的页面跳转要通过 rewriteRoutes 才可以实现。
- 除了服务端返回的动态菜单,前端本身也定义了一些基础菜单,前端的基础菜单分为两大类,分别是 constantRoutes 和 dynamicRoutes,其中 constantRoutes 是固定菜单,也就是一些跟用户权限无关的菜单,例如 404 页面、首页等;dynamicRoutes 是动态菜单,也就是也根据用户权限来决定是否展示的菜单,例如分配用户、字典数据、调度日志等等。
- filterDynamicRoutes 方法则是将前端提前定义好的 dynamicRoutes 菜单进行过滤,找出那些符合当前用户权限的菜单将之添加到路由中(这些菜单都不需要在菜单栏渲染出来)。
- 接下来涉及到四个不同的保存路由数据的变量,分别是 routes、addRoutes(经松哥分析,这个变量并无实际作用,可以删除之)、defaultRoutes、topbarRouters 以及 sidebarRouters,四个路由变量的作用各有不同:
routes:
routes 中保存的是 constantRoutes 以及服务端返回的动态路由数据,并且这个动态路由数据中的 path 已经完成了重写,所以这个 routes 主要用在两个地方:
- 首页的搜索上:首页的搜索也可以按照路径去搜索,所以需要用到这个 routes,如下图:
- 用在 TagsView,这个地方也需要根据页面渲染不同的菜单,也是用的 routes:
sidebarRouters:
这个就是大家所熟知的侧边栏菜单了,具体展示是 constantRoutes+服务端返回的菜单,不过这些 constantRoutes 基本上 hidden 属性都是 false,渲染的时候是不会被渲染出来的。
topbarRouters:
这个是用在 TopNav 组件中,这个是将系统的一级菜单在头部显示出来的,如下图:
一级菜单在顶部显示,左边显示的都是二级三级菜单,那么顶部菜单的渲染,用的就是这个 topbarRouters。
defaultRoutes:
想要开启顶部菜单,需要在 src/layout/components/Settings/index.vue 组件中设置,如下图:
开启顶部菜单之后,点击顶部菜单,左边菜单栏会跟着切换,此时就是从 defaultRoutes 中遍历出相关的菜单设置给 sidebarRouters。
好了,这就是这四个 routes 变量的作用,老实说,脚手架中这块的代码设计有点混乱,没必要搞这么多变量,等松哥抽空给大家优化下。
generateRoutes 方法最终会返回 rewriteRoutes 变量到前面说的那个前置导航守卫中,最终前置导航守卫将数据添加到 router 中。
菜单的渲染都是在 src/layout/components/Sidebar/index.vue 中完成的,看了下都是常规操作,没啥好说的。
3. 小结
好啦,这就是 RuoYi-Vue3 中的动态菜单渲染逻辑,不知道小伙伴们看明白没有?视频即将奉上,对视频感兴趣的小伙伴请戳这里:TienChin 项目配套视频来啦。