前言
读万卷书不如行万里路,实践是件非常重要的事情,因为从这个过程中会学习到很多东西,从而总结出经验,根据经验在制定出更好的执行过程。今天就分享一下实际开发中如何更好的处理权限、动态路由
router带参路由
无论Vue
还是React
都会提供/:id
方式动态匹配,通常这种方式会用来实现匹配某一类相同结构的页面,这个不是我们讨论的重点,这里一笔带过
权限路由
路由可以承载很多信息,如必传的path
、component
等,还可以携带icon
、keepAlive
等信息,接下来也会涉及到这些,这里简单的聊一下,回到我们的话题,通常权限的路由有两种做法:
前端保留全量路由
这里也依然有两种做法:
方式一
前端保留一份全量的路由映射关系,然后根据后端接口返回过来的权限标识生成一份新的路由数据,然后再讲这份路由数据添加到router
-
优点:路由中不存在木有权限的路由,无法通过url输入的方式访问
-
缺点:如果有业务想要用户了解到他哪些有权限,哪些无权限从而促进用户去消费、开通更高级的账户时这种方案不是非常合适,但也可以通过路由守卫中去做相应的提示来解决这样的问题
方式二
前端保存全量路由,在路由守卫中根据后端同学的权限数据去决定用户的去向
-
优点:所有处理均在路由守卫中解决,不需要维护额外的数据
-
缺点:路由中存在没有权限的路由,容错低
ok介绍完了,但这也不是我们今天想要给大家分享的重点,因为这里依然有很多的问题:
- 前端需维护一份数据
- 前端维护的数据依然需要根据真正的权限过滤后使用
- 实际开发中前端同学新模块需手动配置路由
- 前端目录规范难以达到实际效果(后续会介绍)
- 可能会产生维护多份和权限、路由相关的数据
综上,接下来我们介绍一种更自动化、更安全、更能规范开发者行为的一种方式
权限标识动态生成路由
在此之前先介绍两个方法,这两个方法贯穿了整个动态路由的实现,也是得以实现的核心方法
require.context
require.context
是用来帮助我们匹配.vue
文件,从而让我们可以动态取到.vue
组件,可以尝试执行一下require.context('../pages', true, /\.vue$/).keys()
看看结果,他不仅可以帮助我们去匹配组件,也可以帮助我们去动态注册组件,就像这样:
// 通过require.context注册components下的所有组件
import Vue from 'vue'
const componentsContext = require.context('../components', true, /\index.vue$/)
componentsContext.keys().forEach(component => {
const componentConfig = componentsContext(component)
/**
* 兼容 import export 和 require module.export 两种规范
*/
const ctrl = componentConfig.default || componentConfig
Vue.component(ctrl.name, ctrl)
})
复制代码
addRoutes
router.addRoutes
的作用是帮助我们动态将数据添加到路由中,但他也同样带来了路由重复的问题,因为页面刷新时路由信息才会被重置,所以当你退出登录在登录时再次调用router.addRoutes
时就会导致路由重复的问题,解决这个有两种方式:
方式一
// 带来的后续问题需手动解决
// 如项目部署非根目录,假设部署在test文件夹下
// 此时回到登录页就需要改为window.location.href = 'https://juejin.cn/test/login'
window.location.href = '/login'
复制代码
方式二
// 二、每次添加路由前清空路由信息
// router.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const createRouter = () => new Router({
mode: 'history',
routes: []
})
const router = createRouter()
export function resetRouter () {
const newRouter = createRouter()
router.matcher = newRouter.matcher
}
export default router
// 后续调用addRoutes前先调用resetRouter
// login.js登录
import {resetRouter} from '@/router'
resetRouter()
router.addRoutes(routes)
复制代码
实现
静态部分
所谓静态部分就是不会参与权限的页面,比如首页、登录注册等页面,假设我们现在只有一个首页,今天路由如下:
const router = new Router({
mode: 'hash',
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/404',
component: Notfound
},
{
path: '*',
redirect: '/404',
component: Notfound
}
]
})
复制代码
权限数据
要实现这样自动化的路由,也需要后端同学的配合,其实也只是按照固定的数据格式返回前端需要的信息而已,假设我们和后端同学约定后返回的路由格式如下:
[{
"id": "1",
"icon": "el-icon-user-solid",
"title": "用户",
"mark": "user",
"children": [{
"id": "1-1",
"icon": "el-icon-s-flag",
"title": "白名单",
"mark": "white",
"children": [{
"id": "1-1-1",
"icon": "el-icon-s-order",
"title": "商品",
"mark": "commodity"
}, {
"id": "1-1-2",
"icon": "",
"title": "商品详情",
"mark": "commodityDetail",
"isMenu": false
}]
}]
}, {
"id": "2",
"icon": "el-icon-takeaway-box",
"title": "生产线",
"mark": "proline"
}, {
"id": "3",
"icon": "el-icon-takeaway-box",
"title": "访问跳转至无权限",
"mark": "test",
"power": false
}]
复制代码
简单分析一下数据:
- 类似
user
用户这样的菜单有子级,生成路由数据时不会添加path
- 类似
commodityDetail
商品详情这样的菜单isMenu
为false
会作为路由组件添加到路由数据,但不会生成菜单(路由带参这里暂不考虑) - 类似
power
访问跳转至无权限这样的菜单会作为菜单展示,但是会跳转到无权限访问页面,以刺激用户去做用户升级
看似复杂,实则这样的配置属于一次性的,逻辑亦是一次性的
生成路由
这里就是路由数据生成的关键,对于业务开发者必须遵循一定的规则去创建目录,类似nuxt
这样的框架,路由是按文件名生成,文件夹的名称即代表了path
的结构
假定我们匹配组件的规则如下: 匹配文件夹下的同名.vue组件为路由组件
,以上述路由为例子生产线
的访问路径为/main/proline
,商品
的访问路径为/main/user/white/commodity
,按照这个规则我们来写一下逻辑:
// 获取组件
const componentsContext = require.context('../pages', true, /\.vue$/).keys()
class AddMenuRouter {
constructor() {
this.mainRoutes = [{path: '/main',name: 'main', component: main}]
this.componentsContext = componentsContext
}
// 匹配组件
getCom(v) {
return resolve => require([`@/pages${v}`], resolve)
}
// 检索path,不存在则404
checkPath(v) {
let a, s, l, n, m
this.componentsContext.map(item=>{
a = item.split('/')
s = a[a.length-1]
l = s.substring(0, s.length-4)
if(v === l) n = item
})
m = n || `./404/Notfound.vue`
return m.substring(1, m.length)
}
// 处理接口权限数据
getChildRoutes(routes, path = "/main") {
if(!routes) return
if(Object.prototype.toString.call(routes) !== "[object Array]") return console.warn("routes数据格式错误")
routes.length && routes.map((item) => {
item.meta = { keepAlive: true } // 缓存信息后续会用到
if(item.children) {
this.getChildRoutes(item.children, `${path}/${item.mark}`)
} else {
item.name = item.mark
item.path = `${path}/${item.mark}`
item.component = item.mark ? this.getCom(this.checkPath(item.mark)) : this.getCom(this.checkPath('Notfound'))
}
})
return routes
}
// 获取/main的redirect路径,假设跳转到第一个菜单的第一个有path的页面
getRedirect(routes) {
if (!routes) return
let lastChild
if (routes.children && routes.children.length > 0) {
return this.getRedirect(routes.children[0])
} else {
lastChild = routes
return lastChild
}
}
// 获取菜单权限
_getMenuData() {
// 此处可以做本地与联调的配置,以应对后端异常时影响开发,devMenuData为自定义菜单数据
return config.devmodule ? devMenuData : getMenuData().then((data) => {
if (data.statusCode == '200') return data.result.children ? data.result.children : []
})
}
// 获取最终要添加到路由中的数据
async getMainRoutes(menudata) {
try {
const menudata = await this._getMenuData();
const routes = this.getRoutes(menudata)
const redirect = this.getRedirect(routes[0])
this.mainRoutes[0].redirect = redirect && redirect.path ? redirect.path : ""
this.mainRoutes[0].children = routes
return this.mainRoutes
} catch(e) {
console.error(`error:${error}`)
}
}
}
export default new AddMenuRouter()
复制代码
ok到此已经获取动态部分路由的信息,测试看一下数据
const addMenuRouter = AddMenuRouter()
addMenuRouter.getMainRoutes()
console.log(addMenuRouter.mainRoutes)
// [
// {
// "path": "/main",
// "name": "main",
// "redirect": "/main/user/white/commodity",
// "component": ƒ main(resolve),
// "children": [
// {
// "id": "1",
// "icon": "el-icon-user-solid",
// "title": "用户",
// "mark": "user",
// "children": [
// {
// "id": "1-1",
// "icon": "el-icon-s-flag",
// "title": "白名单",
// "mark": "white",
// "children": [
// {
// "id": "1-1-1",
// "icon": "el-icon-s-order",
// "title": "商品",
// "mark": "commodity",
// "meta": {
// "keepAlive": true
// },
// "name": "commodity",
// "path": "/main/user/white/commodity",
// "component": ƒ main(resolve),
// },
// {
// "id": "1-1-2",
// "icon": "",
// "title": "商品详情",
// "mark": "commodityDetail",
// "isMenu": false,
// "meta": {
// "keepAlive": true
// },
// "name": "commodityDetail",
// "path": "/main/user/white/commodityDetail",
// "component": ƒ main(resolve),
// }
// ],
// "meta": {
// "keepAlive": true
// }
// }
// ],
// "meta": {
// "keepAlive": true
// }
// },
// {
// "id": "2",
// "icon": "el-icon-takeaway-box",
// "title": "生产线",
// "mark": "proline",
// "meta": {
// "keepAlive": true
// },
// "name": "proline",
// "path": "/main/proline",
// "component": ƒ main(resolve),
// },
// {
// "id": "3",
// "icon": "el-icon-takeaway-box",
// "title": "访问跳转至无权限",
// "mark": "test",
// "power": false,
// "meta": {
// "keepAlive": true
// },
// "name": "test",
// "path": "/main/test",
// "component": ƒ main(resolve),
// }
// ]
// }
// ]
复制代码
添加登录
登录成功后将拿到的数据添加到路由中去
{
methods: {
async _login() {
const menuRouter = await this._addMenuRouter()
this.$router.addRoutes(menuRouter)
this.$router.push('/main')
}
_addMenuRouter() {
return addMenuRouter.getMainRoutes().then(data=> data)
}
}
}
复制代码
菜单、路由守卫
菜单同样是根据getMainRoutes
获取的数据生成,至于更多的处理均可以在router.beforeEach
中处理,因为所有关于权限的数据都已经在路由中,可以轻松的拿到并处理,这里不在延伸了
缓存页面
keep-alive
这里不在介绍了,我们聊一下如何随意切换是否缓存或者不缓存某个页面,还记得上边生成路由时都会添加一个keep-alive
配置吗,这个就是决定我们是否缓存某个页面,现在对我们AddMenuRouter
添加缓存信息的存储:
// 获取组件
const componentsContext = require.context('../pages', true, /\.vue$/).keys()
class AddMenuRouter {
constructor() {
this.mainRoutes = [{path: '/main',name: 'main', component: main}]
this.componentsContext = componentsContext
this.keepAliveArr = {}
}
// 匹配组件
getCom(v) {
return resolve => require([`@/pages${v}`], resolve)
}
// 检索path,不存在则404
checkPath(v) {
let a, s, l, n, m
this.componentsContext.map(item=>{
a = item.split('/')
s = a[a.length-1]
l = s.substring(0, s.length-4)
if(v === l) n = item
})
m = n || `./404/Notfound.vue`
return m.substring(1, m.length)
}
// 处理接口权限数据
getChildRoutes(routes, path = "/main") {
if(!routes) return
if(Object.prototype.toString.call(routes) !== "[object Array]") return console.warn("routes数据格式错误")
routes.length && routes.map((item) => {
item.meta = { keepAlive: true } // 缓存信息后续会用到
if(item.children) {
// 只记录路由组件的缓存
this.keepAliveArr[item.mark] = { keepAlive: item.meta.keepAlive }
this.getChildRoutes(item.children, `${path}/${item.mark}`)
} else {
item.name = item.mark
item.path = `${path}/${item.mark}`
item.component = item.mark ? this.getCom(this.checkPath(item.mark)) : this.getCom(this.checkPath('Notfound'))
}
})
return routes
}
// 获取/main的redirect路径,假设跳转到第一个菜单的第一个有path的页面
getRedirect(routes) {
if (!routes) return
let lastChild
if (routes.children && routes.children.length > 0) {
return this.getRedirect(routes.children[0])
} else {
lastChild = routes
return lastChild
}
}
// 获取菜单权限
_getMenuData() {
// 此处可以做本地与联调的配置,以应对后端异常时影响开发,devMenuData为自定义菜单数据
return config.devmodule ? devMenuData : getMenuData().then((data) => {
if (data.statusCode == '200') return data.result.children ? data.result.children : []
})
}
// 获取最终要添加到路由中的数据
async getMainRoutes(menudata) {
try {
const menudata = await this._getMenuData();
const routes = this.getRoutes(menudata)
const redirect = this.getRedirect(routes[0])
this.mainRoutes[0].redirect = redirect && redirect.path ? redirect.path : ""
this.mainRoutes[0].children = routes
// 存储缓存信息
store.commit('SET_KEEPALIVEARR',this.keepAliveArr)
return this.mainRoutes
} catch(e) {
console.error(`error:${error}`)
}
}
}
export default new AddMenuRouter()
复制代码
我们在store
中用keepAliveArr
记录了所以页面的缓存信息,接下来就是如何快速的切换是否缓存页面
// vue2.0 写一个mixin全局混入,调用m_setPagesCache和m_removePagesCache即可
// vue3.0 写一个hooks即可
methods: {
/**
* 缓存页面
* @param {e} 参数e: 将要缓存页面的name名,例如:{to.name}
*/
m_setPagesCache(e) {
let deepKeepAliveArr = this.m_copy(store.state.keepAliveArr)
deepKeepAliveArr[e].keepAlive = ture
setTimeout(() => store.commit('SET_KEEPALIVEARR', deepKeepAliveArr), 0)
},
/**
* 取消页面缓存
* @param {e} 参数e: 取消缓存页面的name名
*/
m_removePagesCache(e) {
let deepKeepAliveArr = this.m_copy(store.state.keepAliveArr)
deepKeepAliveArr[e].keepAlive = false
store.commit('SET_KEEPALIVEARR', deepKeepAliveArr)
}
},
// 路由钩子函数快速切换缓存
beforeRouteEnter(to,from,next) {
if(store.state.keepAliveArr[to.name]){
to.meta.keepAlive = store.state.keepAliveArr[to.name].keepAlive
}
next()
}
}
复制代码
至此已经完成了基本的功能,是不是看起来还是蛮多的,但这是一次性、永久性的封装,带来的好处就是:
- 业务开发只需关系业务代码,无需再对业务架构层面逻辑纠结
- 新增模块异常简单,只需创建文件即可,文件路径即为访问地址
- 规范前端目录结构,属于强制性的规范
- 规范权限的配置,整合权限数据
- 前端无需在维护数据,有权限页面、有权非菜单页面、有权限带参页面(本文章demo没有处理)、无权限可访问页面、无权限不可访问页面均可在获取动态路由阶段处理掉,比较灵活
结语
还有很多细节,比如路由数据和缓存数据配合前端缓存处理等等,差不多就是这样了。实践还是很重要的,有时候想做一件事就去做吧,先不要管他有多大回报,这个过程本身就是收获