拜托:vue项目,别再盲目的用 router.addRoute 动态添加路由的方案,去实现路由鉴权了好吗!

2,179 阅读8分钟

什么是路由鉴权?

假设你系统有张三,李四两个用户,且总计有如下3个路由,现在你需要实现:张三,能访问所有路由,李四只能访问C路由。那么这就是路由鉴权

/hello/a: A路由
/test/b: B路由
/ok/c: C路由

一个怪异现象

难道vue中路由鉴权方案就只有 router.addRoute 动态添加路由 一种实现方案吗? 拜托,明明有更友好的方案啊,为什么好多人像魔障了一般,就只认这一种方案呢?

为什么我有这种感觉?

  • 我看过几个开源项目,路由鉴权采用都是动态添加路由的方案
  • 我接手过一些需要路由鉴权的工作项目,基本也都是采用动态添加路由的方案
  • 有几次面试,也被问到,如何实现路由鉴权,面试官的预期答案是动态添加路由

如何通过 router.addRoute 动态添加路由方式实现路由鉴权?

基本实现步骤

  1. 在全局前置路由守卫中,判断有加载远程路由数据,未加载远程路由数据,则继续执行第2步
  2. 加载远程路由数据
  3. 将远程路由数据转换为RouteRecordRaw对象
  4. RouteRecordRaw对象通过:router.addRoute(routeRaw)添加到路由表
  5. next({ ...to, replace: true }). 为什么要执行这一步看这里:vue-router 4.x: addRoute

使用vite和webpack在具体实现上的不同之处

你的路由信息可能是这样的: { name: 'demo02', path: '/demo02', component: '/views/demo/demo02.vue' }

webpack添加动态路由

function routeInfoToRouteRecordRaw(routeInfo: RouteInfo): RouteRecordRaw {
  const { name, path, component } = routeInfo
  let importPartPath = component.replace(/^\/+/, '') // 过滤字符串前面所有 '/' 字符
  importPartPath = importPartPath.replace(/\.\w+$/, '') // 去除.vue后缀名
  // 最后importPartPath类似这样: views/demo/demo02
  // 最后import(xx)的xx内容类似这样: @/views/demo/demo02.vue
  return {
    name,
    path,
    component: () => import('@/' + importPartPath + '.vue')
  }
}

vite添加动态路由

// 通过 vite 的 glob 加载所有组件
const modules = import.meta.glob('@/views/**/*.vue') // 导入
/*
modules类似这样

{
    '/src/views/demo/demo02.vue': () => import("/src/views/demo/demo02.vue")
}

*/
function routeInfoToRouteRecordRaw(routeInfo: RouteInfo): RouteRecordRaw {
  const { name, path, component } = routeInfo
  let importPartPath = component.replace(/^\/+/, '') // 过滤字符串前面所有 '/' 字符
  importPartPath = importPartPath.replace(/\.\w+$/, '') // 去除.vue后缀名
  // 最后importPartPath类似这样: views/demo/demo02
  return {
    name,
    path,
    // 这里为什么和webpack的方式不一样? 因为vite不支持import(xxx)中包含变量,因此要改为vite glob方式引入指定目录所有组件,再按照key取出来
    component: modules[`/src/${importPartPath}.vue`]
  }
}

vue3 + vite:打包部署后,动态组件渲染404问题解决_vite 404-CSDN博客

vue3-vite动态路由导入组件不能使用模板字符串的问题_vue3 import 动态模板字符串-CSDN博客

功能 | Vite 官方中文文档 (vitejs.dev)

在路由守卫中添加动态路由

// 远程路由是否加载
let remoteRouteLoaded = false
router.beforeEach(async (to, from, next) => {
  if (!remoteRouteLoaded) {
    //远程路由设置为已加载
    remoteRouteLoaded = true
    // 获取远程路由(这里就可以根据不同的用户由后端返回不同的路由了) :routeInfoList 类似: [{ name: 'demo02', path: '/demo02', component: '/views/demo/demo02.vue' }]
    const routeInfoList = await loadRemoteRoue()
    // 将远程路由信息,转换为 RouteRecordRaw 对象,并添加到路由表中
    const retArr: RouteRecordRaw[] = routeInfoList.map((item) => routeInfoToRouteRecordRaw(item))
    retArr.forEach((item) => router.addRoute(item))
    // 当前在未添加远程路由前,可能已经匹配了旧的路由组件,而实际预期是想去新路由组件,因此需要再执行如下代码,使得能进入预期的新路由组件
    next({ path: to.path, query: to.query, hash: to.hash, replace: true })
  } else {
    // 动态路由已添加,则直接next()即可
    next()
  }
})

有的同学可能会说,我的动态添加的不是根路由而是子路由,实现步骤与上面基本一致,只是添加路由时,先指定上级路由名 vue-router 4.x 添加嵌套路由

路由配置

未使用动态添加路由方案,你原本的路由配置可能像这样

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'index',
      component: ScreenSizeView
    },
    {
      path: '/demo01',
      name: 'demo01',
      component: () => import('@/views/demo/demo01.vue')
    },
    {
      path: '/demo02',
      name: 'demo02',
      component: () => import('@/views/demo/demo02.vue')
    },
    {
      path: '/demo03',
      name: 'demo03',
      component: () => import('@/views/demo/demo03.vue')
    },
    // 上面路由配置都未匹配则匹配该路由组件
    { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
  ]
})

使用动态添加路由方案后,你的路由配置可能像这样(demo03,demo02,demo01都通过动态路由方式加载进来)

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'index',
      component: ScreenSizeView
    },
    // 上面路由配置都未匹配则匹配该路由组件
    { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
  ]
})

为什么不建议这么做? 原因如下

看了上面的逻辑,可能有的同学,会想:“这不是挺好的吗?”,我的回答是:“NO, NO, NO这并不好”,原因如下:

前端的整体结构变得不清晰了

原本你只需要通过看路由配置,就能清晰的知道这个前端项目有哪些路由,路由匹配表达式,以及对应的路由组件是哪个。就像下面这样

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'index',
      component: ScreenSizeView
    },
    {
      path: '/demo01',
      name: 'demo01',
      component: () => import('@/views/demo/demo01.vue')
    },
    {
      path: '/demo02',
      name: 'demo02',
      component: () => import('@/views/demo/demo02.vue')
    },
    {
      path: '/demo03',
      name: 'demo03',
      component: () => import('@/views/demo/demo03.vue')
    },
    // 上面路由配置都未匹配则匹配该路由组件
    { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
  ]
})

你改为动态添加路由方式之后,你已经无法通过路由配置,对这个项目的整体结构有一个清晰的认知了。就像下面这样(你不知道这个项目还有哪些路由,也不知道会匹配哪些路由组件)

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'index',
      component: ScreenSizeView
    },
    // 上面路由配置都未匹配则匹配该路由组件
    { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
  ]
})

有部分同学可能会嘴硬一下:"写文档,我们会在文档中描述清楚!"

还有部分同学可能会说: “没有完美的解决方案,有舍就有得,我认为得到的比失去的多”

引用了新的API,增加了学习成本,沟通成本

你问一个vue开发,有没有用过vue-router,绝大多数人都能肯定的告诉你用过。

你问一个vue开发,有没有用过动态添加路由,大部分人可能会犹豫一下,然后,再回复用过。

为什么会犹豫一下呢?

  1. 可能在想你是指动态添加路由,还是指动态路由
  2. 自己没实现过,但做过的项目中,确实有用过该方案,但未详细关注过
  3. 没用过,但听过,为了找工作,那就回复用过呗,大不了事后马上去恶补一下这个知识点

有同学会想:“你说增加了学习成本也就罢了,哪里又增加了什么沟通成本?你怕不是在忽悠人吧”

请回顾之前的这句代码const modules = import.meta.glob('@/views/**/*.vue'), 这是将view目录下所有组件都加载进来了,然后添加动态路由组件时,也是匹配的这个modules中的内容。

这有什么问题吗?

view目录成了一个约定,如果不放在view目录,那将无法匹配到这个路由组件,但路由定义本身是没有限制一定要放在哪个目录的

有同学又会想:“这点学习成本和沟通成本你都不愿意增加?你别做开发了!”

不知你有没有遇到过一种情况,工期紧张的时候,你可能因为一个平时一眼就能看出来的小问题,而卡半天?所以,勿以善小而不为 勿以恶小而为之

404路由变得不明确了

原本的404路由只有一个意思,未匹配的路由都进404

现在使用动态添加路由之后,404就存在两种意思

  1. 当前用户没这个路由权限,所以404
  2. 系统中确实没有这个路由,所以404

要明确是没有路由权限就变复杂了

可能有的系统要求,没权限,要求进入没权限的界面,而不是404,那这时采用动态添加路由的方案,就变得难以实现这个特性了。

meta信息没了

原本路由配置中,是可以包含meta信息,用于实现一些特殊功能的(如:路由缓存控制),但当前这种动态添加路由的方式,路由信息中已经没有meta信息了,虽说,再加个这个配置也不是不行,但这进一步增加了管理员配置菜单的复杂度。本来管理员,只需要简单配置下菜单就好了,现在连meta信息都放上去了,那又有了一堆约定,这是写在文档中好呢,还是不写?即使当前写了,多经手几个人,后续的人员还会维护这个文档吗?甚至这个文档会一直流传下去吗?

路由鉴权的另一种实现方式

无论采用哪种方式,实现路由鉴权,路由定义也是菜单定义,后端肯定维护的是菜单信息,而不是直接维护路由信息

那么,我们完全可以依然,将路由定义保留在前端项目本身,让路由鉴权只是实现路由鉴权即可。

let remoteMenuLoaded = false
let routeNameSet = new Set<string>()
router.beforeEach(async (to, from, next) => {
  // 获取目标路由名
  const { name: toName, fullPath: toFullPath } = to
  // console.log(to, from)
  if (!remoteMenuLoaded) {
    remoteMenuLoaded = true
    // 从后端获取菜单信息
    const menuList = await loadMenu()
    // 提取菜单信息中的路由名,将其放入一个set中,如果toName包含在该路由名set中,说明有该路由权限,否则没有
    routeNameSet = extractRouteName(menuList)
  } 
  if(toName && !routeNameSet.has(toName)){
      // 无权限,则转入无权限路由
      next({name:'NotPermission'})
  }else{
      // 有权限或匹配的404路由,则放行
      next()
  }
})

以上都是个人实践得出的看法,如果错误或更好的见解,欢迎在评论区中指出