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

Vue3怎么监听路由参数变化才靠谱?新手踩过的5个坑全帮你填了

terry 10小时前 阅读数 126 #Vue

最近在整理社区技术问题帖的时候,发现Vue3路由参数监听这块的提问真的特别多——比如有人改了路由里的id却没触发数据刷新,有人直接用watch监听$route.params却报错,还有人不知道什么时候该用watch什么时候该用别的钩子,其实这些问题大多是因为对Vue3的响应式机制、Vue Router的更新逻辑没摸透,或者踩了一些版本或者写法上的小坑,今天就把这些内容掰碎了聊,从基础写法到进阶优化,再到具体场景下的方案选择,顺便帮你避开新手常犯的5个致命错误。

先搞懂两个基础前提:路由参数什么时候变?Vue3里怎么取参数?

在讲具体的监听方法之前,这两个前提要是搞不明白,后面不管用什么写法都会出问题,先从第一个开始说——

路由参数变化的两种场景

很多人以为只要改了地址栏里的路由参数,页面就会自动变化,但实际上Vue Router默认是有「复用组件」机制的,举个最简单的例子,你做了一个商品详情页,路由是/product/:id,当你从/product/1跳转到/product/2的时候,Vue Router不会销毁原来的ProductDetail组件再重新创建一个,而是直接复用它——这时候组件的created、mounted这些生命周期钩子只会在第一次进入页面的时候触发,参数变了它们根本不会理你,这就是为什么很多人“明明改了id但数据没刷”的核心原因。 也有两种情况组件会被销毁重建:一种是路由里有非参数部分变了,比如从/product/1跳到/category/electronics;另一种是你强制开启了不复用组件的模式,但这个模式非常不推荐,性能会很差。

Vue3里获取路由参数的正确方式,别再直接用$route了

第二个前提就是参数的获取方式,Vue2的时候我们习惯在模板或者script里直接用this.$route.params.id,但Vue3里setup语法糖没有this,而且如果直接用useRouter或者useRoute的返回值里的params,很多新手会踩响应式的坑。 首先回忆一下Vue Router 4.x(对应Vue3)的官方推荐:要获取路由信息,必须用useRoute()这个组合式API,获取到的route对象是响应式的,但注意——它的响应式是「浅层次」的!换句话说,route.params本身不是响应式对象,只有当整个route对象的引用发生变化时,相关的响应式依赖才会更新,那什么时候route对象的引用会变呢?刚好就是刚才说的「路由切换但组件复用」的时候——Vue Router会创建一个新的route对象赋值给useRoute返回的那个响应式引用。 这点非常重要,后面讲watch的具体写法的时候会反复用到,另外补充一点,如果是要获取query参数,逻辑和params完全一样,都是浅层次响应式的route对象下的属性。

最基础的监听方法:watch监听useRoute的返回值

既然知道了route对象是响应式引用,那最直接的监听方法就是用Vue3的watch或者watchEffect来监听它,不过这里有好几种写法,新手很容易选错,导致要么不生效,要么性能浪费,要么拿到的不是想要的数据。

第一种写法:直接watch整个route对象

先写一段代码示例,然后再分析它的优缺点:

import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 直接监听整个route对象
watch(route, (newRoute, oldRoute) => {
  // 这里可以获取到新的和旧的路由信息
  console.log('新的商品id:', newRoute.params.id)
  // 触发数据刷新的逻辑,比如调用getProductDetail(newRoute.params.id)
})

这段代码是能生效的,但它有个非常大的问题——只要route对象里的任何属性变了,不管是params、query、meta还是hash,甚至是matched数组(这个是Vue Router内部用来记录匹配到的路由规则的),watch都会触发,比如你只是在详情页里滚动了一下页面,更新了hash值,watch里的商品详情接口就会被重新调用一次,这显然是没必要的,会浪费服务器资源和前端性能。 所以这种写法只适合需要监听路由所有信息变化的场景,比如做路由埋点的时候,不管跳转到哪里、参数怎么变、hash怎么改都要上报,否则千万不要用。

第二种写法:监听route.params的id属性

很多新手看到第一种写法浪费性能,就会想:那我只监听params里的id不就行了?于是写出了这样的代码:

import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 试图只监听params.id
watch(route.params.id, (newId, oldId) => {
  console.log('新的商品id:', newId)
  // 调用接口
})

这段代码看起来很合理,但你运行一下就会发现——根本不生效!为什么?刚才在基础前提里已经说过了,route.params本身不是响应式对象,它只是route这个响应式引用里的一个普通属性值,当路由切换但组件复用的时候,Vue Router会替换整个route对象的引用,而不是直接修改route.params.id的值,这时候watch监听的是之前那个旧route对象里的params.id,新的route对象里的id根本不会被它追踪到,所以自然不会触发回调。 那如果组件没有被复用呢?比如从详情页跳到首页再跳回来,这时候created或者mounted会重新执行,假设你在mounted里也调用了接口,那没问题,但如果只靠这个watch的话,还是不会有任何反应——因为组件销毁再重建的时候,setup函数会重新执行,useRoute会拿到新的route对象,但watch是在组件挂载前设置的,监听的还是新对象的初始params.id,除非你后续手动改了这个新对象的params.id(但Vue Router不允许直接修改route对象的属性,否则会报错或者出现不可预期的行为),否则watch永远不会触发。 所以这种写法是完全错误的,新手一定要避开!

第三种写法:用getter函数返回params.id(官方推荐!)

既然直接监听属性值不行,那有没有办法让watch追踪到整个route对象的变化,但只在params.id改变的时候才触发回调呢?当然有,官方推荐的就是用getter函数作为watch的第一个参数,代码示例如下:

import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 用getter函数返回你要监听的具体属性
watch(
  () => route.params.id,
  (newId, oldId) => {
    // 只有当id真的变了的时候才会触发
    console.log('新的商品id:', newId)
    // 记得加个判断,避免id是undefined的时候调用接口报错
    if (newId) {
      getProductDetail(newId)
    }
  },
  {
    // 可选参数,看具体需求
    immediate: true, // 第一次进入页面的时候就立即执行一次回调,避免需要在mounted里重复写调用接口的逻辑
    deep: false // 这里因为监听的是基本数据类型(string或者number),所以deep默认就是false,不用显式写,但如果监听的是对象类型的参数,就需要考虑加了
  }
)
// 模拟获取商品详情的函数
const getProductDetail = async (id) => {
  try {
    // 这里替换成你真实的接口调用
    const res = await fetch(`/api/product/${id}`)
    const data = await res.json()
    // 处理数据,赋值给响应式变量展示在页面上
  } catch (err) {
    console.error('获取商品详情失败:', err)
    // 处理错误,比如展示错误提示
  }
}

这段代码就是目前最通用、最靠谱的写法了,几乎能覆盖80%以上的场景,它的核心原理是:getter函数会在每次Vue的响应式系统追踪依赖的时候被执行,当整个route对象的引用发生变化时(也就是组件复用、路由参数变化的时候),getter函数会重新执行,返回新的params.id,这时候Vue会比较新返回的值和旧的值是否相等,如果不相等,就会触发watch的回调。 另外注意一下可选参数immediate:很多新手第一次进入页面的时候会在mounted里调用一次接口,然后在watch里再写一遍,这不仅代码重复,还可能出现竞态条件(比如第一次进入页面的接口还没返回,watch里因为immediate又触发了一次,导致后面的接口先返回覆盖了前面的),加了immediate: true之后,watch会在组件挂载前(或者说setup函数执行完watch的设置之后)立即执行一次回调,这时候就可以把mounted里的代码删掉了,既简洁又避免了竞态条件的风险。

第四种写法:用watchEffect自动追踪依赖

除了watch之外,Vue3还提供了watchEffect这个组合式API,它不需要你明确指定监听的目标,而是会自动执行传入的函数,并追踪函数内部用到的所有响应式依赖,只要依赖发生变化,函数就会重新执行,那用watchEffect怎么监听路由参数呢?代码示例如下:

import { watchEffect } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 直接在watchEffect里用route.params.id
watchEffect(() => {
  const id = route.params.id
  if (id) {
    console.log('新的商品id:', id)
    getProductDetail(id)
  }
})

这段代码看起来比watch更简洁,不用写getter函数,不用比较新旧值,那它是不是比watch更好呢?其实不一定,要看具体场景,watchEffect和watch的主要区别有以下几点:

  1. 触发时机:watchEffect默认就是immediate: true的,第一次就会执行;而watch默认immediate: false,需要显式设置才会第一次执行。
  2. 能否获取旧值:watchEffect只能获取到当前的响应式依赖值,无法获取到变化之前的旧值;而watch可以通过回调函数的第二个参数拿到旧值。
  3. 是否需要明确监听目标:watchEffect自动追踪,watch需要明确指定getter函数或者响应式对象。
  4. 性能控制:watch可以通过deepflush等参数更精细地控制监听的深度和触发的时机;而watchEffect的控制相对弱一些,不过也可以通过第二个参数的flush选项来调整(比如flush: 'post'可以让函数在DOM更新之后再执行)。 如果你不需要获取旧值,而且函数内部只用了很少的响应式依赖(比如只有route.params.id),那用watchEffect是没问题的,代码更简洁;但如果你需要获取旧值,或者函数内部用到了很多响应式依赖,不想让它们变化的时候都触发函数,那还是用watch更稳妥。

新手常犯的5个致命错误,你中了几个?

刚才在讲基础写法的时候已经提到了一两个错误,但还有几个更隐蔽的,很多工作一两年的Vue开发者都可能踩,现在把它们整理出来,帮你彻底避开:

错误1:直接修改useRoute返回的route对象

刚才说过,Vue Router不允许直接修改route对象的任何属性,比如你不能写route.params.id = '3'或者route.query.sort = 'price'——这样写要么会直接报错(在开发环境下Vue Router会有警告),要么会出现不可预期的行为,比如页面URL没变但组件里的数据变了,或者反过来URL变了但组件没更新。 如果要修改路由参数,必须用useRouter返回的router对象的push或者replace方法:

import { useRouter } from 'vue-router'
const router = useRouter()
// 用push方法修改params,会保留历史记录
const changeProductId = (newId) => {
  router.push({
    name: 'ProductDetail', // 推荐用name而不是path,因为path修改params的时候需要手动拼接字符串,容易出错
    params: { id: newId }
  })
}
// 用replace方法修改query,不会保留历史记录(用户点击后退按钮不会回到之前的query状态)
const changeSort = (newSort) => {
  router.replace({
    query: { sort: newSort }
  })
}

这里补充一个小技巧:如果用path的话,params会被忽略!比如你写router.push({ path: '/product', params: { id: '3' } }),跳转到的URL会是/product而不是/product/3,这点一定要注意!所以修改params的时候,必须用name;修改query的时候,用name或者path都可以。

错误2:监听对象类型的params时忘了加deep参数

刚才说第三种写法的时候提到了deep参数,这里再展开讲一下,有些时候我们的路由参数不是基本数据类型,而是对象或者数组——比如一个搜索页面,路由是/search/:filters,这里的filters是一个JSON字符串化的对象,或者有些开发者会直接用query传对象(虽然query传对象在URL里会被转成类似filters[name]=phone&filters[price]=1000-2000的形式,但useRoute返回的route.query.filters会是一个普通对象,不是响应式的)。 这时候如果你直接用getter函数返回route.params.filters或者route.query.filters,然后watch监听的话,只有当整个filters对象的引用发生变化时,watch才会触发——但如果组件复用、路由切换的时候,Vue Router替换了整个route对象,那filters对象的引用肯定会变,所以这时候不加deep也能触发?不对,等一下,如果filters是通过query传递的,而且你是通过点击同一个页面的按钮来修改filters的某个属性(比如只修改filters.price),这时候如果你用router.push或者replace的时候,是直接修改原来的filters对象的属性再传进去的,那新的filters对象的引用和旧的是一样的——这时候整个route对象的引用虽然变了,但getter函数返回的filters对象的引用没变,Vue比较新旧值的时候会认为它们相等,所以watch不会触发! 举个例子说明这种情况:

import { watch, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 假设搜索页面的filters是响应式的,用来绑定表单
const filters = reactive({
  name: '',
  price: ''
})
// 试图监听route.query.filters
watch(
  () => route.query.filters,
  (newFilters) => {
    console.log('新的筛选条件:', newFilters)
    // 调用搜索接口
  },
  { immediate: true }
)
// 修改价格筛选条件的函数
const changePrice = (newPrice) => {
  // 直接修改filters对象的price属性
  filters.price = newPrice
  // 把filters对象传给router.push
  router.push({
    name: 'Search',
    query: { filters }
  })
}

这段代码里,当你第一次调用changePrice的时候,route.query.filters会从undefined变成filters对象,所以watch会触发;但当你第二次调用changePrice的时候,只是修改了filters.price,filters对象的引用没变,这时候router.push会把这个引用传给新的route对象的query.filters,所以getter函数返回的newFilters和旧的filters是同一个引用,Vue会认为它们相等,watch不会触发! 这时候怎么解决呢?有两种方法: 第一种方法是给watch加deep: true参数:

watch(
  () => route.query.filters,
  (newFilters) => {
    console.log('新的筛选条件:', newFilters)
    // 调用搜索接口
  },
  { immediate: true, deep: true }
)

加了deep: true之后,Vue会递归遍历filters对象的所有属性,只要有任何一个属性的值变了,不管引用有没有变,watch都会触发,但这种方法有个性能问题——如果filters对象非常大(比如有几十上百个属性),递归遍历会消耗很多性能。 第二种方法是在传给router.push或者replace的时候,创建一个新的filters对象,而不是直接修改原来的:

const changePrice = (newPrice) => {
  // 不直接修改原来的filters对象,而是用展开运算符创建一个新的
  const newFilters = {
    ...filters,
    price: newPrice
  }
  // 把新的filters对象传给router.push
  router.push({
    name: 'Search',
    query: { filters: newFilters }
  })
  // 这里顺便更新一下绑定表单的filters对象,保持同步
  Object.assign(filters, newFilters)
}

这种方法不需要加deep: true,因为每次传给route的都是新的filters对象,引用变了,Vue自然会触发watch,而且性能更好,所以更推荐第二种方法

错误3:没有处理竞态条件

什么是竞态条件?简单来说就是,你先触发了请求A(比如获取id=1的商品详情),然后又触发了请求B(比如获取id=2的商品详情),但因为网络原因,请求B先返回了,覆盖了页面上的数据,然后请求A才返回,又覆盖了请求B的数据——这就导致用户看到的是id=1的商品详情,但地址栏里的id是2,非常影响用户体验。 这种情况在路由参数切换比较频繁的时候(比如用户快速点击商品列表里的不同商品)特别容易出现,那怎么解决呢?有几种常见的方法: 第一种方法是取消上一个未完成的请求:现在大部分的HTTP请求库都支持取消请求,比如Axios可以用CancelToken或者AbortController(推荐用AbortController,因为CancelToken已经被Axios官方标记为 deprecated 了),代码示例如下(用Axios和AbortController):

import { watch, ref } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
// 用来存储当前请求的AbortController
let currentAbortController = null
// 模拟获取商品详情的函数
const getProductDetail = async (id) => {
  // 如果上一个请求还没完成,就取消它
  if (currentAbortController) {
    currentAbortController.abort()
  }
  // 创建一个新的AbortController
  currentAbortController = new AbortController()
  const signal = currentAbortController.signal
  try {
    const res = await axios.get(`/api/product/${id}`, { signal })
    // 处理数据
  } catch (err) {
    // 如果是取消请求导致的错误,就不要展示错误提示
    if (!axios.isCancel(err)) {
      console.error('获取商品详情失败:', err)
      // 处理其他错误
    }
  }
}
watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      getProductDetail(newId)
    }
  },
  { immediate: true }
)

第二种方法是忽略旧请求的返回结果:如果你的请求库不支持取消请求,或者你不想用取消请求的功能,那可以用一个变量来记录当前最新的请求id,只有当返回结果的请求id和最新的id一致时,才处理数据,代码示例如下:

import { watch, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 用来存储当前最新的请求id
const latestRequestId = ref(0)
const getProductDetail = async (id) => {
  // 生成一个新的请求id
  const currentRequestId = ++latestRequestId.value
  try {
    // 这里替换成你真实的接口调用
    const res = await fetch(`/api/product/${id}`)
    const data = await res.json()
    // 只有当当前请求的id和最新的id一致时,才处理数据
    if (currentRequestId === latestRequestId.value) {
      // 处理数据
    }
  } catch (err) {
    // 同样只有当id一致时,才处理错误
    if (currentRequestId === latestRequestId.value) {
      console.error('获取商品详情失败:', err)
      // 处理错误
    }
  }
}
watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      getProductDetail(newId)
    }
  },
  { immediate: true }
)

这两种方法都能很好地解决竞态条件的问题,具体用哪种看你的项目情况——如果用Axios的话,推荐第一种;如果用原生fetch或者其他不支持取消请求的库,推荐第二种。

错误4:在onBeforeRouteUpdate里重复写监听逻辑

Vue Router 4.x还提供了一个导航守卫钩子叫onBeforeRouteUpdate,它是专门用来处理「组件复用、路由参数变化」这种场景的,会在路由更新但组件复用之前触发,很多新手会先在watch里写一遍监听逻辑,然后又在onBeforeRouteUpdate里写一遍,这不仅代码重复,还可能出现冲突。 其实onBeforeRouteUpdate的作用和watch监听route的getter函数是类似的,但它有一个watch没有的功能——可以控制是否允许路由更新(虽然一般情况下我们不会阻止,因为都是跳转到同一个页面的不同参数),还可以在路由更新之前做一些准备工作(比如清空上一个页面的表单数据、取消上一个请求等)。 那什么时候用onBeforeRouteUpdate,什么时候用watch呢?其实两者可以配合使用——比如在onBeforeRouteUpdate里清空上一个页面的数据、取消上一个请求,然后在watch里调用接口获取新数据;或者如果你不需要控制路由更新,也不需要在更新前做什么准备工作,那只用watch就够了。 这里给一个onBeforeRouteUpdate和watch配合使用的示例:

import { watch, ref, onBeforeUnmount } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
import axios from 'axios'
const route = useRoute()
let currentAbortController = null
// 商品详情数据
const productDetail = ref(null)
// 加载状态
const loading = ref(false)
const getProductDetail = async (id) => {
  if (currentAbortController) {
    currentAbortController.abort()
  }
  currentAbortController = new AbortController()
  const signal = currentAbortController.signal
  loading.value = true
  productDetail.value = null
  try {
    const res = await axios.get(`/api/product/${id}`, { signal })
    productDetail.value = res.data
  } catch (err) {
    if (!axios.isCancel(err)) {
      console.error('获取商品详情失败:', err)
    }
  } finally {
    loading.value = false
  }
}
// 组件复用、路由更新之前触发
onBeforeRouteUpdate((to, from, next) => {
  // 清空上一个页面的数据和加载状态(其实getProductDetail里已经做了,但这里可以再做一次,更保险)
  productDetail.value = null
  loading.value = true
  // 取消上一个请求(getProductDetail里也做了)
  if (currentAbortController) {
    currentAbortController.abort()
  }
  // 必须调用next(),否则路由不会更新
  next()
})
watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      getProductDetail(newId)
    }
  },
  { immediate: true }
)
// 组件卸载之前触发,取消最后一个未完成的请求
onBeforeUnmount(() => {
  if (currentAbortController) {
    currentAbortController.abort()
  }
})

注意一下onBeforeRouteUpdate的回调函数里必须调用next(),否则路由会一直卡在更新前的状态,不会跳转到新的参数页面,onBeforeRouteUpdate只能在setup函数里调用,不能在组件外部或者普通函数里调用。

错误5:忘记处理params为undefined的情况

很多新手在调用接口的时候,直接把route.params.id传进去,没有做任何判断,这就会导致当用户直接访问/product(没有带id参数)的时候,接口会请求/api/product/undefined,返回404或者其他错误,影响用户体验。 所以在调用接口之前,一定要加一个判断,确保params或者query里的参数是存在的、合法的。

watch(
  () => route.params.id,
  (newId) => {
    // 不仅要判断newId是否存在,还要判断它是否是合法的类型(比如字符串或者数字)
    if (newId && typeof newId === 'string' && /^\d+$/.test(newId)) {
      getProductDetail(newId)
    } else {
      // 如果参数不合法,可以跳转到首页或者404页面
      router.push({ name: 'Home' })
      // 或者展示一个错误提示
      console.error('商品id不合法')
    }
  },
  { immediate: true }
)

也可以在路由规则里加一个参数验证,这样Vue Router会在路由跳转之前就验证参数是否合法,如果不合法,就不会跳转到这个页面,而是会触发onError钩子或者跳转到404页面,路由规则参数验证的写法如下:

import { createRouter, createWebHistory } from 'vue-router'
const routes = [
  {
    path: '/product/:id',
    name: 'ProductDetail',
    component: () => import('../views/ProductDetail.vue'),
    // 参数验证,返回true表示合法,返回false表示不合法
    props: true, // 顺便提一下,props: true可以把route.params直接作为组件的props传入,这样组件就不需要依赖useRoute了,更利于测试和复用
    beforeEnter: (to, from, next) => {
      const id = to.params.id
      if (id && typeof id === 'string' && /^\d+$/.test(id)) {
        next()
      } else {
        // 跳转到404页面
        next({ name: 'NotFound' })
      }
    }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('../views/NotFound.vue')
  }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

这里顺便提一下props: true这个配置——它可以把route.params直接作为组件的props传入,这样组件就不需要在setup里调用useRoute了,更利于组件的测试和复用,比如ProductDetail.vue可以改成这样:

// ProductDetail.vue
import { watch, ref, onBeforeUnmount } from 'vue'
import axios from 'axios'
// 直接用props接收id
const props = defineProps(['id'])
let currentAbortController = null
const productDetail = ref(null)
const loading = ref(false)
const getProductDetail = async (id) => {
  // 这里的id已经是props传过来的,不需要再从route里取了
  if (currentAbortController) {
    currentAbortController.abort()
  }
  currentAbortController = new AbortController()
  const signal = currentAbortController.signal
  loading.value = true
  productDetail.value = null
  try {
    const res = await axios.get(`/api/product/${id}`, { signal })
    productDetail.value = res.data
  } catch (err) {
    if (!axios.isCancel(err)) {
      console.error('获取商品详情失败:', err)
    }
  } finally {
    loading.value = false
  }
}
// 监听props.id的变化(props是响应式的!)
watch(
  () => props.id,
  (newId) => {
    // 因为路由规则里已经做了参数验证,所以这里可以不用再做太复杂的判断,但加一个还是更保险
    if (newId) {
      getProductDetail(newId)
    }
  },
  { immediate: true }
)
onBeforeUnmount(() => {
  if (currentAbortController) {
    currentAbortController.abort()
  }
})

这种写法是不是更简洁、更利于测试?如果你把组件单独拿出来测试的话,只需要给它传一个id的props就可以了,不需要模拟Vue Router的环境,所以如果你的组件只需要用到route.params的话,强烈推荐用props: true的方式

进阶优化:有没有比watch更好的监听方案?

刚才讲的都是最基础的监听方案,能覆盖大部分场景,但如果你想追求更好的性能或者更简洁的代码,有没有更好的方案呢?当然有,这里给大家介绍两种:

方案1:用pinia或者vuex管理路由参数和数据

如果你的项目里用了状态管理工具(比如Pinia或者Vuex),那可以把路由参数和对应的数据都放在状态管理里,这样不仅可以统一管理,还可以避免组件之间的数据传递,甚至可以做数据缓存(比如用户点击过的商品详情,下次再点击的时候直接从缓存里取,不需要再调用接口)。 这里给一个用Pinia的简单示例:

// stores/product.js
import { defineStore } from 'pinia'
import axios from 'axios'
export const useProductStore = defineStore('product', {
  state: () => ({
    // 存储商品详情的缓存,key是商品id,value是商品数据
    productCache: {},
    // 当前选中的商品id
    currentProductId: null,
    // 加载状态
    loading: false
  }),
  getters: {
    // 获取当前商品的详情,如果缓存里有就从缓存里取,没有就返回null
    currentProductDetail: (state) => {
      return state.currentProductId ? state.productCache[state.currentProductId] : null
    }
  },
  actions: {
    // 设置当前商品id,并获取详情
    async setCurrentProductId(id) {
      // 如果id和当前的一样,就不需要做任何操作
      if (id === this.currentProductId) {
        return
      }
      this.currentProductId = id
      // 如果缓存里有这个id的商品数据,就不需要再调用接口了
      if (this.productCache[id]) {
        return
      }
      // 否则调用接口获取数据
      this.loading = true
      try {
        const res = await axios.get(`/api/product/${id}`)
        // 把数据存入缓存
        this.productCache[id] = res.data
      } catch (err) {
        console.error('获取商品详情失败:', err)
      } finally {
        this.loading = false
      }
    }
  }
})

然后在ProductDetail.vue里,配合props: true使用:

// ProductDetail.vue
import { watch } from 'vue'
import { useProductStore } from '../stores/product'
const props = defineProps(['id'])
const productStore = useProductStore()
// 监听props.id的变化,调用store的action
watch(
  () => props.id,
  (newId) => {
    if (newId) {
      productStore.setCurrentProductId(newId)
    }
  },
  { immediate: true }
)

最后在模板里直接用store的getters:

<template>
  <div v-if="productStore.loading">加载中...</div>
  <div v-else-if="productStore.currentProductDetail">
    <h1>{{ productStore.currentProductDetail.name }}</h1>
    <p>{{ productStore.currentProductDetail.price }}</p>
    <!-- 其他商品详情 -->
  </div>
  <div v-else>商品不存在</div>
</template>

这种方案的优点很明显:数据统一管理、可以做缓存、组件更简洁、利于测试和复用;缺点是需要引入状态管理工具,如果你的项目很小,可能没必要。

方案2:用Suspense和async setup

Vue3还提供了Suspense组件和async setup语法糖,可以用来处理异步组件的加载,也可以用来处理路由参数变化时的数据刷新,不过这种方案目前还处于实验阶段(虽然很多项目已经在用了),而且需要配合错误边界组件使用,否则如果异步请求失败,整个页面会白屏。 这里给一个简单的示例,感兴趣的可以试试:

// ProductDetail.vue
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
// async setup语法糖,直接在setup里await异步请求
const productDetail = await axios.get(`/api/product/${route.params.id}`).then(res => res.data)
// 注意:async setup里不能直接用watch或者watchEffect监听路由变化,因为setup是异步的,
// 需要用onBeforeRouteUpdate或者把watch放在一个同步的函数里,然后在async setup里调用
// 这里用onBeforeRouteUpdate来处理路由参数变化
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from, next) => {
  // 必须先调用next(),否则路由不会更新
  next()
  // 然后再获取新的商品详情
  productDetail.value = await axios.get(`/api/product/${to.params.id}`).then(res => res.data)
})

然后在App.vue或者父组件里用Suspense包裹:

<template>
  <Suspense>
    <!-- 正常显示的内容 -->
    <template #default>
      <router-view />
    </template>
    <!-- 加载中的内容 -->
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

这种方案的优点是代码非常简洁,不需要用watch或者watchEffect,不需要手动管理加载状态;缺点是处于实验阶段、需要配合错误边界组件、async setup里的响应式处理比较麻烦(比如刚才的productDetail不能是ref,因为setup是异步的,需要用别的方式处理),所以如果你的项目是生产环境,而且对稳定性要求很高,暂时不推荐用这种方案

不同场景下应该选哪种方案?

最后给大家做一个总结,帮你快速选择适合自己项目的方案:

  1. 项目很小,不需要状态管理,只需要监听基本数据类型的params或query:用官方推荐的watch + getter函数 + immediate参数。
  2. 需要监听对象类型的params或query,而且追求性能:用watch + getter函数 + 每次修改参数时创建新对象(不用deep)。
  3. 需要控制路由更新,或者需要在更新前做准备工作:用watch + getter函数 + onBeforeRouteUpdate。
  4. 不需要依赖useRoute,追求组件的测试和复用:用props: true + watch props.id。
  5. 项目用了状态管理工具,需要统一管理数据和缓存:用Pinia/Vuex + props: true + watch props.id。
  6. 项目是个人项目或者测试项目,追求代码简洁:可以试试Suspense + async setup。

不管选哪种方案,一定要记得避开新手常犯的5个错误:直接修改route对象、监听对象类型参数时忘了加deep或者创建新对象、没有处理竞态条件、在onBeforeRouteUpdate里重复写逻辑、忘记处理参数为undefined的情况。

好了,今天的内容就讲到这里,如果你还有其他关于Vue3路由参数监听的问题,欢迎在评论区留言讨论!

版权声明

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

热门