2026/9/19 3:37:25

Vue Router 路由配置实战:从 history 模式到动态权限拦截的完整指南

Vue Router 路由配置实战:从 history 模式到动态权限拦截的完整指南 1. 路由表的基础结构history模式与routes数组的初始化Vue路由配置看着简单真正上手就会发现多数坑都藏在最基础的那几行初始化代码里。先说说项目的路由是怎么被加载成页面的。Vue Router把URL地址解析成对应的组件再把组件渲染到页面上这个过程的起点就是createRouter创建的路由实例。选history模式还是hash模式是很多人第一次踩的坑。hash模式用得省心地址栏带个#号刷新页面不会出问题因为#后面的内容本质上是浏览器端的状态。history模式地址干净好看但刷新时服务器必须把任意路径都重定向到index.html否则就会出现经典的404问题。在开发环境里Vite替我们做了这件事生产环境部署到Nginx时需要加这样一段配置location / { try_files $uri $uri/ /index.html; }这段配置的意思是当服务器找不到对应的静态文件时就返回index.html交给前端路由去解析。这个配置没做好生产环境刷新页面就白屏这是一条真实存在的生产事故链。接下来看基础配置。一套能直接跑起来的路由表包含了路由的三种核心要素路径、组件引用、路由元信息。这里给一份我在脚手架项目里常用的初始化结构import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue const routes [ { path: /, name: home, component: Home, meta: { title: 首页, requiresAuth: false } }, { path: /user, name: user, component: () import(../views/User.vue), meta: { title: 用户中心, requiresAuth: true } } ] const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) router.beforeEach((to, from, next) { document.title to.meta.title ? ${to.meta.title} - 项目名 : 项目名 next() }) export default routermain.js里通过app.use(router)注册路由实例这一步是Vue Router的插件机制在起作用它会往全局注入两个组件RouterView和RouterLink同时在组件实例上挂载$router和$route。组件引用方式分成两种静态引用和动态引入。import Home是静态引用组件代码打进主包首屏就要加载() import(...)是动态引入Vite会把它单独切分成一个chunk访问到这个路由时才加载对应模块。所以主页面用静态引用二级页面全部用动态引入这是路由表设计里的基本素养。2. 路由参数传递与接收query、params、动态路由三种玩法路由参数是日常开发里躲不掉的部分Vue Router提供了三种传参方式适用的场景完全不同混着用容易翻车。query方式就是URL问号后面的参数形如/user?id123。接收方用route.query.id来拿值。query参数的特点是会暴露在地址栏里能够被分享、被收藏刷新之后依然有效。适合做筛选条件、搜索关键词、分页页码这些需要保留在URL中的状态。跳转时用router.push({ path: /list, query: { page: 1 } })。这种方式不用在路由表里预定义非常灵活想传几个传几个。params方式则完全不同。它的参数直接嵌在路径里形如/user/123。要在路由表里预先定义好占位符{ path: /user/:id }。跳转时用两种写法都可以router.push({ name: user, params: { id: 123 } }) // 或者 router.push(/user/${123})接收方用route.params.id拿值。这里有一个容易忽视的细节如果用{ path: /user, params: { id: 123 } }这种写法params是不会被带上的因为path直接指定了字符串路径Vue Router就不再拿params做路径拼接了。这是很多人写了半天参数没传过去的头号原因。正确做法是用name加params的组合或者直接拼字符串路径。动态路由addRoute是第三种玩法多用于权限控制的场景。后端返回当前用户有权限访问的菜单前端把菜单对应的路由组件动态地挂进路由表function addDynamicRoutes(menus) { menus.forEach(menu { router.addRoute({ path: menu.path, name: menu.name, component: () import(../views/${menu.componentName}.vue) }) }) }这种写法解决了静态路由表无法支撑权限差异的问题但也有个显著的坑动态添加的路由在页面刷新后会消失因为路由表是内存状态刷新后初始化回默认值。解决办法是把用户的菜单信息存到localStorage或者Pinia持久化里刷新后在路由守卫里判断是否需要重新注册动态路由。还有一个高频场景是路由参数变化时组件不刷新。举个例子从/user/1跳转到/user/2Vue Router会复用同一个组件实例组件的created钩子不会再次执行页面就停在旧数据的显示上。用watch监听route.params的变化来解决import { useRoute } from vue-router import { watch } from vue const route useRoute() watch(() route.params.id, (newId, oldId) { fetchUserDetail(newId) })这样参数变了就重新拉数据页面表现就正常了。3. 路由拦截器的配置逻辑不做权限拦截的路由等于白写路由拦截器的官方名称是导航守卫用一句话说明它的能力在路由跳转发生前和发生后插入自定义逻辑。最常用的是router.beforeEach每次导航被触发时解析完目标路由后调用。加上router.beforeResolve和router.afterEach分别负责解析完成的节点和导航确认后的节点。对于大多数后台管理项目beforeEach承担了登录态校验和权限校验。一段典型的拦截逻辑router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } next() })这段代码的逻辑是目标路由标记了需要登录requiresAuth为true且本地没有token就强制跳转到登录页同时用query参数记录用户原本想去哪登录成功后跳回原页面。这个设计模式在权限系统里非常常用。afterEach在导航确定之后执行但它拿不到next函数也改不了导航结果。它适合做页面埋点、设置标题、滚动位置复位这些事router.afterEach((to, from) { if (window._hmt to.path.startsWith(/detail)) { window._hmt.push([_trackPageview, to.fullPath]) } })写了权限守卫之后最多的报错是守卫死循环。这个错误的表现是页面一直跳转、浏览器报Maximum call stack size exceeded或者路由跳转超过重试次数。常见原因是在守卫里做了重定向但重定向的目标路由自身又触发了同样的守卫逻辑形成了A跳B、B跳A的循环。举一个实际会踩到的例子router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { next({ path: /login }) return } next() })这段代码的问题在于当用户访问/login页面且没有token时每次访问都满足!token条件于是next({ path: /login })把自己重定向给了自己触发死循环。修复方式是在重定向前判断目标路由是不是已经是登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next({ path: /login }) return } next() })另外守卫里如果涉及异步操作比如从后端拉取用户权限必须保证异步完成后再调next()。在异步回调里调next()时要注意一旦调用了next()就不应该再执行其他逻辑否则多个next调用会引发不可预期的行为。4. 404兜底、懒加载与滚动行为这些细节配置决定了路由质量很多人写路由表时把通配路由放在数组最后面成了习惯但为什么必须放最后原因是Vue Router匹配路由时按数组顺序查找通配路由/:pathMatch(.*)*能匹配任意路径如果它排在最前面后面所有精确路由就都失效了。所以通配路由永远放在数组最后这个是铁律。404兜底的完整配置在Vue Router 4.x的写法是{ path: /:pathMatch(.*)*, name: not-found, component: () import(../views/NotFound.vue), meta: { title: 页面不存在 } }/:pathMatch(.*)*这个写法有点特殊pathMatch是参数名:pathMatch表示捕获剩余路径(.*)*里的星号让这个参数变成可选的避免在某些场景下匹配出问题。如果只写/:pathMatch(.*)在路由跳转时传给通配路由的参数会丢失所以推荐带两个星号的写法。路由懒加载在Vue 3项目里已经是标配。它的原理是Webpack或者Vite在构建时把动态import的模块单独打包浏览器在首次加载时不请求这个模块只有真正跳转到对应路由时才发请求。对首屏性能的改善非常直观——首屏加载的时间主要取决于主包体积把页面组件全部打散成独立chunk之后主包可以控制在一个很小的体积。给懒加载一个适合项目的分块策略const routes [ { path: /list, component: () import(../views/ListPage.vue), children: [ { path: detail, component: () import(../views/DetailPage.vue) } ] } ]这里的建议是同一模块的页面放在相同目录结构下import路径保持清晰。还有个小技巧在import的注释里写/* webpackChunkName: list */可以自定义chunk名称便于构建后分析哪些页面被单独打包了。Vite下则是通过rollupOptions.output.manualChunks来配置效果类似。路由滚动行为是很多项目没有配置但用户体验差别很大的点。从页面底部跳转到另一个页面时新页面默认从顶部开始显示但在列表页跳转详情页再返回列表页的滚动位置往往已经重置到顶部。配置scrollBehavior可以在路由切换后恢复或者重置滚动位置const router createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } return { top: 0 } } })savedPosition在用户通过浏览器前进/后退按钮跳转时会被浏览器自动记录直接返回它就能恢复之前的滚动位置。对于tab切换、返回列表页这类交互这个配置能省掉很多手动管理滚动位置的代码。5. 嵌套路由与命名视图复杂布局下路由配置的进阶玩法后台管理项目的页面结构基本都是统一的顶部导航、左侧菜单、右侧内容区。这种布局在路由配置里天然适合嵌套路由来处理。外层路由组件放布局骨架内层路由组件放具体业务页面。嵌套路由的配置结构{ path: /layout, component: () import(../layout/index.vue), redirect: /layout/dashboard, children: [ { path: dashboard, component: () import(../views/Dashboard.vue), meta: { title: 工作台 } }, { path: user, component: () import(../views/User.vue), meta: { title: 用户管理 } } ] }外层组件里必须包含RouterView /子路由的内容才会被渲染到这个插槽里template div classlayout Sidebar / div classmain Header / RouterView / /div /div /template嵌套路由的一个细节问题子路由的path是否带/。不带/时是相对路径比如上面的dashboard最终完整路径是/layout/dashboard如果写/dashboard则完整路径变为/dashboard彻底脱离了父路由的路径层级。而且带/的子路由对应的组件还会渲染到子层级但因为路径变化导致父级组件被卸载布局就没了。这个区别用一句话记住子路由相对路径不是绝对路径。外层路由默认打开时redirect字段指定重定向到的默认子路由解决了访问/layout时没有匹配子路由的问题。命名视图则是另一种思路它允许一个路由下同时渲染多个组件到不同位置。写法上把原来的component换成components注意复数{ path: /search, components: { default: () import(../views/SearchResult.vue), sidebar: () import(../views/SearchSidebar.vue), footer: () import(../views/SearchFooter.vue) } }在布局组件中配合使用多个具名RouterViewRouterView / RouterView namesidebar / RouterView namefooter /默认的RouterView /对应components里的default键。这个玩法适合页面不同区域分别管理、不同模块独立组件的场景但一般情况下嵌套路由足够用了命名视图用得不多。嵌套路由跟权限拦截配合时还有一个常见的需求子路由都要求同一份权限校验。这种场景可以在外层路由的meta上设置requiresAuth: true在beforeEach里判断to.matched.some(record record.meta.requiresAuth)就行了。to.matched是一个数组包含了从根路由到目标路由的所有嵌套层级记录这样就不用在每个子路由上都单独标一次权限。实际做菜单权限时还会碰到动态路由结合嵌套路由的情况。动态注册嵌套子路由往往要先把父路由注册好再利用父路由的name来挂子路由router.addRoute(layout, { path: report, component: () import(../views/Report.vue), meta: { title: 报表中心 } })此时子路由需要嵌套在名为layout的父路由下这个语法比直接嵌套在routes里灵活适合做后端驱动的动态菜单权限系统。技术选型层面的经验Vue Router的路由表结构设计和项目规模直接相关小型项目用扁平路由表加query传参够了中大型后台系统才需要嵌套路由加动态路由权限控制。不要一上来就上复杂设计等实际遇到布局和权限需求再上嵌套层级维护成本会低很多。