资讯动态

Vue Router 4 组合式 API 实战:useRouter 与 useRoute 完全指南

发布时间:2026/10/21 18:02:15 来源:尧图企业网站定制
从 Option API 迁移到组合式 API 之后很多 Vue 开发者遇到的第一个“找不着北”的问题就是路由对象去哪了。以前写this.$router.push()、this.$route.query.id写得顺手到了script setup里发现根本没有this于是开始怀疑人生Vue Router 是不是在 Vue 3 里被废掉了当然不是。Vue Router 4 为 Vue 3 专门提供了两个组合式函数——useRouter和useRoute用来替代选项式 API 里的this.$router和this.$route。它们解决的问题很直接在组件实例之外、在组合式 API 的上下文里拿到可用的路由实例和当前路由信息。这篇博文我会从基础用法讲起覆盖响应式细节、组件复用陷阱、组件外调用限制、类型约束等实战环节尽量把我实际项目中踩过的坑一次性讲透。1. 先搞清楚 useRouter 和 useRoute 到底解决了什么问题1.1 选项式时代的直觉在组合式 API 里会失灵在 Vue 2 Vue Router 3 时代路由信息几乎完全依赖组件实例。this.$router是全局路由实例this.$route是当前激活路由的响应式对象它们的访问方式决定了你只能“站在组件内部”才能操作路由。这在选项式 API 里没有任何问题因为所有逻辑都挂在组件选项里this无处不在。但组合式 API 把逻辑从组件选项中抽离出来了。setup()函数在组件实例创建之前执行执行时this还是undefined更别说script setup语法糖里根本不存在this。所以 Vue Router 4 必须提供一种不依赖组件实例的访问方式这就是useRouter和useRoute出现的根本原因。1.2 两者的定位差异一个管“跳”一个管“看”很多初学者会把这两个函数混在一起觉得它们都是“拿路由信息”的。实际上它们的职责明显不同函数返回类型核心职责是否响应式useRouter()Router实例执行编程式导航如push/replace/go否实例本身固定不变useRoute()当前路由的响应式对象读取path、query、params、meta等当前路由信息是路由变化时自动更新useRouter拿到的Router实例是你在createRouter()时创建的那个全局对象应用启动后它不会变化你只需要调用它的方法来改变地址。useRoute拿到的则是“当前激活的路由”它是响应式的地址一变它内部的属性也跟着变组件里用到它的部分会自动重新渲染。理解了这层区别后面的所有用法就都有了立足点需要改变地址用router需要读取地址信息用route。2. 核心用法拆解useRouter 的方法与 useRoute 的状态2.1 useRouter跳转不是只有 push日常开发里 90% 的跳转需求都能用router.push解决但它远不是路由实例的全部。我实际项目中经常用到的方法按使用频率排列如下。编程式导航最常用的方法import { useRouter } from vue-router const router useRouter() // 最常用的跳转相当于 router-link 的 to router.push(/user/123) // 对象语法 router.push({ path: /user, query: { id: 123 } }) // 命名路由适合后台管理系统这种路径经常调整的场景 router.push({ name: UserDetail, params: { id: 123 } }) // 替换当前历史记录搜索页翻页、表单提交后跳转常用 router.replace({ query: { page: 2 } }) // 前进 / 后退指定步数 router.go(1) router.go(-1) // 语义化的前进 / 后退 router.back() router.forward()push和replace最大的差别在于是否留下历史记录。我做数据列表的筛选功能时有个深刻教训筛选条件变化后如果用push用户一按返回键就回到上一个筛选状态再按就离开页面体验非常割裂。改成replace之后返回键直接离开列表页这个行为模式更符合用户的预期。还有一个容易被忽略的方法是resolve它不给地址栏做任何改变只是把一个路由位置解析成完整地址const { href } router.resolve({ name: UserDetail, params: { id: 456 } }) window.open(href, _blank)需要在新窗口打开详情页时这个方法非常实用它不触发导航只是把路由位置对象“翻译”成字符串地址。router.push返回的是一个 Promise跳转失败时会抛出异常。尤其要注意重复跳转到相同的路由地址在 Vue Router 4 里会触发NavigationFailure但不抛出到控制台容易被忽略。用await接收时能拿到返回值判断是否跳转成功const result await router.push(/user/123) if (result undefined) { // 跳转顺利完成 }如果跳转重复或者被守卫拦截返回的是失败对象不是undefined这样就能区分真正成功和被拦截的情况。2.2 useRoute参数、路径、matched一个都不能少useRoute返回的路由对象属性非常多我平时真正用到的关键字段如下。import { useRoute } from vue-router const route useRoute() // 完整的字符串路径包含 query route.fullPath // /user?id123#top // 不含 query 和 hash 的路径部分 route.path // /user // 查询参数对象 route.query // { id: 123, page: 2 } // 动态路由参数 route.params // { id: abc } 对应路由定义 /user/:id // URL 中的 hash 部分不含 # 符号 route.hash // top // 当前路由的名字 route.name // UserDetail // 所有匹配到的路由记录数组 route.matched // [...] // 路由配置里声明的 meta 字段 route.meta // { requiresAuth: true, title: 用户详情 }query和params是最容易写混的一对。务必记住query来自 URL 中?后面的参数params来自路由路径中声明的动态片段而且跳转时用的是name params组合path params组合会被 Vue Router 直接忽略。我曾见过一个同事写了router.push({ path: /user, params: { id: 1 } })结果跳过去/userid参数完全没带排查了好久才发现是用法不对。route.meta有一个很实用的场景菜单标题和页面标题联动。把每个路由的meta.title配好然后在导航守卫里设置document.title或者直接在当前组件里用computed读取route.meta.title。配合路由懒加载可以做得很优雅。3. 响应式细节为什么 route 会变、router 不会变3.1 route 是响应式对象watch 是标准姿势useRoute返回的路由对象是响应式的所以你在模板里直接写route.query.keyword地址栏参数一变视图立刻更新。但如果你想在 JavaScript 逻辑里监听路由变化就得靠watch。最常见的两个监听对象是route.params和route.query但直接整对象监听时需要注意import { watch } from vue import { useRoute } from vue-router const route useRoute() // 动态路由参数变化时触发 watch( () route.params.id, (newId, oldId) { // 重新请求用户详情数据 } ) // 监听整个 query 对象需要开启 deep watch( () route.query, (newQuery) { // 筛选条件变化后重新拉取列表 }, { deep: true } )我自己的习惯是优先监听() route.query或() route.fullPath而不是监听route本身。原因是route对象在每次导航后大概率会变成一个新的对象引用而全对象监听容易在路由跳转时触发你不关心的变化。有一点要注意监听route.params值的场景本质上是组件复用问题。在 Vue Router 中同一个组件如果从/user/1跳到/user/2组件实例会被复用setup()不会重新执行此时只有靠watch才能感知到参数变化并更新数据。下面这个例子是动态路由页面的标准写法。script setup import { ref, watch } from vue import { useRoute } from vue-router const route useRoute() const userInfo ref(null) watch( () route.params.id, async (id) { userInfo.value await fetchUser(id) }, { immediate: true } ) /scriptimmediate: true保证组件首次进入时就执行一次数据请求后续参数变化时再次触发一次监听覆盖两种场景。3.2 组件复用时不重新执行的坑以及 keep-alive 的配合动态路由切换时组件复用的“不重新执行”特性是初用 Vue 3 组合式 API 的人最容易踩的坑。组件实例不销毁重建听起来性能很好副作用就是初始化逻辑不会重跑。除了用watch监听参数之外还可以用onBeforeRouteUpdate这个组合式导航守卫它只在路由更新但组件被复用的时候触发import { onBeforeRouteUpdate } from vue-router onBeforeRouteUpdate(async (to, from) { // 组件复用时的逻辑相当于响应参数变化的手动版本 await loadData(to.params.id) })如果你还用了keep-alive缓存组件情况会更复杂。被缓存的组件切走时不会卸载切回来时也不会重新执行setup此时需要在onActivated里做数据刷新。script setup import { onActivated } from vue import { useRoute } from vue-router const route useRoute() onActivated(() { // 从缓存中被重新激活可能参数没变但需要刷新最新数据 refreshList(route.query.page) }) /script真实项目里列表页 详情页互相切换的场景最容易触发这种问题列表页被keep-alive缓存用户从详情页返回列表页希望列表恢复到之前的位置并刷新数据。这时候setup不会执行watch也不触发因为route没变唯一有效的地方就是onActivated。所以我把项目里的逻辑简单总结成一句话路由参数变化用watch组件重新激活用onActivated进入新页面走setup主流程。三个场景分别对应不同的响应机制乱用就会掉进“明明跳转了却不更新”的坑。4. 易错点集合我在真实项目里踩过的坑4.1 解构 route 参数后失去响应性这是我在 Code Review 里见到最高频的问题之一。有些同学觉得route对象拿到手就可以解构// 错误示范 const { id } useRoute().params const { keyword } useRoute().query // 后面用 id、keyword 做逻辑这样解构出来的id和keyword是普通字符串或undefined与路由完全断开了联系。地址栏参数变了这两个变量纹丝不动页面表现就是“路由变了但内容不更新”。正确做法是用computed或者toRefs保持响应式import { computed, toRefs } from vue import { useRoute } from vue-router // 正确方式一computed const route useRoute() const id computed(() route.params.id) // 正确方式二toRefs const { query, params } toRefs(useRoute()) // 模板或逻辑中使用 query.value.pagetoRefs是官方文档推荐的另一种写法它把响应式对象里的每个属性都转换成一个独立的Ref解构出来的query、params依然保留响应性。两种方式我推荐computed因为当你只需要某个具体的参数时computed(() route.params.id)的语义更清晰还避免了拿到一堆ref后到处写.value。4.2 组件外使用 useRouter / useRoute 的限制useRouter和useRoute必须在组件的setup执行上下文里调用脱离组件环境直接调用会报错。我在项目里遇到过这样的工具函数// utils/navigation.js import { useRouter } from vue-router export function goToDashboard() { const router useRouter() // 报错injection 失败 router.push(/dashboard) }在普通的 JavaScript 工具模块里调用useRouter会提示无法获取注入上下文。正确思路是把这个函数改成接受router参数// utils/navigation.js export function goToDashboard(router) { router.push(/dashboard) } // 组件里 const router useRouter() goToDashboard(router)如果确认是全局唯一的路由实例也可以把createRouter返回的router导出在需要的地方直接引入// router/index.js export const router createRouter({ ... }) // 任意模块里 import { router } from /router router.push(/dashboard)这个方案更直接但要注意别在服务端渲染场景里使用否则全局单例会被请求间共享导致路由状态串台。4.3 跳转同一路由只有参数变化时的处理列表页常见的一个交互是用户已经在/list?page1又点击了当前页的翻页按钮跳转到/list?page1。地址完全一样Vue Router 4 会发出一条NavigationFailure什么都不做。但有些场景确实需要“同一地址也重新拉数据”。举例来说用户从列表页跳到详情页再返回通过back()回到列表页我们希望刷新数据但地址可能和离开时完全一样。这种情况有几种处理手段一是用watch(route.fullPath)监听返回时因为组件重新激活fullPath本身没有变化监听不会触发二是借助onActivated做强制刷新。所以最终还是那句话先判断组件生命周期状态再选对应的刷新机制。另外一个常见场景是“跳转后需要滚动到顶部”即使地址完全一样也想要得到滚动行为。这个直接在router.afterEach里写window.scrollTo(0, 0)最干净不用依赖跳转是否成功。场景是否触发 setup是否触发 watch推荐方案首次进入路由是是immediatesetup内直接请求同路由参数变化否是watch(() route.params.id)keep-alive 重新激活否否onActivated刷新back 返回且地址相同否否onActivated或手动触发刷新这个表格基本概括了路由编程中最容易搞混的几类场景。每次遇到“页面不刷新”先套这个表定位属于哪种情况再去找对应的解决方案比我当初靠瞎猜高效得多。5. 进阶实践从跳转、读取到数据加载与类型约束5.1 把路由信息“翻译”成页面数据与标题实际项目里路由信息很少直接在前台逻辑里裸奔更多的是驱动数据请求和页面标题。先说页面标题。后台管理系统几乎每个路由都希望有独立的浏览器标签标题。我通常的做法是在路由配置的meta里写死标题// router/routes.js { path: /user, name: UserList, meta: { title: 用户列表 } }, { path: /user/:id, name: UserDetail, meta: { title: 用户详情 } }然后在导航守卫统一设置router.afterEach((to) { document.title to.meta.title ? ${to.meta.title} - 某后台管理系统 : 某后台管理系统 })这样任何地方跳转标题都会同步更新不需要每个页面组件自己去写watch。再说数据请求。我习惯把“根据路由参数请求数据”的逻辑封装成一个组合式函数比如一个用户详情页的获取逻辑// composables/useUserDetail.js import { ref, watch } from vue import { useRoute } from vue-router export function useUserDetail() { const route useRoute() const user ref(null) const loading ref(false) const error ref(null) const fetchData async (id) { loading.value true error.value null try { user.value await fetch(/api/user/${id}).then(res res.json()) } catch (e) { error.value e } finally { loading.value false } } watch(() route.params.id, (id) fetchData(id), { immediate: true }) return { user, loading, error, refresh: () fetchData(route.params.id) } }这样页面组件只需要script setup import { useUserDetail } from /composables/useUserDetail const { user, loading, error, refresh } useUserDetail() /script把路由读取和数据获取的耦合关系收敛到组合式函数内部组件模板保持干净。这也是组合式 API 相对选项式最大的优势之一逻辑复用以函数为粒度而不是以组件选项为粒度。5.2 组合式函数拆分的边界何时该传 route 进去写组合式函数时很多人会纠结一个问题里面直接调用useRoute()获取路由信息还是由外部把route传进来这个问题没有绝对答案我个人的判断标准是“可测试性和复用范围”。如果这个组合式函数的目标就是把“路由参数变化”和“数据请求”绑定在一起那直接在函数内部调用useRoute()是合理的因为离开路由上下文它就没意义了。比如上面那个useUserDetail它天然依赖:id参数剥离路由反而复杂。但如果你写的是一个通用的请求函数比如useFetchList它可能在列表页也可能在搜索页路由参数来源完全不同这时候把参数作为入参传入更合理// composables/useFetchList.js export function useFetchList(fetchParams) { // fetchParams 可以是 ref 或 computed // 内部只负责根据参数请求数据 }这背后是“副作用收敛”的原则组合式函数内部调用的useRoute、useStore这类带隐式依赖的接口越少函数的复用半径越大。反之如果过度依赖路由上下文离开页面组件就寸步难行。5.3 类型安全Router 与 RouteLocationNormalizedLoaded最后聊一下 TypeScript 下的类型约束。Vue 3 项目用 TypeScript 的比例越来越高useRouter和useRoute都是有完整类型推导的。import { useRouter, useRoute } from vue-router import type { Router, RouteLocationNormalizedLoaded } from vue-router const router: Router useRouter() const route: RouteLocationNormalizedLoaded useRoute()实际上不写类型注解TypeScript 也能正确推断出它们的类型。真正值得注意的是一些容易出错的类型场景。路由参数的类型是Recordstring, string | string[]动态路由的参数默认全是string类型。从route.params.id取出来的是string做数字计算时要注意// 你心里想的是数字但语言类型是 string const id route.params.id fetchUser(id) // 类型没问题 parseInt(id, 10) // 转数字后再计算query参数同理从 URL 解析出来全是字符串即使你在router.push({ query: { page: 2 } })里传了数字读出来依然是string。这个类型陷阱我提醒过很多次想避免就在请求数据前统一做一个参数类型清洗。还有一个进阶用法是给全局路由相关接口声明自定义类型比如meta字段。Vue Router 自带类型需要扩展才能获得完整提示// types/router.d.ts import vue-router declare module vue-router { interface RouteMeta { title?: string requiresAuth?: boolean } }这样在路由配置、导航守卫和组件里访问to.meta.title时TypeScript 都能给出属性提示不再是无处安身的any。这段类型配置看起来不起眼但在我重构一个大型后台项目时帮了大忙路由元信息加上约束之后所有页面的标题和权限字段都被编译器“盯住”了少配置一个meta.title会直接报错而不是留到运行时才发现。我在实际开发中的习惯是给每个涉及路由跳转的方法都明确写上入参类型避免后续重构时把路由位置对象传成一个残缺的普通对象。这是组合式 API 时代比较容易被忽视的部分毕竟从this.$router切换到useRouter之后少了很多 Vetur 或 Vue Language Tools 自动补全掉类型的机会自己动手约束反而更扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑