vue3实现动态路由,解决动态路由常见的坑

12,117 阅读5分钟

前言

当在开发各种系统应用时,我们常常需要根据不同用户角色来展示不同的菜单页面,动态路由提供了灵活的路由规则,可以根据需要轻松添加、修改或者删除路由,不仅提升用户体验,还增强应用的安全性,避免无权限用户访问敏感页面。

思路

项目架构是基于vue3+vite+pinia。实现的思路是:后端接口根据不同登录用户返回不同的菜单路由数据,前端监听beforeEach路由跳转前事件,处理后端返回的菜单路由数据转换成前端需要的路由数据结构,通过router.addRoute()添加动态路由,同时将菜单路由存储在pinia缓存数据,退出登录时需要清除缓存数据且用router.removeRoute()移除动态路由。

image.png

具体代码参考

/src/route/index.ts

import { createRouter, createWebHistory, type RouteLocationNormalized, type NavigationGuardNext, type RouteRecordRaw } from 'vue-router'
import Layout from '~/layout/index.vue'
import { STORAGE_ACCESS_TOKEN_KEY } from '~/constant'
import { useRoutesStore } from '~/stores/use-routes'

// 基础路由
const outerRoutes = [
  {
    path: '/',
    name: 'login',
    component: () => import('~/views/login/index.vue')
  },
  {
    path: '/register',
    name: 'register',
    meta: { title: '注册' },
    component: () => import('~/views/register/index.vue')
  },
  {
    path: '/forgot-password',
    name: 'forgot-password',
    meta: { title: '忘记密码' },
    component: () => import('~/views/update-password/index.vue')
  },
  {
    path: '/401',
    name: '401',
    component: () => import('~/views/401/index.vue')
  },
  {
    path: '/404',
    name: '404',
    component: () => import('~/views/404/index.vue')
  },
  {
    path: '/500',
    name: '500',
    component: () => import('~/views/500/index.vue')
  },
  {
    path: '/reseller', // 动态路由模块
    name: 'reseller',
    component: Layout,
    children: []
  }
]
// 路由白名单(无需token)
const ROUTE_WHILE_LIST = [
  'login',
  'register',
  'forgot-password',
  '401',
  '404',
  '500'
]
// 404路由(动态路由的时候才添加,以免,找不到路由时会跳转到404)
const notFoundRoute = { path: '/:pathMatch(.*)*', name: 'not-found', redirect: '/404' }

const router = createRouter({
  history: createWebHistory(),
  routes: outerRoutes as RouteRecordRaw[]
})

// 生成动态路由
const generateRoute = async () => {
  const routesStore = useRoutesStore()
  // 请求动态路由数据
  await routesStore.getUserRoutes()
  const resellerRoutes = outerRoutes.find((v) => v.name === 'reseller') as RouteRecordRaw
  router.removeRoute('reseller')
  // 如果有动态路由才添加动态路由reseller模块
  if (routesStore.userRoutes && routesStore.userRoutes.length > 0) {
    resellerRoutes.children = (routesStore.userRoutes as RouteRecordRaw[]) ?? []
    router.addRoute(resellerRoutes)
  }
  router.addRoute(notFoundRoute)
}
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
  const token = globalThis.localStorage.getItem(STORAGE_ACCESS_TOKEN_KEY)
  if (token) {
    const isWhitelist = ROUTE_WHILE_LIST.includes(to.name as string)
    const routesStore = useRoutesStore()
    if (isWhitelist) {
      // 白名单路由且不是动态路由
      next()
    } else {
      if (!routesStore.userRoutes) {
        // 如果刷新页面没有用户路由数据也重新请求菜单路由权限
        await generateRoute()
        next({ ...to, replace: true })
      } else {
        next()
      }
    }
  } else {
    if (ROUTE_WHILE_LIST.includes(to.name as string)) {
      next()
    } else {
      next({ name: 'login' })
    }
  }
})
export default router

/src/stores/use-routes.ts

import { defineStore } from 'pinia'
import { authApi } from '~/api/auth'
import { errorCaptured } from '~/utils/http-error-msg'
import { type RouteRecordRaw } from 'vue-router'
import type { Routes } from '~/types/common/route'

const modules = import.meta.glob('../views/**/*.vue')

interface useRoutes {
  userRoutes: Routes[] | null
  resellerRoute: RouteRecordRaw | null
}

const generateRouter = (userRouters: Routes[]): Routes[] => {
  const newRouters: Routes[] = userRouters.map((router: Routes) => {
    const isParent = router.children && router.children.length > 0
    const routes = {
      ...router,
      meta: { title: router.title, icon: router.icon, isShow: !router.hidden },
      // eslint-disable-next-line @typescript-eslint/no-base-to-string,@typescript-eslint/restrict-template-expressions
      component: router?.component ? modules[/* @vite-ignore */ `../views${router.component}`] : undefined
    }

    if (isParent) {
      routes.redirect = routes.redirect ? routes.redirect : router.children[0].path
      // routes.render = (e: any) => e('router-view')
      // routes.component = () =>
      //   import(/* @vite-ignore */ `../components/ParentView/ParentView.vue`)
    }
    if (routes && router.children) {
      routes.children = generateRouter(router.children)
    }
    return routes
  })
  return newRouters
}

export const useRoutesStore = defineStore('userRoutes', {
  state: (): useRoutes => {
    return {
      userRoutes: null // 用户动态路由
    }
  },
  actions: {
    setUserRoutes (routeObject: Routes[]) {
      this.userRoutes = generateRouter(routeObject)
    },
    async getUserRoutes (persist = true) {
      const [res] = await errorCaptured(authApi.getUserRoutes)
      if (res && res.code === 200) {
        this.setUserRoutes(res.data)
      } else {
        this.userRoutes = []
      }
    },
    clearUserRoutes () {
      this.userRoutes = null
    }
  }
})

动态路由常见的坑

1.如果动态路由是一个数组,vue3需要遍历数组利用router.addRoute()一个个添加路由,vue2.2router.addroutes方法在vue3中已移除。同时当退出登录时,需要通过router.removeRoute()方法移除动态路由才能禁止访问。

2.404页面路由需要在添加动态路由后才添加,否则会出现找不到路由时会匹配跳转到404页面的问题。

3.当刷新页面首次加载动态路由时如果beforeEach直接next()后找不到路由会出现空白页面(如果添加404页面会跳转到404页面)。所以当加载动态路由时需要用next({ ...to, replace: true })。
next({ ...to, replace: true })意味着不放行,中断当前导航,执行新的导航,replace: true 只是一个设置信息,告诉VUE本次操作后,不能通过浏览器后退按钮,返回前一个路由。

4.当接口返回的动态菜单数据需要将component字符串转换成前端加载路由地址。在vue2中支持require导入模块或文件但是在vue3中已经不支持require导入了,为此vite提供了一个全新的方法import.meta.glob方法来支持批量导入文件。

5.当项目实现动态路由后,跳转动态路由需要用router.push({path:'/xxx'})做跳转,因为如果用router.push({name:'xxx'})方式做跳转,如果还没有加载动态路由,跳转时会直接报错,不会进入beforeEach事件中,就不能请求加载动态路由。(例如:设置一个白名单路由三秒后用router.push({name:'xxx'})跳转动态路由会报错)

补充与优化

1.按产品需求本项目加载动态路由方案是利用pinia储存路由数据,每次刷新浏览器页面会重新请求动态路由数据。如果不需要每次刷新页面请求动态数据可以采用登录时请求动态路由,同时将路由数据存储在localStorage,实现持久缓存,退出登录时再清空路由储存数据。
2.用户体验优化:当没有菜单权限的用户登录访问该菜单页面时希望跳转401无权限页面,而不是404页面。
代码补充:

import { type RouteRecordRaw } from 'vue-router'
import routes from '~/routes/local-routes'
/**
 * @example: 将路由转成全路径地址的数组(如:['/401','/404','/reseller/product'])
 * @param {RouteRecordRaw} routes
 * @param {*} prefix
 * @return {*} string[]
 */
const getDynamicFullPathRoute = (routes: RouteRecordRaw[], prefix = ''): string[] => {
  const result = []
  for (let i = 0; i < routes.length; i++) {
    // 子路由path如果不是以'/'开头则拼接父路由地址
    const fullPath = routes[i].path.startsWith('/') ? routes[i].path : prefix + '/' + routes[i].path
    if (!routes[i].children) {
      result.push(fullPath)
    } else {
      const children = getDynamicFullPathRoute(routes[i].children as RouteRecordRaw[], fullPath)
      result.push(...children)
    }
  }
  return result
}
router.beforeEach(async (to, from, next) => {
  // 访问动态路由时,如果没有权限跳转到401页面
  if (to.path === '/404' && to.redirectedFrom?.fullPath) {
    // routes为本地写死路由,用于测试
    const dynamicRouteArr = getDynamicFullPathRoute(routes.find((v) => v.path === '/reseller')?.children as RouteRecordRaw[], '/reseller')
    if (dynamicRouteArr.includes(to.redirectedFrom?.fullPath)) {
      next({ path: '401' })
      return
    }
  }
  ......
})

文献参考

next({ ...to, replace: true }):www.cnblogs.com/linjiangxia…
Vue3+vite中使用import.meta.glob:blog.csdn.net/2303_770721…
参考项目:github.com/1942847253/…