
1. 初识动态路由它到底解决的是什么问题做前端开发的朋友尤其是接触过中大型后台系统、多角色权限平台的几乎都会遇到同一个痛点页面太多了权限不一样菜单不一样总不能把每个用户的每种可能路径都写死在代码里吧。这时候动态路由就跳出来了。先说结论动态路由简单理解就是不把所有页面路径在项目启动时就固定写死而是等用户登录之后根据用户的角色、权限甚至是后端返回的数据再去动态生成一套可访问的路由表。换句话说路由不再是一张提前印好的地图而是根据这个人是谁、他能干什么临时画出来的专属导航。这跟静态路由的区别特别直观。静态路由就是传统多页面或者早期前端框架里的写法一张路由表从头到尾是固定的写死的/home、/about、/user/list谁都能访问一旦涉及权限判断就得在每个页面的生命周期钩子里手动加判断逻辑页面多了以后全是重复代码而且左侧菜单往往还跟不上路由的变化。动态路由则是把路由本身变成了一种可配置、可计算、可回收的资源它可以根据后端下发的菜单权限数据用前端代码在运行时把对应的组件加载进来、挂载到路由表上然后渲染出对应的菜单和页面。那它适合谁来用我个人的判断是只要你的项目里出现了不同角色登录后看到的东西不一样或者菜单不是写死的而是后台可配置的再或者页面权限经常需要运营人员在后台调整动态路由基本就是绕不开的方案。尤其是现在主流的后台管理框架、低代码平台、多租户系统几乎都依赖这套机制。就算你只是个刚学完框架基础的新手理解了动态路由也能把权限管理这块的认知提升一个档次因为它是打通后端权限数据和前端页面渲染的关键桥梁。2. 动态路由的核心设计思路别急着写代码先想清楚三件事很多教程上来就贴代码教你怎么在路由守卫里加判断、怎么用addRoute但你跟着敲完发现换个项目还是不会。问题出在没理解设计思路。动态路由不是某一段代码的事它是一整套流程的设计核心可以拆成三件事路由怎么来、路由怎么加、路由怎么回收。2.1 路由怎么来从静态配置到动态下发先说路由的数据来源。在动态路由方案里前端通常维护两份路由相关的数据一份是基础路由也叫做公共路由或静态路由部分。这部分跟登录状态无关比如登录页、注册页、404页、忘记密码页它们在项目初始化时就会注册好无论用户有没有登录、有没有权限都要能访问。另一份是业务路由的原料。注意这里用了原料这个词因为这份数据往往不是直接可以用的路由配置而是一个个模块的映射关系某个菜单标识对应哪个前端组件、这个组件放在哪个目录下、需要什么权限码等等。这份数据可能有三种来源后端接口直接返回完整的路由配置包括path、component、name、meta等字段前端拿到后稍作加工就addRoute。后端只返回菜单树和权限标识前端维护一张权限标识 - 组件的映射表然后根据返回的菜单树动态生成路由。前端写死一份全量路由表后端返回的只是这个用户能访问哪些路由的 name 或 path 列表前端根据这个列表过滤全量路由表留下有权限的部分再注册。这三种方式里的第二种是我个人最推荐的因为它兼顾了安全性和灵活性。前端不把后端接口数据直接当路由用而是通过一层转换逻辑把权限码映射到组件既避免了后端返回的数据里带太多前端实现细节也方便前端对组件做懒加载的统一处理。2.2 路由怎么加运行时注册才是动态的精髓那拿到这份原料之后怎么把路由真正加进去这里就涉及到框架层面的核心能力了。以 Vue Router 4 为例它提供了一个极其关键的方法 ——router.addRoute()。这个方法可以在路由实例已经创建之后继续往里面添加新的路由记录而且是响应式的添加完之后router.getRoutes()马上能拿到最新的路由表页面也能立刻访问新增加的路由。这个能力是动态路由能落地的技术底座。因为有了它你才可以在用户登录之后拿着接口返回的权限数据循环遍历、逐条注册把原本不存在的路由注入到应用里。更妙的是它还支持嵌套路由的添加方式比如router.addRoute(parentName, childRoute)可以往某个已有的父路由下面挂子路由。但这里有个很关键的细节什么时候加。你把路由加早了不行因为用户还没登录拿不到权限数据不知道该加哪些加晚了也不行用户登录成功后马上刷新页面、直接访问某个深链接这时候路由还没注册完就会白屏或者跳到404。所以业界普遍的做法是在全局前置守卫beforeEach里做这件事第一次进入时如果发现当前用户已经登录但动态路由还没生成过就先阻塞导航、拉取权限数据、动态注册路由注册完成后再把原始的完整路径next(to.fullPath)放行而不是next()这样能确保刷新后直接访问深层页面的场景不挂。拿 Pinia 或者 Vuex 这类状态管理配合就更顺了。用一个全局变量记录动态路由是否已添加比如isDynamicRouteAdded在守卫里判断如果为false就执行添加流程并置为true否则直接放行。这个布尔值就是整个动态路由流程的开关很多项目里的权限 bug 其实都出在这个开关的状态没有被正确维护上。2.3 路由怎么回收防退出登录后的权限残留动态路由还有一个很容易被忽略的点退出登录后的路由清理。这是我在很多项目里看到过的坑。用户A拥有管理员权限动态注册了/admin相关的一堆路由然后退出登录。如果不清除这些动态添加的路由换用户B登录B的权限数据回来之前/admin依然存在于路由表里B甚至可以直接在地址栏输入这个路径访问到页面就造成了越权。怎么回收Vue Router 4 里提供了router.removeRoute()方法但要想高效回收最好在添加路由时把路由的名字或者返回的移除函数记下来。addRoute这个方法有个小特点它执行后会返回一个移除函数直接调用就能移除刚添加的路由。如果你走的是一次性批量添加可以考虑用一个专门的数组或者 Set 来记录所有动态添加的路由 name退出登录时遍历这个记录逐个调用removeRoute清掉再把isDynamicRouteAdded重置为false。如果是 Vue Router 4还有一种更省心的办法直接把动态路由相关的路由表整体移除但实际项目里大部分人会选择维护一个需要清理的路由 name 列表虽然麻烦一点但胜在精细可控。2.4 想一想这跟菜单渲染到底是什么关系很多初学者会把动态路由和动态菜单搞混以为是同一个功能。其实它们是两个东西但又是密不可分的两兄弟。动态路由解决的是这个路径能不能被访问的问题动态菜单解决的是界面上显示哪些入口的问题。一般项目里的联动逻辑是后端返回菜单树前端根据菜单树渲染左侧导航同时根据同一份数据过滤路由表、动态注册路由。所以菜单和路由是同一份权限数据在展示层和导航层的两种表现形式中间用权限码来关联。想通了这一层你再去看那些后台框架的动态路由代码就会发现套路都非常相似先定义一份路由映射表再在守卫里根据用户权限数据做过滤和注册最后维护一个全局状态来标记是否已经初始化过动态路由。剩下的就是细节差异和性能优化了。3. 动态路由的实战落地从权限数据到页面渲染的完整链路讲完了设计思路下面进入实操环节。我会以一个典型的后台管理系统为例把动态路由从登录到页面渲染的完整链路串一遍。这里用的技术栈是 Vue 3 Vue Router 4 Pinia但核心思想放到 React Router 里也一样适用React 里对应的能力是useRoutes配合动态生成路由对象。3.1 第一步设计好路由的原料结构动态路由的第一步不是写代码而是和后端约定好权限数据的结构。我建议的菜单接口返回结构大致是这样[ { name: Dashboard, path: /dashboard, component: dashboard/index, meta: { title: 工作台, icon: home, permission: dashboard:view }, children: [] }, { name: System, path: /system, component: layout, redirect: /system/user, meta: { title: 系统管理, icon: setting, permission: system:view }, children: [ { name: SystemUser, path: user, component: system/user/index, meta: { title: 用户管理, permission: system:user:list } } ] } ]这里的component字段注意一下它不直接是组件对象而是组件文件的路径字符串。因为前端要按需加载一般在 Vite 或者 Webpack 环境里配合import.meta.glob或者require.context做映射。我推荐在项目里维护一个modules.ts文件集中管理路径字符串到组件对象的映射关系// 使用 Vite 的 import.meta.glob 批量导入页面组件 const modules import.meta.glob(../views/**/*.vue) export function loadView(componentPath: string) { // componentPath 形如 dashboard/index const fullPath ../views/${componentPath}.vue return modules[fullPath] }这里的loadView就是动态路由能实现懒加载的关键。如果不用这种方式你得把所有组件手动 import 进来做成一个大对象路由一多维护起来就难受了。用import.meta.glob的好处是页面文件加了一个新组件不需要手动修改映射表只要目录结构规范它就能自动找到。3.2 第二步区分静态路由和动态路由项目里需要维护两份路由表。一份是静态路由在创建 router 实例时直接注册// routes/static.ts export const staticRoutes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录, hidden: true } }, { path: /404, name: NotFound, component: () import(/views/error/404.vue), meta: { title: 页面不存在, hidden: true } }, { path: /, name: Layout, component: () import(/layout/index.vue), redirect: /dashboard, children: [] } ]另一份是路由原料的映射关系比如每个业务模块的组件路径、权限码等这份数据存放在前端等后端下发菜单之后做匹配使用。这里还有一个细节静态路由里的Layout主框架可以提前注册但它的children先留空等动态路由生成后再通过router.addRoute(Layout, childRoute)挂到它下面。这个做法可以保证不管用户是谁进入系统后都能先看到主框架的布局侧边栏、顶栏只是里面的菜单项和可访问页面根据权限动态变化。3.3 第三步在 Pinia 里管理用户权限和动态路由状态动态路由的核心状态有三个用户信息、菜单树、动态路由是否已添加。我用一个usePermissionStore来统一管理// stores/permission.ts import { defineStore } from pinia export const usePermissionStore defineStore(permission, { state: () ({ menus: [], isDynamicRouteAdded: false }), actions: { async generateRoutes() { // 调用接口获取菜单权限数据 const res await api.getUserMenus() this.menus res.data // 把菜单数据转换为可注册的路由记录 const accessRoutes convertMenusToRoutes(this.menus) accessRoutes.forEach(route { router.addRoute(Layout, route) }) this.isDynamicRouteAdded true }, resetPermission() { this.menus [] this.isDynamicRouteAdded false } } })convertMenusToRoutes是核心转换函数它的作用是把后端返回的菜单原数据加工成 Vue Router 可以识别的路由记录对象。这个函数一般包含这几件事把component字符串通过loadView转换成真正的组件对象处理children递归调用生成嵌套路由根据meta.permission等字段补充路由元信息方便后续做按钮级权限校验过滤掉hidden类型的菜单项这些是只用于权限注册、不展示在菜单里的隐藏路由3.4 第四步在路由守卫里挂载动态路由路由守卫是整个动态路由流程的总调度。我推荐的完整逻辑如下router.beforeEach(async (to, from, next) { const userStore useUserStore() const permissionStore usePermissionStore() if (!userStore.token) { if (to.path /login) { next() } else { next(/login?redirect${encodeURIComponent(to.fullPath)}) } return } // 已登录且访问的是登录页直接跳首页 if (to.path /login) { next(/) return } // 用户信息为空先拉取用户信息 if (!userStore.userInfo) { await userStore.getUserInfo() } // 动态路由已添加直接放行 if (permissionStore.isDynamicRouteAdded) { next() return } try { // 动态生成路由 await permissionStore.generateRoutes() // 放行时使用 to.fullPath避免路由未完全匹配导致的空白页 next({ ...to, replace: true }) } catch (error) { // 权限数据加载失败跳登录并提示 userStore.resetToken() next(/login?redirect${encodeURIComponent(to.fullPath)}) } })这里我特别想强调next({ ...to, replace: true })这个写法。很多教程里写的是next()但在动态路由场景下会出问题用户登录后直接访问/system/user此时第一次导航进来时该路由还没注册to匹配到的是默认的 404 页。虽然你在守卫里加了动态路由但如果直接next()Vue Router 仍然会使用当前已经匹配好的导航记录也就是仍然停在 404 页面。用next({ ...to, replace: true })相当于强制重新触发一次导航让路由重新匹配一遍这时候动态路由已经注册好了自然就能正确渲染。3.5 第五步退出登录时的清理逻辑清理逻辑我这里再展开讲一下因为这是实际项目里最容易出问题的环节。退出登录时至少要做三件事调用登出接口清除服务端会话。清空本地 token 和用户信息。重置权限状态移除动态添加的路由。其中第三件事需要配合路由记录操作。我常用的方式是维护一个dynamicRouteNames数组在generateRoutes里每添加一个路由就把它name存进去退出时遍历调用router.removeRoute(name)export function resetDynamicRoutes() { dynamicRouteNames.forEach(name { if (router.hasRoute(name)) { router.removeRoute(name) } }) dynamicRouteNames.length 0 }这里要说明一下为什么不直接用router.getRoutes()全量清空再重新注册静态路由因为getRoutes()拿到的是扁平化后的路由记录列表你没法轻易区分哪些是静态路由、哪些是动态路由而且直接清空会影响静态路由的稳定。维护一个独立的 name 列表反而清晰可靠。4. 动态路由的关键细节路由守卫、滚动行为与懒加载的配合把动态路由跑通只是第一步真正让它在生产环境里稳定运行还需要处理几个容易踩坑的细节。我挨个说。4.1 动态添加的路由与scrollBehavior的冲突动态路由注册后页面切换时如果发现滚动条位置没有重置或者滚动行为异常很多人会以为是样式问题其实是scrollBehavior函数内拿到的to对象在某些场景下有问题。因为next({ ...to, replace: true })触发的是重导航to对象包含完整路径信息如果滚动行为函数依赖savedPosition可能出现保存的位置信息对应的是上一次未加载成功的路由。我的做法是在scrollBehavior里增加一个判断如果to.meta.scrollToTop为真或者路由是第一次加载成功就强制回到顶部。4.2 懒加载组件加载失败的兜底处理动态路由几乎必然配合组件懒加载。但懒加载有个风险如果页面文件被误删、或者打包后文件名哈希变化但路由表还是旧数据组件加载会抛错页面直接白屏。建议在路由守卫外层加一个全局的错误处理或者在loadView里对import结果做一层容错。比较稳妥的做法是定义一类异常页面路由当loadView找不到组件时返回一个统一的错误页组件而不是直接throw。这个细节在生产环境里真的能救命尤其是多团队协作、频繁改分支的项目里。4.3 动态路由与权限码的二次校验最后提醒一点动态路由只是前端路由层面的权限控制它本质上是一种体验优化不是安全防线。用户如果技术能力足够依然可以通过修改前端代码、调试接口等方式绕过前端限制拿到他本不该看的数据。所以真正的权限校验必须放在后端接口层面每个接口都要校验用户是否有对应权限码。前端动态路由只是让合法用户用得更顺畅让没有权限的页面不出现在菜单里、不可直接访问它能挡住普通用户挡不住刻意攻击者。5. 动态路由的进阶玩法角色切换、按钮级权限与动态菜单联动说完了基础实现我再聊几个动态路由在真实项目里的进阶场景。这些内容不是必须做的但在很多中大型项目里都会碰到。5.1 同一用户切换角色时如何刷新路由有一种场景一个用户有多个角色系统支持在界面上切换当前角色比如从运营切到管理员切换后菜单和可访问页面需要立即刷新。实现思路是在切换角色的 action 里重置permissionStore然后重新拉取该角色下的菜单数据重新生成路由。这里必须注意重置动作里一定要先清掉旧的路由否则新旧路由叠加会出现path冲突报错而且菜单数据也会错乱。5.2 按钮级权限与动态路由的联动菜单级权限用动态路由解决后页面内部的按钮权限一般也有对应的权限码。比如新增用户按钮只有system:user:add权限码的用户才显示。这里常见的做法是在登录时把用户的完整权限码数组存到状态管理里然后在页面上通过一个自定义指令或工具函数判断当前用户是否拥有某个权限码。这套逻辑可以复用上面动态路由的权限数据结构只是粒度更细一层。5.3 多级菜单展平与嵌套路由的取舍如果项目里的菜单层级很深比如三级、四级菜单动态路由的嵌套结构会变得很复杂。这里有个工程化的取舍大多数后台框架会把三级及以上菜单做展平处理也就是侧边栏里显示父子层级但路由层面把深层嵌套拍平到最后一级组件避免一路children嵌套导致路由匹配和面包屑逻辑难以维护。做动态路由时建议一开始就约定好菜单层级最多两级如果业务上一定要多级就考虑展平方案不要无脑递归。6. 常见问题与排查技巧实录最后整理一份我在实际项目里积累的动态路由问题排查清单都是很常见的坑按症状给排查思路。6.1 刷新页面就白屏或跳到 404这是动态路由最经典的问题原因基本就是上面提到过的动态路由还没注册完首次导航就已经匹配到 404 了。排查顺序如下确认路由器守卫里next时机是否正确是否用了next({ ...to, replace: true })。确认isDynamicRouteAdded标记是否在路由注册完成后才置为true。确认页面刷新后 token 是否还存在用户信息是否重新拉取到了。确认动态路由注册动作是否被await完整执行有没有异步没有等完就next()了。6.2 退出登录后重新登录菜单和路由异常这是路由清理没做干净的症状。排查思路确认退出的清理逻辑是否完整调用了resetDynamicRoutes()。确认permissionStore里的menus和isDynamicRouteAdded都重置了。确认重新登录时用户信息变更后动态路由确实重新生成而不是因为某个全局状态没刷新导致沿用旧数据。6.3 动态添加的嵌套路由不生效比如你在Layout父路由下添加子路由但访问时子页面渲染不出来。排查思路确认addRoute的父路由name是否正确addRoute(Layout, childRoute)的父级名称必须和静态路由里定义的一模一样大小写敏感。确认子路由的path是否写成了绝对路径/user如果父路由是/system子路由应该写成相对路径user最终完整路径才是/system/user。确认父路由组件里有router-view /这是嵌套路由能不能渲染出来的必要条件。6.4 动态路由添加成功但菜单没显示菜单数据是后端返回的路由是前端动态注册的二者常见的不同步原因菜单组件里的路由生成逻辑和动态路由的转换逻辑没有共用同一份数据源导致菜单里要显示的项被过滤掉了。菜单树的hidden字段没有正确传递给菜单渲染组件隐藏路由被当成了展示路由。渲染菜单的逻辑里没有递归处理多级子菜单导致只有一级菜单显示出来。顺便分享一个排查技巧在动态路由添加完成后打开开发者工具执行router.getRoutes()先确认路由表里到底有没有这条路由。路由表有、页面也能访问那问题一定出在菜单渲染层路由表都没有那就说明问题出在权限数据拉取或者转换函数上。用这种方式能快速缩小问题范围不用瞎猜。6.5 动态路由在刷新后闪烁一下菜单这个问题很多团队没注意到但体验上确实有感知。原因是因为刷新后先渲染了静态框架此时菜单为空等权限数据异步加载完、动态路由注册完菜单才突然出现视觉上就是闪烁。解决办法有几个方向要么在动态路由生成完成前不渲染主体布局区域而是显示一个全屏 loading要么把菜单渲染的数据源先初始化为空数组在权限数据返回后再给菜单组件传值同时用v-if控制菜单区域的渲染时机。我个人更推荐前者加一个全局的加载状态简单可靠。7. 写在最后的个人建议项目里做动态路由最容易出现的问题往往不是技术本身而是设计时没有把路由和权限的边界梳理清楚。我见过不少团队后端把页面组件路径直接发给前端前端拿来就用看起来效率很高但后续稍微改动前端目录结构后端接口就得跟着改耦合得死死的。如果重来一次我会坚持让后端只负责下发权限标识和菜单层级这类业务数据前端自己维护权限标识到组件的映射关系。这样前后端各管各的哪一边变动都不会牵连另一边。另外新手做动态路由时不要一上来就追求复杂的权限模型先把登录 - 拉菜单 - 注册路由 - 渲染页面 - 退出清理这条主链路跑通再加按钮权限、多角色切换、菜单展平等进阶功能。主链路不扎实后面全都是坑。等你真的把这条链路吃透了你会发现自己对框架路由体系、权限设计、甚至前端工程化的理解都上了一个台阶。