
1. 从跳转不刷新页面说起Vue3 路由到底帮你解决了什么问题做前端这些年我见过不少新人在接触 Vue3 时最容易卡住的一个点明明用window.location.href就能跳转为什么还要折腾一个 vue-router直接改 URL 不是更省事吗这个问题的答案恰好就藏在一个高频热搜里——vue3路由跳转不刷新页面。你可以试一下在 Vue3 项目里用原生的window.location.href从首页跳去列表页结果是整个页面白屏重载应用里存的登录态、滚动条位置、甚至播放到一半的视频进度全部清零。而用路由跳转URL 变了、组件换了页面却没有刷新数据状态还保留着。这就是 vue-router 存在的最根本价值在不刷新的前提下完成视图切换保证单页应用SPA的流畅体验。顺着这个核心需求往下拆Vue3 下的路由跳转要搞明白的事情其实就四件怎么做到跳转不刷新、跳转时怎么带数据过去、目标页面怎么知道我从哪里来、以及在跳转前后如何做拦截和控制。这篇博文不打算写成一册 API 文档而是把我自己实际项目中摸过、踩过、优化过的完整思路整理出来从基础配置到权限控制从参数传递到性能优化每一步都讲清楚为什么这么做。不管你是刚接触 Vue3 的初学者还是在中后台项目里频繁跟路由打交道的开发者这篇文章里的内容应该都能让你少走不少弯路。2. 环境准备与路由模式选型history 和 hash 的差异比想象中更大2.1 初始化项目与安装 vue-router 4Vue3 项目里用的路由必须是 vue-router 4.x这个版本跟上古的 vue-router 3.x 有一个很大的区别3.x 是给 Vue2 用的4.x 才是给 Vue3 用的。很多新手照着网上的旧教程写new VueRouter()结果在 Vue3 里直接报错其实就是这个版本差异导致的。安装命令很简单npm install vue-router4装完之后在src目录下新建一个router/index.js这是最常见的约定式结构。在main.js里完成挂载import { createApp } from vue import App from ./App.vue import router from ./router const app createApp(App) app.use(router) app.mount(#app)这里的app.use(router)做了两件事一是全局注册了router-link和router-view这两个组件二是给每个组件的实例上注入了$router和$route。这意味着你在任意组件里都能通过this.$router.push或者useRouter()拿到路由实例。在 Vue3 的组合式 API 里官方更推荐的做法是在script setup里写import { useRoute, useRouter } from vue-router const route useRoute() const router useRouter()注意route是只读的你想获取当前路径、参数、query都从它身上拿而router是拿来干活的跳转、替换、前进后退都调它的方法。这个分工在后面的代码里会反复体现。2.2 三种路由模式history、hash、memoryvue-router 4 提供了三种模式分别对应createWebHistory、createWebHashHistory、createMemoryHistory。这个选择直接决定了你的 URL 长什么样也决定了跳转不刷新这件事的底层实现方式。先说最常见的 hash 模式。它的 URL 长这样https://example.com/#/user/list。hash 模式的核心原理是监听window上的hashchange事件因为改变#后面的部分页面不会发起新的请求所以天然就不刷新。这种模式的优点是不需要后端配合随便丢到一个静态服务器上就能跑特别适合个人项目、演示项目、或者暂时没条件配置 Nginx 的环境。缺点也很明显URL 里带个#不太好看而且对于需要做 SEO 的公开站点非常不友好搜索引擎对#后面的内容基本是不收录的。再说 history 模式。它利用的是 HTML5 的 History API也就是pushState和replaceState。这两个方法可以修改 URL 而不触发页面刷新同时浏览器会把新的 URL 记录到历史栈里。代码里看到createWebHistory()就是这种模式import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: Home }, { path: /user, component: User } ] })history 模式下的 URL 就是正常路径https://example.com/user/list干净、对 SEO 友好。但它的代价是后端必须做路径重写配置。因为用户可能直接在浏览器里输入https://example.com/user/list访问如果 Nginx 或服务器没有把这个路径重定向到index.html就会返回 404。这一点我在团队协作时反复强调过很多次是有血的教训的。Nginx 层面通常需要这样一段配置location / { try_files $uri $uri/ /index.html; }最后是createMemoryHistory。它的特点是页面跳转完全不会改变 URL模式名里的memory指的是路由历史记录存在内存里。这个模式的典型使用场景是非浏览器环境比如 SSR、单元测试、或者某些需要完全隔离 URL 的环境。平时做常规的 Vue3 页面这个模式基本用不上但要知道它的存在。我个人的项目选型经验是能配后端环境就优先 history配不了就老老实实用 hash。对外展示的官网类项目尽量用 history 并做好 SEO后台管理系统如果部署环境复杂hash 模式反而是最稳妥的不用求运维改配置也不会出现页面一刷新就 404的尴尬。3. 从声明式到命令式四种跳转写法及底层差异分析3.1 router-link模板里的最省心方案在模板里做跳转最简单的方式就是router-link。它会被渲染成一个a标签但和普通a标签最大的区别是它默认拦截了默认的跳转行为改用路由跳转来实现所以点击它不会触发页面的整页刷新。template router-link to/user/list用户列表/router-link router-link :to{ path: /user/detail, query: { id: 101 } }查看详情/router-link /templateto属性可以传字符串也可以传对象。传对象的时候可以携带path、query、params、hash等属性这个在参数传递那一节会详细展开。router-link还有一个非常实用的特性当目标路由匹配成功时当前这个a会自动加上一个router-link-active的 class这对于做侧边栏、Tab 导航的高亮效果来说十分方便。如果你要精确匹配可以用router-link-exact-active或者直接给router-link设置active-class和exact-active-class来覆盖默认类名。3.2 router.push命令式跳转的绝对主力大部分跳转需求都是用router.push完成的。它的语义是向历史栈中推入一条新记录所以点击浏览器后退按钮可以回到上一页。const router useRouter() // 字符串写法 router.push(/user/list) // 对象写法 router.push({ path: /user/list }) // 带 query 参数 router.push({ path: /user/list, query: { page: 2, size: 20 } }) // 带命名路由参数 router.push({ name: UserDetail, params: { id: 101 } })这里有一个经常踩坑的细节当path和params同时出现在对象里时params会被忽略。也就是说// 这样写params 中的 id 不会生效 router.push({ path: /user/detail, params: { id: 101 } }) // 必须用 name params或者拼在 path 字符串里 router.push({ name: UserDetail, params: { id: 101 } }) router.push(/user/detail/${101})为什么会有这个限制因为pushState接收的第一个参数是一个 URL 字符串path会直接作为 URL 的一部分而params是一种抽象参数需要在路由匹配时才能解析。当同时提供path和params时vue-router 会优先按已有 URL 路径走抽象参数自然就没有落点。这算是一个约定比较晦涩的 API 行为面试时也经常被当作考察点。3.3 router.replace防止用户点回上一个页面replace和push的唯一区别是它不会向历史栈推入新记录而是替换当前记录。也就是说点击浏览器后退按钮不会回到上一个页面而是直接回到上上个页面或者干脆没有上一页了。router.replace(/user/list)这个 API 的使用场景非常明确提交类、重定向类页面。比如用户提交了注册表单跳转到成功页。如果你用push用户点击后退会回到表单页而且表单数据可能还在这时候再去提交就可能造成重复提交改用replace后后退就直接跳出流程了逻辑上更合理。同理登录成功后跳转到首页建议也用replace避免用户按后退又回到登录页。3.4 router.go 与 router.back模拟浏览器前进后退这两个方法很直白router.go(1)相当于浏览器前进router.go(-1)相当于后退router.back()等价于router.go(-1)。在 H5 页面里顶部导航栏的返回按钮一般就是用这个实现的。但这里有个不小的坑如果在路由栈的底层再往上退或者在一个新开的浏览器标签页里没有历史记录router.go(-1)会静默失败没有任何反应。很多移动端项目里点击返回没反应排查到最后发现就是这个原因。更稳妥的做法是先用window.history.length判断一下历史记录数量再决定是router.go(-1)还是直接router.replace(/)。4. 路由参数传递与接收query、params、props 的使用边界4.1 query 参数URL 上的明牌query 参数是新手最好理解的方式它直接拼在 URL 的问号后面/user/list?page2size20。传递方式// 传 router.push({ path: /user/list, query: { page: 2, keyword: vue } })接收方式const route useRoute() console.log(route.query.page) // 2注意是字符串 console.log(route.query.keyword) // vue因为 query 参数最终会序列化到 URL 里所以跳转之后即使刷新页面参数也依然存在这是它跟 params 最大的不同。适合传那种可分享、可回写地址栏的数据比如列表页的筛选条件、分页页码、搜索关键字。用户直接把当前地址发给别人别人打开链接也能看到相同的页面状态这是一种很标准的URL 即状态的做法。需要注意route.query读取到的值类型全部是字符串就算传数字进去变成 query 后出来也是字符串处理业务逻辑时该做Number()转换还得做。4.2 params 参数不暴露在 URL 上的暗号params 参数是配合动态路由使用的URL 里会以路径段的形式出现/user/detail/101。定义路由时要用冒号占位const routes [ { path: /user/detail/:id, component: UserDetail } ]跳转时你既可以写成router.push({ name: UserDetail, params: { id: 101 } })也可以直接拼路径router.push(/user/detail/101)接收时const route useRoute() console.log(route.params.id) // 101params 参数是干净 URL方案在 RESTful 风格的接口设计中非常常见。但要注意一个极其隐蔽的坑传递 params 的跳转在目标页面刷新后参数可能丢失。尤其是在打包上线后、Nginx 层没有配置 history 重写的时候用户刷新/user/detail/101可能直接 404或者被重定向到首页导致route.params.id变成undefined。所以 params 最好只用来传长 ID 之类的非敏感但需要区分的数据真正的核心数据传递建议使用状态管理库或者浏览器本地存储。4.3 props让组件与路由彻底解耦vue-router 4 在定义路由时可以传props选项这个选项能让路由参数直接以 props 的形式传给组件。它的本质目的是让组件不依赖route对象从而更容易测试、复用和解耦。const routes [ { path: /user/detail/:id, component: UserDetail, props: true // 把 params 作为 props 传入 } ]开启props: true之后UserDetail组件里可以直接这样接收script setup defineProps({ id: { type: String, required: true } }) /scriptprops还可以设为函数形式这样你可以把 query 参数也映射成组件的 props{ path: /user/detail, component: UserDetail, props: (route) ({ id: route.query.id }) }我比较推荐在项目中推广 props 的用法特别是列表页和详情页这类依赖 URL 入参的组件。当组件逻辑迁移到独立的 hooks 中时你会发现组件本身变得特别干净测试也容易写不用每次 mock 一个$route。4.4 三种传参方式的选型建议为了让你用的时候心里有底我把三种方式的特点整理成一张表方式URL 表现刷新后是否保留适用场景注意点query/list?page2size20是筛选条件、分页、分享链接值全部是字符串params/detail/101手动刷新一般保留路由配置不当可能丢详情页 ID、动态路径参数需要配合路径占位符props由底层决定由底层决定组件解耦、单元测试需要路由配置时显式开启在实际项目里我很少单独只用一个方案通常是query 传列表筛选条件 params 传资源 ID props 让组件接收必要数据混合使用这样既有清晰的 URL 结构又方便组件层面管理和维护。5. 路由跳转不刷新页面从原因分析到完整解决方案5.1 为什么路由跳转会刷新页面回到标题里最核心的那句话——vue3路由实现页面跳转它隐含的一个关键诉求就是跳转不刷新页面。很多人在使用过程中会发现一个现象从 A 页面跳转到 B 页面B 页面里的数据总是旧数据哪怕你在接口里拿到了新数据页面也不更新。这种刷新但不刷新的矛盾根源通常不在 URL 跳转本身而在组件复用的机制上。在 vue-router 中当你从/user/list跳到/user/list?page3或者从/user/detail/1跳到/user/detail/2时这两个路由命中同一个组件实例。vue-router 出于性能考虑会直接复用这个组件实例不重新走一遍created和mounted生命周期。也就是说你的数据请求如果写在这些生命周期里那么切换参数时根本不会重新触发页面展示的数据自然就不刷新了。5.2 解决方案一对路由变化进行监听解决这个问题的第一反应就是监听路由变化在变化时重新拉取数据。在 Vue3 组合式 API 里有两种写法。第一种是watch监听route对象import { useRoute } from vue-router import { watch } from vue const route useRoute() watch(() route.params.id, (newId, oldId) { // 参数变化时重新拉详情 fetchDetail(newId) }, { immediate: true })第二种是watch监听route.path或者整个route但要注意不要直接watch(route)的大对象因为route是一个响应式对象里面的query、params变化都会触发回调容易误请求。更精确的做法是按需监听比如你只需要关注params.id那就只监听() route.params.id。如果你的组件对多个参数都有响应需求可以对一个computed值进行 watchconst currentPage computed(() Number(route.query.page) || 1) watch(currentPage, (page) { fetchList(page) }, { immediate: true })5.3 解决方案二给 router-view 设置 key 强制重建组件另一种思路更暴力也更直接让 Vue 认为每次跳转都是不同的组件强制销毁重建。做法是给router-view加一个keytemplate router-view :keyroute.fullPath / /template这里的route.fullPath包含了路径和 query 的完整字符串也就是说只要 URL 有任何变化key就会变Vue 就会卸载旧组件、挂载新组件。这种做法在确实需要组件从头开始走生命周期的场景下非常有用比如页面内有一个图表组件跳转参数变化时希望整个图表重新初始化。但是这个方案有一个明显副作用每次跳转都会销毁重建组件性能开销更大组件内部的本地状态也会全部丢失。如果只是列表页换个筛选条件其实更推荐用监听路由参数的方式组件实例不销毁、滚动位置不重置用户体验反而更好。所以我的建议是keyroute.fullPath适合那些每个 URL 都对应一套独立 UI 状态的页面比如详情页、表单页而通常的列表页还是应该用参数监听的方式。5.4 刷新页面的真实场景从 URL 直达和浏览器刷新这里还要专门说一下刷新这个词的另一层含义。你可能会遇到这种情况页面处在/user/detail/101时点击浏览器刷新按钮页面变成了 404 或者跳回首页。这个问题的根源跟路由代码无关而是后端没有配置 history 模式的 fallback。解决办法是让 Nginx 把所有请求都指向index.htmllocation / { try_files $uri $uri/ /index.html; }如果你使用的是 hash 模式因为#后面的内容不会发送到服务器所以天然不会出现这个问题。这也是很多团队为了省事坚持用 hash 模式的原因。但从长期维护和 SEO 角度考虑该配好的服务器配置还是不要偷懒。6. 路由守卫跳转前的身份验证、进度条和页面标题管理6.1 全局守卫beforeEach 里的拦截逻辑路由守卫是跳转前这个时间节点上最重要的工具。实际项目里最大的应用场景就是登录态校验尤其是中后台管理系统。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { // 未登录跳转到登录页 next({ name: Login, query: { redirect: to.fullPath } }) } else { next() } })这段代码的逻辑是在每次路由跳转之前先看目标路由to是否声明了需要登录的元信息meta.requiresAuth再看本地有没有 token如果没有就踢去登录页顺便把原始目标地址to.fullPath带在 query 里登录成功后可以跳回来。如果你在pinia或vuex里存了用户信息也可以在守卫里做更细的权限判断router.beforeEach((to) { const store useUserStore() if (to.meta.requiresAuth !store.isLoggedIn) { return { name: Login, query: { redirect: to.fullPath } } } if (to.meta.role !store.roles.includes(to.meta.role)) { return { name: 403 } } })vue-router 4 相比 3 最大的变化是守卫函数里可以不写next()。你要么不返回任何值直接放行要么返回一个路由地址对象来重定向。这个新语法让代码精简了不少也避免了 3 里那种忘了调next()导致路由跳转卡死的经典 bug。6.2 路由元信息 meta把页面标题、权限标记都挂到路由上我在项目里特别习惯用meta字段来做路由配置化。比如这样定义路由const routes [ { path: /, component: Layout, meta: { title: 首页 }, children: [ { path: , component: Dashboard, meta: { title: 工作台, requiresAuth: true } }, { path: user/list, component: UserList, meta: { title: 用户列表, requiresAuth: true, permission: user:list } } ] } ]然后全局守卫里统一处理页面标题router.afterEach((to) { document.title to.meta.title ? ${to.meta.title} - 管理系统 : 管理系统 })这样一套下来新增页面时只需要在路由表里加一条配置标题、权限标记、布局信息都跟着走了不需要每个页面组件里都写一遍document.title。我遇到的一些老项目几百个页面里散落着各种手写标题的代码维护起来非常痛苦。6.3 全局进度条NProgress 与路由守卫的组合在很多中后台模板里你会看到页面顶部有一条蓝色的加载进度条那就是 NProgress。在路由守卫里配合使用非常简单npm install nprogressimport NProgress from nprogress import nprogress/nprogress.css NProgress.configure({ showSpinner: false }) router.beforeEach(() { NProgress.start() }) router.afterEach(() { NProgress.done() })有一个细节值得注意router.afterEach里的NProgress.done()并不总是会被调用。如果路由跳转过程中出现异常比如组件加载失败afterEach可能不会执行进度条就会一直转。稳妥的方案是在路由的onError回调里也调用一次router.onError(() { NProgress.done() })这种异常处理在中大型项目里很关键因为异步组件加载在低网速场景下确实可能失败。7. 动态路由与权限控制中后台系统的工程化实现7.1 动态路由的基本概念静态路由是在new VueRouter或createRouter时就固定好的。但中后台管理系统有一个非常普遍的需求不同角色的用户登录后能看到的路由菜单不一样。把这个需求落到实现层面就是从后端接口拿到当前用户的权限菜单动态生成路由并注入路由实例。这就是动态路由。在 vue-router 4 里动态添加路由的核心 API 是router.addRoute而且它的设计比 3.x 时代优雅了很多。// 登录成功后拉取用户菜单 const menuList await fetchMenu() // 把菜单组件转换成路由配置 const dynamicRoutes menuList.map(item ({ path: item.path, name: item.name, component: () import(/views/${item.component}), meta: { title: item.title, icon: item.icon } })) // 逐个添加 dynamicRoutes.forEach(route { router.addRoute(Layout, route) })7.2 addRoute 的第二个参数父子嵌套路由的正确姿势addRoute有两种常见用法。第一种是添加独立路由router.addRoute({ path: /about, component: About })第二种是往一个已有的路由里添加子路由router.addRoute(Layout, { path: about, name: About, component: About })注意这里第一个参数是父路由的name而子路由的path不需要带父路径前缀。如果你在父路由里已经定义了path: /那么子路由 path 写about最终路径就是/about如果你把子路由 path 写成/about效果也一样。但是如果你写了一个跟父级重复的绝对路径就可能会出现层级嵌套错乱的问题。还有一个容易让新人困惑的点动态添加路由后如果在当前页面直接刷新动态路由就全没了。因为刷新后整个应用重新加载路由表又会回到初始的静态配置。所以在路由守卫里典型的做法是先判断有没有动态路由没有就先拉取、添加、再跳转let isDynamicRoutesAdded false router.beforeEach(async (to) { const store useUserStore() if (!store.isLoggedIn) { return to.meta.requiresAuth ? { name: Login } : true } // 已登录但动态路由还没加过 if (!isDynamicRoutesAdded) { await store.fetchUserMenus() store.menus.forEach(route { router.addRoute(Layout, route) }) isDynamicRoutesAdded true // 关键重新导航一次确保新增路由生效 return { ...to, replace: true } } return true })这里最后return { ...to, replace: true }的目的是当动态路由添加完成后需要重新进行一次跳转否则to对应的路由可能因为刚才根本不存在而匹配失败。这个方法是我在实际项目中踩出来的细节非常重要。7.3 路由权限控制的实现策略对比对于权限控制行业内大致有几种方案前端静态写死权限路由全部写死在守卫里判断角色。简单但权限变更要发版灵活性差。后端返回菜单前端动态注册后端在登录时下发菜单数据前端动态添加路由。灵活性和安全性平衡最好是主流方案。后端返回完整路由表前端全量注册后端把所有路由数据一次性给到前端前端筛选后注册。实现简单但服务端负载相对大且组件路径暴露在接口里有一定风险。我在实际工作中最常用的是第二种。菜单存储在 Pinia 里路由表动态添加按钮级别的权限用一个自定义指令v-permission去控制。这套组合落地的关键是动态路由只负责页面级的拦截按钮级操作还必须在后端接口做过一次鉴权。前端路由守卫只是用户体验层面的优化不是安全边界。7.4 菜单与路由的联动从动态路由生成侧边栏每次动态添加路由之后侧边栏菜单也需要跟着更新。这块最常见的坑是路由和菜单数据源不一致导致菜单显示的和实际能访问的不一致。我的做法是把菜单数据源就定为动态路由的完整配置侧边栏直接根据 Pinia 里保存的菜单树渲染。不单独维护一份菜单数据只保存路由配置然后从路由配置里抽取出菜单需要的 title、icon、path 等字段。这样菜单和路由天然同源不会出现菜单里有但路由表里没有的 404 问题。const menuList computed(() { return router.getRoutes() .filter(route route.meta?.menu route.meta?.hidden ! true) .map(route ({ path: route.path, title: route.meta.title, icon: route.meta.icon })) })不过要注意router.getRoutes()返回的是扁平路由记录要还原成嵌套菜单结构需要额外处理层级关系。理想的数据结构是后端直接返回树形数据前端拿到后一路生成路由、一路生成菜单保持结构一致。8. 异步组件与路由懒加载让首屏轻下来8.1 懒加载的写法差异中后台项目往往有几十上百个页面如果不做处理所有页面代码都会被打包进一个巨大的app.js里首屏加载速度会非常感人。Vue3 配合路由懒加载的做法非常简单把组件定义从静态 import 改成动态 import。// 不推荐所有组件都被打包进主包 import UserList from ../views/UserList.vue // 推荐每个页面独立打包按需加载 const UserList () import(../views/UserList.vue)在路由配置里这种写法可以直接简写为{ path: /user/list, name: UserList, component: () import(/views/UserList.vue) }Vite 遇到动态 import会自动做代码分割。最终在浏览器里看到的效果是访问某个页面时才去请求对应的 js 文件首屏只加载首页相关的代码。这对体验和性能的提升非常可观。我用webpack-bundle-analyzer或 Vite 内置的包分析工具检查过很多项目做了懒加载之后主包体积通常能减少 60% 以上。8.2 懒加载配合缓存策略动态 import 在生产环境会生成带 hash 的文件名例如UserList-abc123.js。配合 Nginx 的静态资源缓存策略可以做到文件内容不变就命中缓存改动后自动更新。推荐配置location /assets/ { expires 30d; add_header Cache-Control public, immutable; }这种配置的前提是你严格按照静态资源加 hash入口 HTML 不缓存的方式来做。很多团队上线后遇到页面更新了但用户看不到新版本的问题十有八九是缓存策略配置得不对把index.html也一并缓存了。8.3 加载失败的重试与降级动态 import 在弱网环境下偶尔会加载失败这时可以给路由挂一个组件加载失败的兜底逻辑function loadView(view) { return () import(/views/${view}.vue).catch((error) { // 记录错误日志 console.error(页面加载失败, error) return import(/views/Error/404.vue) }) }这个方法在大型项目迁移、多人协作分支交替时特别有用。避免用户看到白屏或者一串看不懂的错误堆栈体验会好很多。9. 实际项目中的常见坑与排查思路9.1 路由跳转成功但页面内容不更新这个场景在前面跳转不刷新小节里已经讲了原理这里补充一个具体的排查思路。遇到这个情况先别急着改代码按下面顺序确认确认 URL 是否变化了。如果 URL 变了但组件内容没变大概率是组件复用问题。确认目标页面和来源页面是不是同一个组件文件。如果是/user/list跳转/user/list?page2组件自然是同一个需要走监听方案。如果在列表页里用了keyroute.fullPath需要确认 key 确实变化了。如果fullPath没变key 也不会变组件自然不会重建。9.2 跳转后页面白屏先把 Nginx 错误日志打开看有没有 404、405。打开开发者工具 Network 面板看看有没有报错的 js、css 文件。看是不是动态 import 的路径写错了特别是使用字符串变量拼接路径时要确认最终解析出的文件路径存在。9.3 刷新后 404 或回到首页确认路由模式hash 模式一般不会出现这个问题。如果是 history 模式检查 Nginx 的try_files是否配置正确。检查是否有router.beforeEach把刷新后的首次跳转拦截了比如 token 失效被踢回登录页。9.4 动态添加路由后找不到页面确认是否调用了router.addRoute后没有重新push一次。确认父路由的name是否拼写正确。确认动态路由的path和现有路由有没有冲突vue-router 会优先使用先添加的路由。10. 从跳转本身到工程化关于路由设计的一点个人总结Vue3 路由实现页面跳转表面上看是几个 API 的调用问题但真正拉开开发效率差距的是对路由设计思想的理解。我最深的体会是路由表不应该只是路径到组件的映射它完全可以成为整个应用配置的载体。把页面标题、布局方式、权限标记、菜单显隐、缓存策略都放在meta里统一管理然后通过守卫、指令、组件依据这些配置做响应式处理项目会变得非常整洁。我在实际项目中还发现一个朴素但很有用的技巧给路由表按照模块拆分文件。比如router/modules/user.js管用户模块router/modules/order.js管订单模块然后在router/index.js里统一把子模块的 routes 合并到一起。这样多人协作时各自维护自己的模块路由冲突少了很多动态路由的菜单中心化管理也顺手了不少。最后再分享一个小经验不管用什么模式、什么写法路由配置和菜单配置一定要保持单一数据源。无论是后端下发菜单前端注册路由还是前端维护路由并同步生成菜单都不要让两套数据各自维护否则后期一定会出现菜单能点路由却 404这类让人头皮发麻的问题。趁项目还小的时候把路由表设计好后面能省下大量排查时间。