Code前端首页关于Code前端联系我们

Vue3怎么监听路由变化?这3种实用方案覆盖所有场景,附踩坑细节

terry 43分钟前 阅读数 13 #Vue
文章标签 Vue3监听路由变化

最近在帮朋友改一个Vue3做的电商后台,他总说“页面切换的时候统计数据没刷新”“标签页高亮总是慢半拍”,排查下来发现核心问题就是路由监听没写对,不少刚从Vue2转过来的开发者,或者刚上手Vue3的新手,都容易在这里踩坑——要么忘了setup语法糖的特性,要么选了不合适的监听方案导致性能浪费,甚至有些场景直接监听不到,今天就把整理好的3种实用监听方案分享出来,从基础到进阶,结合真实开发场景讲清楚怎么用,还有高频踩坑点和优化建议,保证你看完就能解决99%的路由监听需求。

使用watch/watchEffect深度/浅层监听响应式路由对象

这是Vue2过渡到Vue3最容易想到的方案,不过要注意Vue3的路由对象是通过useRouteuseRouter这两个组合式API获取的,而且有响应式特性的只有useRoute返回的对象,useRouter拿到的只是一个方法集合哦。

具体怎么写?

先回忆一下基础逻辑:从vue-router里引入useRoute,拿到当前活跃路由的响应式对象route,然后用watch/watchEffect监听它的变化。 举个标签页自动高亮的例子吧——比如后台管理系统顶部的导航栏,切换页面后对应的菜单项要变成橙色高亮:

<template>
  <div class="nav-bar">
    <div 
      v-for="item in navList" 
      :key="item.path"
      :class="{ active: route.path === item.path }"
      @click="$router.push(item.path)"
    >
      {{ item.name }}
    </div>
  </div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const navList = ref([
  { path: '/dashboard', name: '数据概览' },
  { path: '/order', name: '订单管理' },
  { path: '/user', name: '用户列表' }
])
// 监听route.path,浅层监听就行,因为path是字符串基本类型
watch(
  () => route.path,
  (newPath, oldPath) => {
    console.log(`路由从${oldPath}切换到${newPath}`)
    // 这里不用写额外的高亮逻辑哦,因为模板里已经绑定了响应式route.path
    // 但如果需要做其他操作,比如埋点统计、请求初始化可以在这里写
  }
)
</script>
<style scoped>
.nav-bar { display: flex; gap: 20px; padding: 10px 30px; background: #f0f2f5; }
.active { color: #ff6a00; font-weight: bold; border-bottom: 2px solid #ff6a00; }
</style>

watch vs watchEffect怎么选?

这里要单独说一下,很多新手会混淆这两个监听API,其实它们在路由监听里的场景差别还挺大的:

  • watch:惰性监听(第一次不会执行回调),可以拿到新旧值,适合有明确监听目标、需要对比路径/参数变化的场景,比如刚才的埋点(统计从哪里来的流量)、或者判断是否需要回退到首页(比如旧路径是登录页新路径是其他权限不足的页面)。

  • watchEffect:立即执行一次(只要依赖的响应式数据变了就会执行),不用指定明确的监听目标,适合不需要新旧值、只要路由有变化就触发初始化请求/操作的场景,比如电商商品详情页切换后自动请求新的商品信息——举个例子:

    <script setup>
    import { ref, watchEffect } from 'vue'
    import { useRoute } from 'vue-router'
    import { getProductDetail } from '@/api/product'
    const route = useRoute()
    const productDetail = ref({})
    const loading = ref(false)
    // 只要route.params.id变了就会执行,而且第一次打开页面也会请求
    watchEffect(async () => {
      loading.value = true
      try {
        const res = await getProductDetail(route.params.id)
        productDetail.value = res.data
      } catch (err) {
        console.error('获取商品详情失败', err)
      } finally {
        loading.value = false
      }
    })
    </script>

踩坑细节

这个方案虽然常用,但踩坑概率最高,我整理了3个高频问题:

  1. 错误监听整个route对象:之前朋友就是直接watch(route, ...),还加了deep: true,结果每次路由切换请求发了3次!因为useRoute返回的对象包含了很多属性,比如matched(当前路由的匹配数组)、queryparamspathfullPath等等,切换路由时这些属性可能会同时变化,加了deep就会触发多次回调。正确的做法是只监听你需要的属性,比如只监听fullPath(如果你的页面同时依赖path和query/params),或者只监听params.id这种具体字段。
  2. setup语法糖里忘记解构后失去响应式:有些开发者为了代码简洁,会直接const { path, query } = useRoute(),这样解构出来的pathquery就变成普通的字符串/对象了,失去了响应式,后续监听肯定没用。如果一定要解构,记得用toRefs包裹const { path, query } = toRefs(useRoute()),这样每个属性都是响应式的ref对象。
  3. 切换参数相同的路由监听不到:比如用户从/product/123切换到/product/456params.id变了能监听到;但如果用户点击刷新按钮,或者通过其他方式跳转到参数完全相同的路由(比如/product/123/product/123),pathfullPathparamsquery都没变,这时候watch/watchEffect都不会触发——这种场景后面的方案二和方案三可以解决。

使用onBeforeRouteUpdate/onBeforeRouteLeave路由守卫组合式API

Vue2里我们常用beforeRouteUpdatebeforeRouteLeave这两个组件内守卫,Vue3里把它们改成了组合式API,放在了vue-router里引入,而且只能在组件的setup函数里调用(或者在<script setup>里直接用),这是最贴合“某个页面组件内部路由变化”的监听方案。

具体怎么写?

先讲onBeforeRouteUpdate,它主要解决同一个组件被复用但路由参数/查询字符串变化的场景——刚才方案一的踩坑点3就是典型的复用场景(比如Vue3的<router-view>默认会复用相同路径模式的组件,除非你加了key="$route.fullPath")。 还用刚才的商品详情页举例子,这次假设我们不加key,靠onBeforeRouteUpdate来刷新数据:

<script setup>
import { ref, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getProductDetail } from '@/api/product'
const router = useRouter()
const route = useRoute()
const productDetail = ref({})
const loading = ref(false)
const isFormDirty = ref(false) // 假设商品详情页有个编辑表单,用来判断是否需要提醒用户保存
// 页面第一次加载时获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getProductDetail(route.params.id)
    productDetail.value = res.data
  } catch (err) {
    console.error('获取商品详情失败', err)
  } finally {
    loading.value = false
  }
}
fetchData()
// 同一个组件被复用(参数/查询字符串变化)时执行
onBeforeRouteUpdate(async (to, from, next) => {
  console.log(`从商品${from.params.id}切换到商品${to.params.id}`)
  await fetchData() // 这里可以await,因为守卫支持异步
  next() // 必须调用next()才能放行路由切换,否则页面会卡住
})
// 离开当前组件时执行,不管是跳转到其他页面还是复用这个组件
onBeforeRouteLeave((to, from, next) => {
  if (isFormDirty.value) {
    const confirmLeave = window.confirm('您编辑的内容还没保存,确定要离开吗?')
    if (confirmLeave) {
      next()
    } else {
      next(false) // 取消路由切换
    }
  } else {
    next()
  }
})
</script>

这个方案的优势

对比方案一,它有两个很明显的优势:

  1. 支持异步操作和路由拦截:刚才的例子里,onBeforeRouteUpdate可以await fetchData(),等数据加载完再切换路由(虽然实际开发中可能不会等,因为会有空白期,但可以用来处理一些必要的前置操作);onBeforeRouteLeave可以判断表单是否修改过,阻止用户意外离开,这是watch/watchEffect做不到的。
  2. 专门针对组件复用场景:即使你不加key="$route.fullPath",只要参数/查询字符串变了,onBeforeRouteUpdate就会触发,而且只触发一次,不会像错误的方案一那样触发多次回调。

踩坑细节

  1. 必须调用next():这是很多新手容易忘的,不管是onBeforeRouteUpdate还是onBeforeRouteLeave,如果不调用next(),路由切换就会被中断,页面不会跳转,控制台也不会报错(这点很坑!找了好久才发现),从Vue Router 4.1.0开始,也可以直接return true/false/导航路径来代替next(),比如刚才的onBeforeRouteLeave可以简化成:
    onBeforeRouteLeave((to) => {
      if (isFormDirty.value) {
        return window.confirm('您编辑的内容还没保存,确定要离开吗?')
      }
      return true
    })

    这种写法更简洁,推荐用。

  2. 不能在子组件里调用:这两个组合式API只能在直接被<router-view>渲染的组件里调用,也就是所谓的“页面级组件”,如果在页面级组件的子组件(比如商品详情页的“规格选择”组件)里调用,是不会生效的——如果子组件需要监听路由变化,只能用方案一或者把页面级组件的响应式路由对象传下去。

在全局路由守卫里监听并通过状态管理共享数据

如果你的监听需求是跨组件的、全局的,所有页面切换时都要埋点统计UV/PV、所有权限页面切换时都要验证token是否过期、整个应用顶部的面包屑导航要随路由变化更新,这时候用前两种方案都不合适——前两种是局部的,全局埋点不可能每个页面都写一遍watch,全局权限验证更不可能每个页面都写一遍守卫。

具体怎么写?

首先要讲清楚全局路由守卫的类型,Vue Router 4里有3种常用的:

  1. 前置守卫router.beforeEach,在所有路由切换执行,用来做权限验证、token刷新等。
  2. 解析守卫router.beforeResolve,在所有异步组件加载完成后、前置守卫通过后执行,用来做依赖异步数据的操作(比如动态生成面包屑导航)。
  3. 后置钩子router.afterEach,在所有路由切换完成后执行,没有next()参数,不能拦截路由,适合做埋点统计、页面滚动重置等。

埋点统计是最典型的全局路由监听场景,我们用router.afterEach来实现,然后面包屑导航用router.beforeResolve+状态管理(Pinia或者Vuex都可以,这里用官方推荐的Pinia)来实现:

配置全局路由守卫(一般放在src/router/index.js里):

import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user' // Pinia的用户状态管理
import { useBreadcrumbStore } from '@/stores/breadcrumb' // Pinia的面包屑状态管理
import NProgress from 'nprogress' // 可选:引入进度条插件,提升用户体验
import 'nprogress/nprogress.css'
// 路由配置(省略)
const routes = [
  { path: '/login', component: () => import('@/views/Login.vue'), meta: { requiresAuth: false, title: '登录' } },
  { 
    path: '/dashboard', 
    component: () => import('@/views/Dashboard.vue'), 
    meta: { requiresAuth: true, title: '数据概览', breadcrumb: [{ name: '首页', path: '/dashboard' }] } 
  },
  { 
    path: '/order', 
    component: () => import('@/views/Order.vue'), 
    meta: { requiresAuth: true, title: '订单管理', breadcrumb: [{ name: '首页', path: '/dashboard' }, { name: '订单管理', path: '/order' }] } 
  }
]
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})
// 前置守卫:权限验证
router.beforeEach((to, from) => {
  NProgress.start() // 开始进度条
  document.title = to.meta.title || 'Vue3电商后台' // 动态修改页面标题
  const userStore = useUserStore() // 注意:Pinia的store必须在守卫内部调用,否则会拿到未初始化的store
  if (to.meta.requiresAuth && !userStore.token) {
    // 如果需要登录但没有token,跳转到登录页,并记录原路径,登录后跳转回去
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})
// 解析守卫:动态生成面包屑导航
router.beforeResolve((to) => {
  const breadcrumbStore = useBreadcrumbStore()
  // 假设有些页面的面包屑是动态的(比如商品详情页的面包屑最后一项是商品名称)
  let breadcrumb = to.meta.breadcrumb || []
  if (to.path.startsWith('/product/')) {
    // 这里可以先从路由参数里拿到商品ID,然后异步请求商品名称,再更新面包屑
    // 因为beforeResolve支持异步,所以可以await
    // 为了演示简单,先假设有商品名称
    breadcrumb = [
      { name: '首页', path: '/dashboard' },
      { name: '商品列表', path: '/product' },
      { name: `商品${to.params.id}`, path: to.fullPath }
    ]
  }
  breadcrumbStore.setBreadcrumb(breadcrumb)
})
// 后置钩子:埋点统计、停止进度条
router.afterEach((to, from) => {
  NProgress.done() // 停止进度条
  // 埋点统计:可以把to.path、from.path、用户ID、时间戳等信息发送给埋点服务器
  const userStore = useUserStore()
  const logData = {
    userId: userStore.userId || 'guest',
    fromPath: from.fullPath,
    toPath: to.fullPath,
    timestamp: Date.now()
  }
  console.log('埋点数据:', logData)
  // 实际开发中这里应该调用埋点API
  // await sendLog(logData)
  // 不过afterEach不支持await,因为它是后置钩子,不能影响路由切换,所以可以用.then().catch()处理异步埋点
  // sendLog(logData).then(() => console.log('埋点成功')).catch(err => console.error('埋点失败', err))
})
export default router

创建Pinia的面包屑状态管理(一般放在src/stores/breadcrumb.js里):

import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useBreadcrumbStore = defineStore('breadcrumb', () => {
  const breadcrumbList = ref([])
  const setBreadcrumb = (list) => {
    breadcrumbList.value = list
  }
  return { breadcrumbList, setBreadcrumb }
})

在全局的面包屑导航组件里使用(比如放在src/components/Breadcrumb.vue里,然后在App.vue里引入):

<template>
  <div class="breadcrumb">
    <span v-for="(item, index) in breadcrumbStore.breadcrumbList" :key="item.path">
      <router-link v-if="index < breadcrumbStore.breadcrumbList.length - 1" :to="item.path">{{ item.name }}</router-link>
      <span v-else>{{ item.name }}</span>
      <span v-if="index < breadcrumbStore.breadcrumbList.length - 1" class="separator">/</span>
    </span>
  </div>
</template>
<script setup>
import { useBreadcrumbStore } from '@/stores/breadcrumb'
const breadcrumbStore = useBreadcrumbStore()
</script>
<style scoped>
.breadcrumb { padding: 10px 30px; background: #fff; border-bottom: 1px solid #e8e8e8; }
.breadcrumb a { color: #1890ff; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.separator { margin: 0 8px; color: #999; }
</style>

这个方案的优势

  1. 全局通用,一次配置永久生效:不用在每个页面级组件里重复写监听逻辑,埋点、权限验证、面包屑导航这些需求一次性搞定。
  2. 结合状态管理实现跨组件数据共享:全局路由守卫里处理的数据(比如面包屑导航)可以通过Pinia/Vuex共享给所有子组件,不用父子组件传参或者事件总线。
  3. 支持动态页面标题和进度条:刚才的例子里还加了动态修改页面标题和引入NProgress进度条,这些都是提升用户体验的常用操作,用全局后置钩子实现最方便。

踩坑细节

  1. Pinia/Vuex的store必须在守卫内部调用:如果在src/router/index.js的顶部就import { useUserStore } from '@/stores/user'然后初始化const userStore = useUserStore(),会报错“pinia not installed”——因为Pinia是在main.js里安装的,而router是在main.js里引入并挂载的,所以在router/index.js的顶部调用useUserStore时,Pinia还没初始化。正确的做法是在每个守卫的回调函数内部调用useUserStore/useBreadcrumbStore
  2. afterEach不支持拦截路由和await:刚才已经提过了,afterEach是后置钩子,路由已经切换完成了,所以没有next()参数,也不能拦截;而且如果在afterEach里写异步操作,不能用await,否则会影响后续的代码执行(虽然不会影响路由切换),所以要用.then().catch()处理。
  3. 异步组件加载的问题:如果你的页面是用() => import('@/views/xxx.vue')这种方式引入的异步组件,前置守卫执行的时候异步组件还没加载完成,所以不能在前置守卫里获取异步组件内部的数据,这时候可以用解析守卫router.beforeResolve,它是在所有异步组件加载完成后执行的。

3种方案怎么选?

最后给大家整理一个选择方案的参考表,方便快速判断: | 场景 | 推荐方案 | |------|----------| | 页面组件内部的局部操作(比如标签页高亮、单个参数变化后的数据刷新) | 方案一:watch/watchEffect | | 同一个组件被复用但参数/查询字符串变化、离开当前组件前需要提醒用户保存 | 方案二:onBeforeRouteUpdate/onBeforeRouteLeave | | 跨组件的全局操作(比如埋点统计、权限验证、动态面包屑导航、动态页面标题) | 方案三:全局路由守卫+状态管理 |

其实不管选哪种方案,核心都是要理解Vue3的响应式原理和Vue Router 4的组合式API特性,还有不同场景下的最佳实践,刚才讲的3种方案我都在实际开发中用过,踩过的坑也都写出来了,只要按照上面的方法来,基本不会有问题,如果还有其他Vue3路由相关的问题,欢迎在评论区留言讨论~

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门