Vue3动态路由实战:菜单接口、约定式与权限控制的完整实践

发布时间:2026/9/18 11:17:19
Vue3动态路由实战:菜单接口、约定式与权限控制的完整实践 做过 Vue 后台管理系统的人应该都经历过这种尴尬侧边栏菜单是写死写在路由配置里的产品临时提了个新页面你要去 router 文件里手动 import 一个组件再配一条 route有时候还得顺便给菜单文件加一项。一次两个页面还好项目过了几十个页面之后这种玩法基本是灾难。后来接了个权限管理需求要求不同角色登录后看到的菜单完全不一样我才正式把动态路由这套机制啃下来。在 Vue3 Vite 项目里动态路由这个话题被聊得非常散网上随便一搜就是动态路由实现addRoute 用法但真正落到自己项目里你会发现需求之间的差别非常大。我这几年折腾下来动态路由真正高频出现的也就三种场景后端菜单接口驱动的路由表、基于 Viteimport.meta.glob的文件约定式路由、基于用户角色权限的守卫挂载与动态过滤。这篇文章不整虚的把每个场景的适用环境、核心代码、以及我踩过的坑全部拆开讲。1. 动态路由的三种需求模型先搞明白你要的是哪一种很多人在网上问动态路由怎么实现时其实自己也没想清楚要解决什么问题。这里先帮大家把概念理清所谓动态路由关键在一个动字但动态的方向可以完全不同。第一种是数据驱动的路由表。路由的配置信息比如 path、name、meta、children不再是前端写死的而是由后端接口统一返回。前端收到菜单树之后把它转换成 vue-router 能识别的路由记录然后通过router.addRoute动态注册。这种场景多出现在后台管理系统尤其是需要运营人员在后台配置菜单、调整页面入口的项目。它的典型特征是你登录系统后拿到的菜单跟其他人的菜单可能完全不一样但这个差异体现在路由的内容上。第二种是约定式路由。路由结构完全由 views 目录下的文件路径决定新增页面时只需要在指定目录下新建一个.vue文件路由自动就出现了不用再去路由配置文件里手动维护。这种方案的底气来自 Vite 提供的import.meta.glob能力在 webpack 时代你还得自己写 require.contextVite 这里写起来简洁得多。它适合页面数量多、团队约定意识强的项目尤其是原型阶段和内部系统。第三种是权限控制下的路由挂载。不管路由表是静态配置还是后端接口下发前端都需要根据当前登录用户的角色或权限码决定哪些路由可以注册、哪些路由不能访问。这种场景一般配合beforeEach导航守卫完成用户刷新页面后先判断登录态再拉取权限、过滤路由表、动态挂载最后放行导航。一个现实项目往往不是单一场景。我见过大多数商用后台都是静态路由打底 接口菜单动态路由 递归映射组件 守卫拦截的组合打法文件约定式作为组件加载的技术支撑角色权限作为过滤逻辑穿插其中。后面我会把每个场景单独拆开再讲怎么在同一个项目里把它们缝合起来。2. 场景一后端菜单接口驱动前端递归生成路由记录这个场景是后台管理系统里最典型的动态路由方案市面上主流的中后台框架包括若依这类开源项目核心逻辑都差不多。2.1 后端返回的菜单数据长什么样先说约定。动态路由不是把整个 vue-router 配置文件搬到后端而是让后端返回一份菜单树前端拿到后转换成路由记录。我们项目里后端返回的数据大概是下面这个结构[ { path: /dashboard, name: Dashboard, component: dashboard/index, meta: { title: 工作台, icon: dashboard, affix: true } }, { path: /system, name: System, component: Layout, meta: { title: 系统管理, icon: setting }, children: [ { path: /system/user, name: SystemUser, component: system/user/index, meta: { title: 用户管理, icon: user } }, { path: /system/role, name: SystemRole, component: system/role/index, meta: { title: 角色管理, icon: role } } ] } ]有几个字段需要提前约定清楚。path是访问路径name是路由名component是前端组件的映射标识这个字段后端存的是字符串绝对不能直接拿来做动态 import。meta里主要放标题、图标、缓存标记这些页面辅助信息。为什么 component 用字符串而不是真实组件路径很简单后端不可能知道你的前端组件存在哪个目录下也不可能返回一个函数给你所以必须约定一套组件标识规则由前端来做字符串到组件的解析。2.2 核心实现把菜单树递归转换成路由记录拿到菜单数据后第一件事是转换成 vue-router 需要的RouteRecordRaw。这个过程必须递归处理因为菜单是树形的路由也天然应该有 children 嵌套关系。import type { RouteRecordRaw } from vue-router import Layout from /layout/index.vue interface MenuData { path: string name: string component: string meta?: Recordstring, any children?: MenuData[] } const viewModules import.meta.glob(/src/views/**/*.vue) function resolveComponent(component: string): any { if (component Layout) { return Layout } // 约定 component 字符串对应 src/views 下的文件路径 const target Object.keys(viewModules).find( (key) key /src/views/${component}.vue ) if (!target) { console.warn([DynamicRoute] 未找到组件: ${component}) return () import(/views/error/404.vue) } return viewModules[target] } function transformMenuToRoutes(menus: MenuData[]): RouteRecordRaw[] { return menus.map((menu) { const route: RouteRecordRaw { path: menu.path, name: menu.name, meta: menu.meta ?? {}, } if (menu.component) { route.component resolveComponent(menu.component) } if (menu.children menu.children.length) { route.children transformMenuToRoutes(menu.children) } return route }) } export async function generateDynamicRoutes(menus: MenuData[]) { return transformMenuToRoutes(menus) }注意这里我用了import.meta.glob(/src/views/**/*.vue)预加载所有页面组件。viewModules这个对象key 是文件路径value 是一个返回 Promise 组件的懒加载函数。resolveComponent就是根据后端返回的字符串在这个映射表里找到对应的加载函数。这套做法的好处是以后新增页面只需要在 views 目录下加文件不用再维护一套额外的组件映射表。至于为什么 component 是字符串而不是真实的路径核心考虑是安全性和可维护性后端返回一个受控的标识符前端严格映射避免把文件路径直接暴露到接口层。2.3 路由注册与刷新防白屏路由表转换完了接下来的问题是什么时候 addRoute以及刷新页面怎么办。直接在登录页拿到菜单后就添加这在单页应用里看着没问题但刷新一次就全没了。因为刷新之后内存里的 Vuex/Pinia store 被重置路由表是空的所以要有一个初始化一次的动作。// store/modules/permission.ts import { defineStore } from pinia import { generateDynamicRoutes } from /router/dynamic export const usePermissionStore defineStore(permission, { state: () ({ isDynamicRoutesAdded: false, dynamicRoutes: [] as RouteRecordRaw[], }), actions: { async buildDynamicRoutes(menus: MenuData[]) { const routes await generateDynamicRoutes(menus) this.dynamicRoutes routes this.isDynamicRoutesAdded true return routes }, reset() { this.dynamicRoutes [] this.isDynamicRoutesAdded false } } })守卫里这样写// router/index.ts import { usePermissionStore } from /store/modules/permission import { useUserStore } from /store/modules/user const whiteList [/login] router.beforeEach(async (to) { const userStore useUserStore() const permissionStore usePermissionStore() if (!userStore.token) { if (whiteList.includes(to.path)) return true return /login?redirect${encodeURIComponent(to.fullPath)} } if (userStore.token to.path /login) { return / } // 关键动态路由只初始化一次 if (!permissionStore.isDynamicRoutesAdded) { try { // 用户信息在登录时已经存进 store这里直接取 const menus userStore.menus const routes await permissionStore.buildDynamicRoutes(menus) routes.forEach((route) router.addRoute(route)) // 重新进入一次当前导航让新添加的路由生效 return { ...to, replace: true } } catch (error) { // 拉取菜单失败清空登录态回登录页 await userStore.resetToken() return /login?redirect${encodeURIComponent(to.fullPath)} } } return true })这里有一个非常容易踩的坑拿到动态路由之后第一次导航的to对象其实已经匹配完了如果当前访问的是刚加进去的路由直接return true是不会正确渲染的。必须return { ...to, replace: true }强制触发一次新的导航让路由匹配结果刷新。我第一次写这块逻辑时漏了这一步结果登录后跳转首页正常一刷新首页就白屏排查了半天才发现是这个问题。2.4 为什么 404 路由总是拦在最前面动态路由另一个高频坑是明明 addRoute 成功访问/system/user却落进了 404 页面。原因在于通配路由/:pathMatch(.*)*注册得太早。如果你在createRouter的 routes 数组里就写死了 404 兜底路由那么当用户刷新/system/user页面时路由匹配是从已有路由表里找的。此时动态路由还没添加通配路由就接管了。等你的动态路由 addRoute 进去一切已经晚了。解决办法有三种第一404 路由不要放在静态路由表里等动态路由全部添加完后再router.addRoute({ path: /:pathMatch(.*)*, component: NotFound })第二通配路由保留但守卫里判断如果是授权之外且动态路由未初始化时先执行完动态添加逻辑再进入导航第三404 页面的 render 逻辑做特殊处理匹配不到的时候重新跳一次。我个人的习惯是方案一最干净。动态路由添加之后菜单和路由始终是一份完整数据404 永远只在最后兜底。3. 场景二Vite 的 import.meta.glob让路由跟着目录结构走第二种场景和接口没什么关系它是 Vite 项目独有的便利特性路由表根据 views 目录下的文件名自动生成。团队只要约定好规则新增页面 新建文件不用维护任何路由配置。3.1 import.meta.glob 到底做了什么import.meta.glob是 Vite 提供的一个特殊语法它接收一个静态字符串路径返回一个对象。下面这行代码是场景二的核心const pages import.meta.glob(/src/views/**/*.vue)pages 的值大概是这样的{ /src/views/dashboard/index.vue: () import(/src/views/dashboard/index.vue), /src/views/system/user/index.vue: () import(/src/views/system/user/index.vue), /src/views/error/404.vue: () import(/src/views/error/404.vue) }注意两个细节。第一glob 参数必须是一个静态字符串不能用变量拼Vite 是在编译阶段解析它的第二默认返回的是懒加载函数真正点击路由时才去请求组件代码正好契合路由懒加载的需求。还有一个可选参数{ eager: true }作用是把默认值直接变成组件对象而不是函数适合不需要懒加载的场景。我在实际项目里还遇到过 glob 路径写错的案例直接用import.meta.glob(src/views/**/*.vue)少了最前面的/编译时 Vite 会直接把整个字符串作为相对路径匹配结果为空。这个坑虽然提示明显但新手见到pages是个空对象时往往会一脸懵。3.2 从文件路径推导出路由 path文件路径是绝对路径第一步要把它转换成路由地址。我常用的约定是/src/views/dashboard/index.vue对应路由/dashboard/src/views/system/user/index.vue对应路由/system/user/src/views/error/404.vue对应路由/error/404处理逻辑很简单剥掉/src/views前缀去掉.vue后缀再把/index替换成空。完整代码如下function filePathToRoutePath(filePath: string): string { return filePath .replace(/src/views, ) .replace(/\.vue$/, ) .replace(/\/index$/, ) || / } function generateRoutes() { return Object.keys(pages).map((filePath) { const routePath filePathToRoutePath(filePath) return { path: routePath, name: routePathToName(routePath), component: pages[filePath], } }) }name 的生成建议也用 routePath 推导比如把/system/user转成驼峰SystemUser这样保证唯一性同时也能配合keep-alive组件缓存和router.removeRoute。3.3 嵌套路由与 Layout 的处理文件路径推导路由最麻烦的是嵌套关系。如果系统页面都挂在同一个后台布局组件下顶层一定需要一个 Layout 容器。直接遍历生成的扁平路由是行不通的因为 Layout 需要套在所有业务页面外面。我的做法是生成路由时先按顶层目录分组每个顶层目录作为一级路由下面的文件作为它的 children。比如/src/views/system/user/index.vue会生成{ path: /system, component: Layout, children: [{ path: user, component: ... }] }这样/system/user就正确渲染在布局内。function buildRoutesWithLayout(filePath: string, page: any): RouteRecordRaw { const segments filePath .replace(/src/views, ) .replace(/\.vue$/, ) .split(/) .filter(Boolean) if (segments.length 0) { return { path: /, component: page } } if (segments.length 1) { return { path: /${segments[0]}, component: page } } const parentPath /${segments[0]} const childRelativePath segments.slice(1).join(/).replace(/^index$/, ) return { path: parentPath, component: Layout, children: [ { path: childRelativePath || , component: page, }, ], } }不过说实话当你的路由嵌套超过两层同时还涉及动态参数、重定向、命名视图时约定式路由的代码复杂度会快速上升维护成本不一定比手写路由低。所以场景二我建议用在页面结构相对规整、层级固定的中后台项目上。3.4 约定式路由的边界条件场景二看着省事但它有几个明显的边界问题。第一个是动态参数路由比如详情页/article/:id目录结构怎么表达常见做法是约定_id.vue或[id].vue表示动态段然后在生成 path 时做一次转换。第二个是重定向比如/需要跳转到/dashboard约定式路由没法自动表达只能靠额外配置或手动补一条。第三个是命名路由冲突文件名重复会导致 path 重复Vite 编译期不会报错但运行时页面会互相覆盖。我自己在使用场景二时一般会搭配一个page.config.js或单独的路由配置文件来做例外处理约定式路由负责 90% 的常规页面剩下的靠白名单配置补齐。这样既享受了自动化的便利又不至于被约定限制死。4. 场景三基于角色权限的守卫挂载控制谁能看到哪个路由如果说场景一和场景二是路由的生产和组织结构场景三就是路由的访问控制。这也是权限系统的最后一个环节解决的是另外一个问题用户没有某个路由的权限时直接输入 URL 访问怎么办。4.1 权限模型先定好角色、权限码、路由表如何关联做动态路由权限之前先想清楚权限模型。最基础的是 RBAC 模型用户属于角色角色拥有菜单/权限码路由表带权限标签。具体到 Vue 项目里我通常给每个路由的meta添加一个permissions字段存一个权限码数组{ path: /system/user, name: SystemUser, component: () import(/views/system/user/index.vue), meta: { title: 用户管理, permissions: [system:user:list] } }登录之后后端返回当前用户拥有的权限码列表比如[system:user:list, system:role:list]。前端的任务就是检查权限码再过滤动态路由表。这个方案比在 meta 里写 roles 数组更细因为 roles 是粗粒度的两个角色可能同时要访问同一个菜单但按钮级的操作权限完全不同。4.2 路由过滤函数递归过滤静态路由表如果动态路由的来源本身就是一组静态配置比如内部系统菜单固定只是不同角色可见性不同那就不需要走接口菜单那一套直接在守卫里过滤即可function hasPermission(permissions: string[], routePermissions?: string[]): boolean { if (!routePermissions || routePermissions.length 0) return true return routePermissions.some((p) permissions.includes(p)) } function filterRoutesByPermission( routes: RouteRecordRaw[], permissions: string[] ): RouteRecordRaw[] { return routes .filter((route) hasPermission(permissions, route.meta?.permissions)) .map((route) { if (route.children route.children.length) { return { ...route, children: filterRoutesByPermission(route.children, permissions), } } return route }) }这里有一个关键细节meta.permissions不存在的路由也就是没有权限要求的路由默认放行。比如登录页、首页这种基础路由不应该因为某次过滤被误杀。还有过滤完 children 之后父级如果只剩一个空壳没有 children 也没有 component这个父级路由就不应该再保留否则会出现一个没有内容的空白菜单。4.3 守卫中动态挂载的完整流程权限守卫和场景一里的刷新防白屏逻辑是连在一起的。完整流程应该是刷新页面 - 守卫判断 token - 拿到用户信息和权限码 - 检查是否已经挂载过动态路由 - 没挂载就过滤并 addRoute - 重新导航。router.beforeEach(async (to) { const userStore useUserStore() const permissionStore usePermissionStore() if (!userStore.token) { if (whiteList.includes(to.path)) return true return /login?redirect${encodeURIComponent(to.fullPath)} } if (!permissionStore.isDynamicRoutesAdded) { const permissions userStore.permissions const allRoutes await fetchRoutes() const accessibleRoutes filterRoutesByPermission(allRoutes, permissions) accessibleRoutes.forEach((route) { router.addRoute(route) }) permissionStore.isDynamicRoutesAdded true return { ...to, replace: true } } // 已经没有权限访问时落到 404 return true })代码看着简单但有几个细节必须注意。第一fetchRoutes可能走的是接口也可能直接就是从静态模块里取的看你选用场景一还是直接用静态路由表。第二isDynamicRoutesAdded这个标志位一定不能只是随便一个布尔值要放进 store因为守卫是全局的刷新后需要重新初始化。第三登出时一定要调用permissionStore.reset()否则下次登录其他账号时上一个账号的路由还在路由表里会造成权限泄露。4.4 动态路由的重复添加与清理vue-router 4 的addRoute方法设计得比较灵活重复添加同名路由时默认会警告但不报错。如果你在登出时没有 removeRoute下一次登录再 addRoute很容易出现路由重复定义页面渲染异常的诡异 bug。保险的做法是添加之前先判断if (!router.hasRoute(route.name)) { router.addRoute(route) } else { router.removeRoute(route.name) router.addRoute(route) }登出时的清理动作也要做全function resetRouter() { permissionStore.getDynamicRoutes.forEach((route) { if (route.name router.hasRoute(route.name)) { router.removeRoute(route.name) } }) permissionStore.reset() }这个清理动作是我的一个老朋友项目里出的真实事故。当时测试环境切账号切换得比较频繁有同事反馈上个账号的菜单过一会儿又冒出来了排查到最后就是这个原因路由表是全局单例store 清了路由注册记录没清。5. 组合实战一个后台项目里三种方案怎么协同讲了三个场景很多人可能还是会问我到底该用哪种其实商用后台项目里它们三个不是互斥的而是互补的。5.1 三种方案对比怎么选先看一张对比表维度接口菜单驱动文件约定式角色权限过滤路由数据来源后端接口返回views 目录文件前端路由表权限码核心优势菜单可后台配置新增页面零成本访问控制安全核心成本前后端字段约定多额外约定规则需要处理时序与清理适用项目需要运营配置菜单的中大型后台内部系统、原型迭代快所有需要鉴权的系统典型配合与权限过滤组合作为组件映射底座与接口菜单组合在我看来没有绝对的最佳方案只有当前项目阶段最合适的方案。如果你是给内部团队快速搭一个管理工具场景二就够用一个文件就是一个页面省心省力。如果你的系统面向多个客户每个客户看到的菜单不同那必须上场景一把菜单配置权交到运营手里。不管哪种只要涉及登录和不同角色都逃不开场景三的守卫和过滤逻辑。5.2 混合架构静态打底 接口菜单 目录映射组件 守卫拦截我目前维护的中后台项目最后稳定下来的是这样一套混合架构静态路由只放/login、/404、/dashboard这种不需要权限的基础页面用户登录后后端返回菜单树和权限码列表前端用import.meta.glob(/src/views/**/*.vue)建立组件映射再递归把菜单树转成路由记录转出来的路由记录根据权限码再过滤一遍双重保险通过router.addRoute挂载到路由实例上守卫里维护一个isDynamicRoutesAdded用来处理刷新白屏和重复挂载。组件映射、菜单转换、权限过滤这三个函数单独拆成模块互不依赖。以后如果后端接口字段调整只改转换那一层如果组件目录重构只改 glob 映射那一层如果权限模型换了只改过滤函数。代码是长这样的// router/dynamic/index.ts export async function buildDynamicRoutes(menus: MenuData[], permissions: string[]) { const rawRoutes transformMenuToRoutes(menus) // 场景一菜单 - 路由 const filteredRoutes filterRoutesByPermission( // 场景三权限过滤 rawRoutes, permissions ) return filteredRoutes }transformMenuToRoutes 里用的resolveComponent就是场景二的 glob 映射这样三个场景在同一套代码里各司其职。5.3 路由、菜单、面包屑、标签页的联动动态路由挂载是第一步后面还有一堆联动等着处理。菜单要在路由挂载成功后生成面包屑要基于to.matched动态生成标签页要根据路由 name 判断是否缓存。先说菜单。动态路由是通过router.addRoute加到路由实例里的这些路由不会自动出现在router.options.routes里所以你需要自己维护一份菜单数据来源。我的习惯是transformMenuToRoutes 之前把后端菜单树单独存一份到 store菜单组件遍历这份数据渲染登出时清空。如果直接用router.getRoutes()去遍历菜单会遇到两个问题一是路由会带有大量__proto__和内部属性不适合直接绑到视图二是顺序不稳定getRoutes()返回的顺序不是注册顺序不能当作菜单顺序。再讲 keep-alive。动态路由最常被忽略的性能问题就是组件缓存失效。比如从用户管理切到角色管理再切回来每次都会重新请求数据。原因是 keep-alive 的 include 需要匹配路由的 name如果你的动态路由 name 没有生成规范或者每次切换账号时 name 变化了缓存就失效。建议把 name 的生成逻辑固定下来后端菜单里强制要求 name 字段或者用路径做哈希。5.4 一套可以直接复用的动态路由工具函数每次写新项目都要重写一遍动态路由还挺烦的我把最核心的工具函数整理成了一个小模块结构是这样的// router/dynamic/helpers.ts /** 1. 建立组件映射表 */ export function createViewModules() { return import.meta.glob(/src/views/**/*.vue) } /** 2. 根据组件字符串找到对应的懒加载函数 */ export function resolveComponent(component: string, viewModules: Recordstring, any) { if (component Layout) return () import(/layout/index.vue) return viewModules[/src/views/${component}.vue] } /** 3. 菜单树转路由表递归 */ export function transformMenuToRoutes(menus: MenuData[], viewModules: Recordstring, any): RouteRecordRaw[] { // 逻辑见场景一 } /** 4. 按权限码过滤路由表递归 */ export function filterRoutesByPermission(routes: RouteRecordRaw[], permissions: string[]): RouteRecordRaw[] { // 逻辑见场景三 } /** 5. 注册动态路由 */ export function registerDynamicRoutes(router: Router, routes: RouteRecordRaw[]) { routes.forEach((route) { if (route.name router.hasRoute(route.name)) { router.removeRoute(route.name) } router.addRoute(route) }) } /** 6. 清理动态路由 */ export function clearDynamicRoutes(router: Router, routes: RouteRecordRaw[]) { routes.forEach((route) { if (route.name router.hasRoute(route.name)) { router.removeRoute(route.name) } }) }这套工具函数不依赖任何具体的 UI 库和状态库拷到任何一个 Vue3 Vite 项目里都能直接跑。5.5 实测提醒这些坑我建议你提前避开最后把我这几年做动态路由踩过的一些真实问题集中说一下省得大家再走一遍弯路。第一个是接口菜单返回的component字段路径拼错。我在开发时遇到过页面一直白屏控制台也不报错后来打印了resolveComponent的入参才发现后端返回的是system/user/index而我的目录下文件名是SysUser.vue中间隔了一层重命名。这个问题的最好解法是从项目第一天就强制统一目录命名规范后端返回的 component 标识必须严格对应src/views下的文件相对路径。第二个是嵌套路由父级没有组件导致子页面渲染不出来。地址显示正常路由也匹配到了但页面区域空白。排查到最后是父路由没有配置component: Layoutvue-router 会正常匹配但无法渲染嵌套视图。实现动态路由时只要遇到数据有 children 的节点就要确认它的 component 解析到了能渲染子路由的布局组件。第三个是环境变量导致的构建差异。动态路由会用import.meta.glob它是 Vite 的编译期能力在构建测试环境和生产环境时没问题但如果有人把 prod 环境配成vite build --mode test某些环境文件的加载路径可能会影响接口菜单的域名导致登录后动态路由拉取失败页面卡在守卫的 loading 状态里。这个问题和动态路由本身无关但排查起来很容易绕晕建议把路由初始化失败的兜底提示做得明确一点。第四个是动态页面做了缓存后权限变化不生效。比如用户 A 有某个菜单权限访问过后页面被 keep-alive 缓存管理员把 A 的权限收回A 再次访问旧路径时如果动态路由过滤已经把他拦在 404 之外还好怕的是路由还在、页面缓存还在A 直接看到了旧数据。处理方式是在登出时不仅清 store还要调用keep-alive对应的缓存清理逻辑或者在权限变化后强制刷新整个页面。动态路由这套东西每个项目细节都不太一样但核心思路就这些。我自己的经验是第一版先别急着上最重的那套方案用静态路由 权限守卫把项目跑通等后端菜单接口稳定了再把接口驱动那层替换进去。这样前期开发不被接口阻塞后期切换成本也不高。至于场景二的文件约定式单独使用的话清爽组合使用的话作为组件映射底座是目前我在 Vite 项目里最依赖的姿势。