Vue Router 4 实战:从基础配置到动态权限路由 写了几年前端也面试过不少候选人我发现一个比较普遍的现象很多人说起 Vue Router 或 React Router第一反应是“这不就配个 path 和 component 嘛有啥好讲的”。但真到业务落地时动态路由、权限拦截、刷新后路由丢失、二级页面 keep-alive 失效、微前端子应用路由冲突……随便一个需求就能把人卡住很久。网上的资料其实不少但大多只讲某个点比如单独讲 addRoute或者单独讲路由守卫很少有文章把“从基础配置到权限路由落地”完整串起来。这篇博文我打算用 Vue 3 Vite Vue Router 4 作为主线从基础配置开始逐步讲到动态路由、路由守卫、权限菜单、刷新恢复、组件复用不刷新等真实项目里绕不开的知识点同时简单对比 React Router 的差异和选型思路。如果你只会写“简单配置路由”或者写过一点但缺乏系统整理这篇文章很适合你。读完你可以理解路由在项目里的真正职责也能独立实现一套基于后端菜单的权限路由方案。部分代码示例需要根据你实际的项目版本微调但核心思路是通用的。1. 路由到底在解决什么问题不止是 path 和 component1.1 前端路由的本质先看一个基本问题前端路由到底是什么在 Web 开发早期页面跳转是服务器根据 URL 返回新 HTML每次跳转都是整页刷新。后来单页应用SPA出现我们希望页面切换不再刷新整个浏览器窗口而是通过 JavaScript 动态替换页面内容同时让 URL 能反映当前页面状态支持浏览器前进后退。这一套机制就是前端路由。实现前端路由有两种主流模式Hash 模式URL 中有#例如http://localhost:5173/#/user/123。hash 改变不会触发浏览器向服务器发请求所以部署简单但也导致 URL 不太“干净”。History 模式URL 看起来更像普通地址例如http://localhost:5173/user/123。这种模式依赖 HTML5 History API看起来更正式但部署到 Nginx 或 Tomcat 时要额外配置否则用户直接访问/user/123会 404。很多新手只在开发环境用过 history 模式一上生产就发现白屏问题基本都出在这。1.2 “简单配置路由”和“项目级路由”的差距不少初学者理解的“配置路由”是这样的{ path: /home, component: Home }但真实项目里路由承载的职责远不止“URL 对应哪个页面”路由守卫页面是否允许访问是否已登录是否有权限判断不通过时跳转登录页或 403。动态路由菜单不是写死在前端而是后端返回菜单和权限标识前端动态注册。嵌套路由后台管理页有统一的侧边栏、顶部栏布局子页面渲染在router-view /里这需要 children 路由。路由元信息通过meta记录页面标题、图标、是否缓存、是否需要权限等信息。组件复用用户从/article/1跳到/article/2如果共用同一个详情组件页面数据不会自动刷新。这些点单独看都不难但组合在一起才是项目里真正会遇到的“路由问题”。所以别再满足于“会写 path 和 component”了。接下来的篇幅我会从最小可用示例开始逐步把这些能力全部串起来。2. 环境准备与技术栈说明本文示例以 Vue 3 生态为主组合方式如下Vue 3 Vite Vue Router 4如果你用的是 Vue 2 Vue Router 3部分 API 写法不同但核心设计思路是一致的。React 开发者也不用走开第 6 章我会专门对比 React Router 的差异。创建项目可以使用 Vite 官方脚手架npm create vitelatest vue-router-demo -- --template vue cd vue-router-demo npm install npm install vue-router4安装完成后项目目录大概是src/ ├── main.js ├── App.vue ├── views/ │ ├── Home.vue │ ├── About.vue │ └── Login.vue └── router/ └── index.js示例环境版本不必刻意追求最新重点是演示配置思路。下面我们会从src/router/index.js开始写代码。3. Vue Router 核心能力拆解从配置到传参3.1 基础路由与懒加载先写一个最基础的路由配置// src/router/index.js import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue const routes [ { path: /, name: home, component: Home }, { path: /about, name: about, component: () import(../views/About.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default router然后在src/main.js中注册路由import { createApp } from vue import App from ./App.vue import router from ./router createApp(App).use(router).mount(#app)组件中使用router-view /渲染当前路由对应的页面!-- src/App.vue -- template router-view / /template这里有一个关键点Home使用静态importAbout使用() import()懒加载。懒加载的好处是当前页面只加载当前页的代码块首页打开更快。实际项目中建议大部分页面都做成懒加载打包时会自动按路由拆分成独立 chunk。3.2 嵌套路由与二级路由后台管理系统最常见的结构是整体布局包含侧边栏和顶部栏点击菜单后中间内容区变化但布局组件本身不重新渲染。这种需求用嵌套路由来实现。// src/router/index.js import { createRouter, createWebHistory } from vue-router import Layout from ../layout/Layout.vue const routes [ { path: /admin, component: Layout, redirect: /admin/dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(../views/admin/Dashboard.vue), meta: { title: 工作台, icon: dashboard } }, { path: user, name: UserList, component: () import(../views/admin/UserList.vue), meta: { title: 用户管理, icon: user } } ] } ]注意子路由path不要写/dashboard直接写dashboard即可。父路由的组件里需要放一个router-view /!-- src/layout/Layout.vue -- template div classlayout aside classlayout-sidebar router-link to/admin/dashboard工作台/router-link router-link to/admin/user用户管理/router-link /aside main classlayout-main router-view / /main /div /template这样访问/admin/dashboard时Layout渲染整体框架Dashboard渲染到 Layout 内部的router-view /里。3.3 路由传参query、params 与 props项目开发中列表页跳到详情页、详情页回列表页保留筛选条件这类需求每天都会遇到。query 方式// 跳转时携带 query router.push({ path: /user/detail, query: { id: 123, source: list } })详情页读取const route useRoute() console.log(route.query.id) // 123params 方式// 路由配置 { path: /user/:id, name: UserDetail, component: () import(../views/user/UserDetail.vue) }// 跳转 router.push({ name: UserDetail, params: { id: 123 } })params 和 query 的区别要分清params 是路径参数拼在 URL 路径里query 是查询参数拼在问号后面。另外params 配合name跳转才可靠如果用path跳转params 会被忽略。更推荐的方式是开启props: true让路由把参数作为组件的 props 传入{ path: /user/:id, name: UserDetail, component: UserDetail, props: true }// views/user/UserDetail.vue script setup defineProps({ id: { type: String, required: true } }) /script template div当前用户 ID{{ id }}/div /template这种做法让组件不再依赖useRoute()组件复用性和可测试性更高。3.4 路由重定向、别名与命名视图重定向用于访问旧地址时跳到新页面或者访问父路径时默认展示子页面const routes [ { path: /admin, redirect: /admin/dashboard } ]重定向也可以写成函数比如根据登录状态跳转const routes [ { path: /admin, redirect: () { const token localStorage.getItem(token) return token ? /admin/dashboard : /login } } ]别名是给当前路由额外映射一个访问路径{ path: /home, alias: /index, component: Home }访问/index时同样会渲染Home组件。命名视图用于一个页面需要多个独立出口的场景比如布局页有侧边栏、主内容、底部三个出口const routes [ { path: /demo, components: { default: () import(../views/DemoContent.vue), sidebar: () import(../views/DemoSidebar.vue), footer: () import(../views/DemoFooter.vue) } } ]template router-view namesidebar / router-view / router-view namefooter / /template3.5 监听路由变化解决组件复用不刷新这是很多新手踩坑的点从/article/1点击上一篇跳到/article/2页面 URL 变了但组件内容没变。原因是两个路径共用了同一个组件实例Vue Router 复用了组件没有执行重新挂载onMounted不会再次触发。解决方案是监听路由参数变化。组合式 API 中可以用watch// views/article/ArticleDetail.vue script setup import { ref, watch } from vue import { useRoute } from vue-router import { getArticleDetail } from /api/article const route useRoute() const article ref(null) async function fetchDetail(id) { article.value null const data await getArticleDetail(id) article.value data } watch( () route.params.id, (newId) { fetchDetail(newId) }, { immediate: true } ) /script{ immediate: true }保证第一次进入页面时也执行一次。这种方式适用于详情页、Tab 切换、搜索列表参数变化等场景。如果不想复用组件也可以给router-view /加:keyrouter-view :keyroute.fullPath /这种方式会强制组件重新渲染但代价是组件的内部状态也会被重置需要按实际场景取舍。4. 动态路由与权限路由实战90% 项目都会用到4.1 需求背景后台管理系统几乎都会遇到这样一个需求不同角色登录后看到不同菜单不同账号能访问的页面不同。普通用户不能访问管理页管理员可以看到全部菜单。实现方式通常有两种全部路由写在前端通过权限标识控制显示和访问。后端返回当前用户可访问的菜单和路由前端动态注册。第二种更常见也更安全。它的核心流程是用户登录成功后前端拿到 token。前端请求后端获取用户信息、菜单权限数据。前端把菜单数据转换为 Vue Router 的路由配置用router.addRoute()动态注册。路由守卫中判断“动态路由是否已添加”如果没有则先添加再放行。侧边栏菜单根据同一份菜单数据渲染。4.2 后端返回菜单结构前端动态生成路由假设后端返回的菜单结构如下[ { path: /system, name: System, component: Layout, meta: { title: 系统管理, icon: setting }, children: [ { path: user, name: SystemUser, component: system/User, meta: { title: 用户管理, icon: user } }, { path: role, name: SystemRole, component: system/Role, meta: { title: 角色管理, icon: role } } ] } ]前端准备一个组件映射表因为动态路由配置中的component不能直接传后端返回的字符串需要映射到实际组件// src/router/component-map.js const modules import.meta.glob(../views/**/*.vue) export function loadView(component) { let path ../views/${component}.vue const loader modules[path] if (!loader) { throw new Error(组件不存在: ${path}) } return loader }这里用到了 Vite 的import.meta.glob属于 Vite 特有的语法如果使用 Webpack则对应require.context。然后写一个转换函数// src/router/dynamic.js import { loadView } from ./component-map export function generateRoutes(menus) { const routes [] menus.forEach((menu) { const route { path: menu.path, name: menu.name, component: loadView(menu.component), meta: menu.meta || {} } if (menu.children?.length) { route.children generateRoutes(menu.children) } routes.push(route) }) return routes }最后动态注册const tempRoutes generateRoutes(menus) tempRoutes.forEach((route) { router.addRoute(route) })如果菜单第一层是“布局组件”而不是具体页面转换时需要特殊处理通常约定component: Layout时加载布局组件if (menu.component Layout) { route.component Layout }4.3 路由守卫与登录鉴权动态路由必须结合路由守卫使用目的是在页面跳转前做统一拦截。// src/router/guard.js import router from ./index const whiteList [/login, /404] router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) // 未登录只能访问白名单页面 if (!token) { if (whiteList.includes(to.path)) { next() } else { next({ path: /login, query: { redirect: to.fullPath } }) } return } // 已登录且动态路由已添加直接放行 if (router.hasRoute(System)) { next() return } try { // 获取用户菜单 const menus await getUserMenus() const dynamicRoutes generateRoutes(menus) dynamicRoutes.forEach((route) { router.addRoute(route) }) // 添加完成后重新进入目标路由 next({ ...to, replace: true }) } catch (error) { console.error(加载动态路由失败, error) next({ path: /login }) } })这里有一个容易踩坑的写法动态路由添加完成后直接next()可能会报错因为当前要跳转的路由在beforeEach执行时还不存在。更稳妥的做法是next({ ...to, replace: true })让路由重新解析一次。登录页退出登录时要清除动态路由。Vue Router 4 没有直接提供“删除所有动态路由”的方法常用的处理方式是记录已添加的 route nameconst dynamicRouteNames [] export function addDynamicRoutes(menus) { const routes generateRoutes(menus) routes.forEach((route) { router.addRoute(route) dynamicRouteNames.push(route.name) }) } export function resetDynamicRoutes() { dynamicRouteNames.forEach((name) { if (router.hasRoute(name)) { router.removeRoute(name) } }) dynamicRouteNames.length 0 }退出登录时调用resetDynamicRoutes()避免下一个账号登录后还能看到上个账号的路由。4.4 刷新页面动态路由丢失问题动态路由是运行时通过addRoute添加的页面一旦刷新内存中的路由配置会清空。用户如果刷新一个/system/user页面此时路由表里还没有这个路由页面就会 404 或白屏。解决思路是“刷新后重新注册动态路由”。步骤如下登录成功拿到菜单数据后将它保存到sessionStorage或localStorage。页面刷新后路由守卫在执行时检测到“有 token当前动态路由不存在”。从本地存储恢复菜单数据重新调用addRoute。恢复完成后next({ ...to, replace: true })。这里要特别注意直接存整个路由配置比较麻烦组件字段无法序列化所以通常只存储后端返回的原始菜单 JSON刷新后重新执行转换逻辑。4.5 动态菜单渲染动态注册了路由侧边栏菜单也要跟着变化。菜单渲染的数据源和后端返回的菜单数据保持一致。!-- src/layout/components/SidebarMenu.vue -- template ul li v-formenu in menus :keymenu.name router-link v-if!menu.children?.length :tomenu.path {{ menu.meta.title }} /router-link template v-else span{{ menu.meta.title }}/span ul li v-forchild in menu.children :keychild.name router-link :to${menu.path}/${child.path} {{ child.meta.title }} /router-link /li /ul /template /li /ul /template script setup defineProps({ menus: { type: Array, required: true } }) /script注意子路由的完整路径如果父路由 path 是/system子路由 path 是user那么完整路径是/system/user。5. 约定式路由与配置式路由怎么选5.1 两种方式对比Vue Router 官方默认是“配置式路由”也就是集中在一个routes数组里写路由。这种方式结构清晰、配置明确、可读性好也是大多数 Vue 项目的做法。“约定式路由”则是不用手动维护路由表通过文件目录结构自动生成路由。例如src/pages/ ├── index.vue → 生成 / ├── about.vue → 生成 /about └── user/ ├── index.vue → 生成 /user └── detail.vue → 生成 /user/detail在 Vue 生态里UmiJS 等框架支持这种模式Vite 插件也能实现类似功能。5.2 业务场景选择约定式路由的优势是项目结构规范页面文件即路由新增页面不需要手动配置适合页面数量多、目录结构稳定的中后台项目。缺点是路由和文件强耦合不够灵活复杂的嵌套和权限处理成本更高。配置式路由的优势是路由和组件解耦可以自由控制路由的 meta、权限、嵌套和重定向适合权限管控复杂、需要动态注册路由的企业级系统。我的建议是中小型项目、结构固定的项目用配置式路由更直接大型中后台如果团队规范严格、页面数量庞大可以考虑约定式路由加插件但权限动态路由部分仍然要单独设计。6. Vue Router 与 React Router 差异及选型参考6.1 核心差异React 项目里React Router 是事实标准目前主流是 React Router 6。它和 Vue Router 的差异主要体现在以下几点声明方式不同。Vue Router 通过routes数组配置React Router 6 使用createBrowserRouter创建路由对象再通过RouterProvider注入。路由匹配和渲染方式不同。React Router 把路由匹配结果通过Outlet渲染子路由Vue Router 则是通过router-view /。编程式导航不同。Vue Router 使用router.pushReact Router 使用useNavigate()返回的navigate函数。路由守卫概念不同。Vue Router 有beforeEachReact Router 6 没有官方等价物通常用包裹组件或者useEffect来实现类似鉴权逻辑。React Router 6 的示例import { createBrowserRouter, RouterProvider, Outlet } from react-router-dom function Layout() { return ( div nav菜单/nav main Outlet / /main /div ) } const router createBrowserRouter([ { path: /admin, element: Layout /, children: [ { path: dashboard, element: Dashboard / }, { path: user, element: UserList / } ] } ]) export function AppRouter() { return RouterProvider router{router} / }React Router 的配置更“组件化”没有全局的 beforeEach 钩子鉴权通常这样处理function RequireAuth({ children }) { const token localStorage.getItem(token) if (!token) { return Navigate to/login replace / } return children }6.2 不同业务场景下如何选择选型主要取决于你所在项目的技术栈而不是单纯比较两个库的好坏。如果你已经用 Vue 3 开发优先选 Vue Router 4因为和 Vue 生态的配合更自然例如keep-alive缓存页面、组件内onBeforeRouteUpdate等。如果是新项目且团队技术栈未定可以从两点考虑如果团队更熟悉 Vue 的组合式 API 和响应式状态Vue Router 更顺手。如果团队主要使用 React 生态那么 React Router 的组件化思维和 JSX 写法更一致。在微前端场景下路由问题会更复杂。比如 Vue2 主应用接入 Vue3 子应用子应用路由异常通常表现为跳转后页面空白、路由信息错乱、切换子应用后主应用路由被覆盖。常见原因包括子应用路由 base 未配置、主应用和子应用路由模式不一致、history 和 hash 混用。解决办法是子应用启动时设置base: window.__POWERED_BY_QIANKUN__ ? /子应用名称 : /并在卸载时重置路由实例。7. 常见问题与排查清单下面整理了一些路由实战中高频出现的问题特别是权限路由和部署阶段的问题。问题现象常见原因解决思路刷新页面后 404history 模式部署没有配置 fallbackNginx 配置try_files $uri $uri/ /index.html或后端做 SPA fallback刷新页面后动态路由页面空白动态路由只存在于内存刷新后丢失将菜单数据持久化到本地刷新后重新 addRoute从一个详情页跳到另一个详情页页面内容不变组件被复用onMounted 不重新触发watch 路由参数变化重新拉取数据或给 router-view 加 key登录后跳转正常再次刷新又回到登录页刷新后路由未恢复守卫判断路由不存在而跳转在 beforeEach 中判断“有 token 但无动态路由”时先恢复路由再放行动态 addRoute 后提示路由重复路由已经添加过又重复执行 addRoute添加前用 hasRoute 判断或者记录已添加的 name跳到/system/user显示 404动态路由还没添加完成next 时机过早使用next({ ...to, replace: true })重新进入目标路由子应用路由跳转异常微前端子应用路由 base 未配置或模式冲突按主应用情况配置 base且主应用与子应用尽量使用一致的路由模式浏览器直接访问/system/user白屏静态资源路径不正确base 配置缺失Vite 配置base: /或部署子路径同时 Nginx 配置 fallbackkeep-alive 页面缓存不生效路由 name 与组件 name 不一致确认路由 name 和 defineOptions name 一致否则 keep-alive 无法识别排查顺序建议先确认是本地问题还是部署问题本地问题优先看路由配置和控制台报错部署问题优先看服务器配置和资源路径。8. 最佳实践与工程建议8.1 路由文件按模块拆分不要把一个几十甚至上百条路由全部堆在router/index.js里建议按模块拆分src/router/ ├── index.js # 创建 router 实例 ├── routes/ │ ├── static.js # 静态路由登录、404、首页 │ └── system.js # 系统管理模块 └── guard.js # 路由守卫模块文件导出为路由数组在index.js里合并import staticRoutes from ./routes/static import systemRoutes from ./routes/system const routes [...staticRoutes, ...systemRoutes]8.2 统一约定 meta 字段meta是路由配置里的核心扩展点建议团队统一规范meta: { title: 用户管理, // 页面标题同时用于菜单和 document.title icon: user, // 菜单图标 keepAlive: true, // 是否缓存页面 permission: system:user:list, // 权限标识 hidden: false // 是否在菜单中隐藏 }页面上统一处理标题router.afterEach((to) { document.title to.meta.title ? ${to.meta.title} - 管理系统 : 管理系统 })8.3 权限判断放在路由守卫还是组件内路由守卫适合做全局拦截例如是否登录、是否 404细粒度的按钮级权限建议放在组件内处理不要把所有权限判断都塞进路由守卫。常见的做法是配合自定义指令或工具函数// utils/permission.js export function hasPermission(permission) { const permissions JSON.parse(localStorage.getItem(permissions) || []) return permissions.includes(permission) }button v-ifhasPermission(system:user:add)新增用户/button8.4 性能优化路由懒加载是基本要求但也可以更细component: () import(../views/system/UserList.vue)对于首屏不涉及的大组件、图表组件、富文本编辑器考虑单独分包。如果页面本身加载慢可以配合vite-plugin做按需加载。8.5 部署时的路由配置history 模式部署一定要处理 fallback。Nginx 常见配置location / { try_files $uri $uri/ /index.html; }需要注意如果项目部署在子目录比如/admin/前端路由 base 要配置成/admin/Nginx 的 try_files 也要对应调整。8.6 安全边界动态路由权限判断不要只依赖前端路由隐藏后端接口必须做权限校验。不要把 token 直接暴露在 URL query 参数中避免日志泄漏。前端路由守卫只是体验优化真正的安全边界在后端接口鉴权。退出登录时除了清空 token还要清空动态路由和本地菜单缓存避免账号串线。9. 总结路由是前端项目里连接“URL”和“页面”的枢纽也是中后台系统权限落地的重要载体。从这篇文章可以梳理出几条主线基础层路由模式、懒加载、嵌套路由、命名路由、传参方式。能力层动态路由 addRoute、路由守卫、菜单数据驱动、页面缓存控制。工程层路由文件拆分、meta 约定、部署 fallback、微前端路由隔离。如果你能把动态路由和权限守卫这套流程完整跑通再处理常见刷新丢失、组件复用不刷新、路由重复添加等问题项目里的路由环节基本不会成为你的瓶颈。下一步可以从两个方向继续深入一是结合keep-alive把标签页缓存和路由缓存机制彻底搞清楚二是研究微前端场景下路由隔离与状态同步这也是很多大型系统绕不开的话题。希望这篇文章能帮你省下一些自己摸索的时间如果遇到具体报错也欢迎留言一起交流。