提升项目档次-动态、权限路由

更多文档

前言

读万卷书不如行万里路,实践是件非常重要的事情,因为从这个过程中会学习到很多东西,从而总结出经验,根据经验在制定出更好的执行过程。今天就分享一下实际开发中如何更好的处理权限、动态路由

router带参路由

无论Vue还是React都会提供/:id方式动态匹配,通常这种方式会用来实现匹配某一类相同结构的页面,这个不是我们讨论的重点,这里一笔带过

权限路由

路由可以承载很多信息,如必传的pathcomponent等,还可以携带iconkeepAlive等信息,接下来也会涉及到这些,这里简单的聊一下,回到我们的话题,通常权限的路由有两种做法:

前端保留全量路由

这里也依然有两种做法:

方式一

前端保留一份全量的路由映射关系,然后根据后端接口返回过来的权限标识生成一份新的路由数据,然后再讲这份路由数据添加到router

  • 优点:路由中不存在木有权限的路由,无法通过url输入的方式访问

  • 缺点:如果有业务想要用户了解到他哪些有权限,哪些无权限从而促进用户去消费、开通更高级的账户时这种方案不是非常合适,但也可以通过路由守卫中去做相应的提示来解决这样的问题

方式二

前端保存全量路由,在路由守卫中根据后端同学的权限数据去决定用户的去向

  • 优点:所有处理均在路由守卫中解决,不需要维护额外的数据

  • 缺点:路由中存在没有权限的路由,容错低

ok介绍完了,但这也不是我们今天想要给大家分享的重点,因为这里依然有很多的问题:

  1. 前端需维护一份数据
  2. 前端维护的数据依然需要根据真正的权限过滤后使用
  3. 实际开发中前端同学新模块需手动配置路由
  4. 前端目录规范难以达到实际效果(后续会介绍)
  5. 可能会产生维护多份和权限、路由相关的数据

综上,接下来我们介绍一种更自动化、更安全、更能规范开发者行为的一种方式

权限标识动态生成路由

在此之前先介绍两个方法,这两个方法贯穿了整个动态路由的实现,也是得以实现的核心方法

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
}]
复制代码

简单分析一下数据:

  1. 类似user用户这样的菜单有子级,生成路由数据时不会添加path
  2. 类似commodityDetail商品详情这样的菜单isMenufalse会作为路由组件添加到路由数据,但不会生成菜单(路由带参这里暂不考虑)
  3. 类似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()
  }
}
复制代码

至此已经完成了基本的功能,是不是看起来还是蛮多的,但这是一次性、永久性的封装,带来的好处就是:

  1. 业务开发只需关系业务代码,无需再对业务架构层面逻辑纠结
  2. 新增模块异常简单,只需创建文件即可,文件路径即为访问地址
  3. 规范前端目录结构,属于强制性的规范
  4. 规范权限的配置,整合权限数据
  5. 前端无需在维护数据,有权限页面、有权非菜单页面、有权限带参页面(本文章demo没有处理)、无权限可访问页面、无权限不可访问页面均可在获取动态路由阶段处理掉,比较灵活

结语

还有很多细节,比如路由数据和缓存数据配合前端缓存处理等等,差不多就是这样了。实践还是很重要的,有时候想做一件事就去做吧,先不要管他有多大回报,这个过程本身就是收获

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享