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

或者yarn add lodash-es

terry 2小时前 阅读数 31 #Vue
文章标签 lodashes

Vue3 怎么监听路由query变化 踩坑全解决附实战代码

在开发带搜索、筛选、分页的页面时,路由query参数简直是个宝——既能把当前状态存进地址栏分享给别人,刷新后还能自动还原,体验感拉满,但真上手Vue3 watch route query,很多人要么发现没反应,要么刷新了状态对不上,要么频繁触发接口请求卡成狗,别急,今天咱们把所有坑挖出来填实,从基础写法到进阶优化再到完整实战,一步步讲明白。

为什么要选watch监听route.query,而不用其他方法?

很多刚接触Vue3路由的同学,会先想到用onMounted+onUpdated这种生命周期组合拳,或者setup里直接访问route.query当响应式数据,但这两种方法要么太麻烦要么有局限,watch才是最优解,咱们对比着说清楚。

生命周期组合拳为什么不行?

生命周期的逻辑大概是这样的:onMounted的时候,先拿一次初始的route.query,去请求数据或者初始化组件;onUpdated的时候,再检查route.query有没有变,变了再执行后续操作,但这里有两个大问题: 第一个问题是“组件会不会更新”的不确定性,如果你的页面里没有其他响应式数据变化,只有route.query变了,而你绑定route.query的地方很少甚至没有,那onUpdated根本不会触发!举个例子,你做了个只有后端返回列表的纯展示页,整个组件的template只用到了后端返回的list,没有直接用route.query,那query从?page=1跳到?page=2,模板不会变,onUpdated就不会执行,你自然拿不到新的query,页面也不会更新。 第二个问题是逻辑太分散,难维护,初始逻辑放在onMounted,变化逻辑放在onUpdated,如果逻辑复杂(比如参数校验、防抖节流、缓存处理),这两个钩子得写重复的代码,改起来也容易漏,还有更细的坑,比如路由切换前组件销毁,onUpdated可能不会触发最后一次;或者路由是keep-alive缓存的,组件不会重新mount,但query变了,onMounted不跑,这时候状态全乱。

setup里直接访问route.query为什么有隐患?

如果你用的是Vue Router 4的Composition API(也就是import { useRoute } from 'vue-router'那种),那useRoute返回的route对象本身是响应式的,对吧?但这里的响应式是“浅层响应式”!Vue Router的官方文档里明确提过这点,但很多人没注意。

浅层响应式意味着什么?只有route.path、route.query这种顶层属性的引用变化才会触发响应,可query是个对象啊!如果你的页面是从?keyword=vue跳到?keyword=react,那整个route.query的引用其实没变,只是它里面的keyword属性变了,这时候你直接在template里用route.query.keyword没问题,Vue会自动追踪到深层属性,但如果你在setup里用computed或者直接写逻辑(比如直接if(route.query.keyword)请求数据),那除非你加了deep或者访问的时候显式触发深层属性(比如解构的时候用toRefs或者单个toRef),否则逻辑不会自动执行!

举个反例,很多新手会这么写:

// setup里
import { useRoute, onMounted } from 'vue-router'
import { getList } from '@/api/list'
const route = useRoute()
onMounted(() => {
  getList({ page: route.query.page || 1, keyword: route.query.keyword || '' })
})
// 想当然以为query变了会自动跑,结果根本不会

这种写法刷新是没问题,但切换query参数页面死都不动,就是浅层响应式惹的祸。

所以综合来看,watch route.query才是既稳又灵活的方案——既能监听query的所有变化(不管是顶层引用还是深层属性),又能把初始逻辑和变化逻辑合并在一起,还能配合各种配置项解决各种问题。


watch route.query的3种基础写法,你用对了吗?

既然选了watch,那接下来就是写对,这里要分三种情况,分别对应不同的需求场景,大家别乱用,不然还是会踩坑。

情况1:query有增删/整个对象替换时才监听——用普通watch+直接监听route.query

这种情况用得比较少,但如果你的项目里有这种逻辑(比如从没有keyword参数的状态,突然跳到有keyword的状态,才触发某些全局弹窗提示),那可以用这种写法:

import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 直接监听route.query,不用加任何配置
watch(route.query, (newQuery, oldQuery) => {
  console.log('整个query对象变了!', newQuery, oldQuery)
  // 你的逻辑
})

为什么这种情况用得少?因为刚才说过,Vue Router 4的route.query是保持引用不变的!除非你用了类似router.replace({ query: {...} })或者router.push({ query: {...newObj} })这种显式创建新对象替换旧query的写法,否则普通的属性赋值(比如只改page或者keyword),整个route.query的引用不会变,普通watch根本不会触发!新手踩的第一个“watch没反应”的坑,大概率就是这种写法,但需求又只是改属性,所以白搭。

情况2:query的任意属性变化都要监听——用普通watch+{ deep: true }

这是最常用的场景了!比如搜索页、筛选页、分页页,不管改哪个参数,都要重新请求数据,这时候就加个deep:true配置:

import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { getList } from '@/api/list'
const route = useRoute()
// 加deep:true,不管属性怎么变都监听
watch(
  route.query,
  (newQuery) => {
    const params = {
      page: parseInt(newQuery.page) || 1, // 注意query参数默认是字符串,要转成数字哦!
      pageSize: parseInt(newQuery.pageSize) || 10,
      keyword: newQuery.keyword || '',
      status: newQuery.status || 'all'
    }
    getList(params)
  },
  { deep: true }
)

哎,这里顺便提一句,query参数是字符串这个点也是个大踩坑点!很多人在逻辑里直接判断if(params.page === 1),结果page是字符串'1',条件不成立,接口请求参数错了,数据也出不来,找半天找不到原因,不管你是用push/replace传的数字还是字符串,路由解析出来的query永远是字符串类型,拿到手一定要先转类型!

不过这个写法虽然能解决“没反应”的问题,但又有个新坑:组件刚挂载的时候,watch不会自动执行!所以你刷新页面的时候,query明明有值,但接口不会请求,页面是空的,这时候怎么办?加个immediate:true配置!

情况3:组件刚挂载+属性任意变化都要监听——用普通watch+{ deep: true, immediate: true }

这就是最通用的“万金油”写法了,能覆盖90%的日常场景:

import { watch, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getList } from '@/api/list'
const route = useRoute()
const list = ref([])
const loading = ref(false)
watch(
  route.query,
  async (newQuery) => {
    loading.value = true
    try {
      const params = {
        page: parseInt(newQuery.page) || 1,
        pageSize: parseInt(newQuery.pageSize) || 10,
        keyword: newQuery.keyword?.trim() || '', // 再加个trim防止用户输入空格
        status: newQuery.status || 'all'
      }
      const res = await getList(params)
      list.value = res.data.list
    } catch (err) {
      console.error('获取列表失败', err)
    } finally {
      loading.value = false
    }
  },
  { deep: true, immediate: true }
)

这样写之后,不管是刷新页面,还是改任意query参数,接口都会自动请求,loading也会正确显示,基本逻辑就通了。

万金油”写法虽然稳,但不是最优的,接下来咱们讲进阶优化,解决性能问题和更细的场景需求。


watch route.query的进阶优化,告别接口请求卡顿和无效监听

刚才的“万金油”写法有两个明显的问题:第一个是频繁改参数会频繁触发接口请求,比如用户快速输入搜索关键词,每敲一个字母就请求一次,既浪费服务器资源,又可能导致前一次请求的结果覆盖后一次(比如最后敲的是“react”,但前一次“reac”的请求慢,最后显示的是“reac”的结果,这就是“竞态问题”);第二个是加了deep:true之后,Vue会递归监听query对象的所有属性,虽然query对象一般不大,递归监听的性能损耗可以忽略,但如果能精准监听我们需要的属性,不是更优雅吗?

优化1:用防抖解决频繁请求的问题

防抖的原理很简单:当用户连续触发某个操作时,只有在操作停止后的指定时间内没有再触发,才会执行真正的逻辑,比如搜索关键词的防抖时间可以设为300-500毫秒,让用户敲完字停顿一下再请求。

不过这里要注意,Vue3的watch配置里没有debounce选项!很多新手会在watch的回调函数里直接写setTimeout,这其实是错的——因为每次触发watch都会创建一个新的setTimeout,不会清除上一个,结果还是会在指定时间后执行所有的请求,只是延迟了而已,正确的做法是用lodash-es的debounce函数(或者自己封装一个),然后把回调函数包裹起来,还要注意用箭头函数保持this指向(虽然setup里不用this,但习惯要养好),另外如果组件销毁了,要记得取消防抖函数,防止内存泄漏。

先安装lodash-es(因为要配合Vue3的Tree Shaking,不能直接用lodash的debounce,不然会把整个lodash包都打包进去,体积太大):

npm install lodash-es```
然后修改代码:
```javascript
import { watch, ref, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { getList } from '@/api/list'
import { debounce } from 'lodash-es'
const route = useRoute()
const list = ref([])
const loading = ref(false)
// 封装请求函数
const fetchList = async (newQuery) => {
  loading.value = true
  try {
    const params = {
      page: parseInt(newQuery.page) || 1,
      pageSize: parseInt(newQuery.pageSize) || 10,
      keyword: newQuery.keyword?.trim() || '',
      status: newQuery.status || 'all'
    }
    const res = await getList(params)
    list.value = res.data.list
  } catch (err) {
    console.error('获取列表失败', err)
  } finally {
    loading.value = false
  }
}
// 用debounce包裹,防抖时间设为300毫秒,trailing设为true(默认就是true,意思是停止操作后执行),leading设为false(默认也是false,意思是操作开始时不执行)
// 这里注意!不要直接把debounce的返回值写在watch的回调里,要先赋值给一个变量,这样才能在onUnmounted里取消它
const debouncedFetchList = debounce(fetchList, 300)
// 组件销毁时取消防抖函数
onUnmounted(() => {
  debouncedFetchList.cancel()
})
watch(
  route.query,
  (newQuery) => {
    debouncedFetchList(newQuery)
  },
  { deep: true, immediate: true }
)

这样修改之后,用户快速输入搜索关键词就不会频繁请求了,只有在停顿300毫秒后才会请求一次,体验感提升很多。

不过防抖解决了频繁请求的问题,但解决不了“竞态问题”——也就是前一次慢请求覆盖后一次快请求的问题,那怎么解决竞态问题呢?

优化2:用请求取消+请求ID解决竞态问题

请求取消的方法有很多种,如果你用的是axios的话,最简单的方法是用axios的CancelToken或者AbortController(推荐用AbortController,因为CancelToken已经被axios官方标记为弃用了)。

先修改你的axios封装文件,/utils/request.js:

import axios from 'axios'
import { ElMessage } from 'element-plus' // 假设用的是Element Plus
// 创建axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})
// 存放正在进行的请求的Map,key是请求的唯一标识,value是AbortController的实例
const pendingRequests = new Map()
// 生成请求的唯一标识的函数
const generateRequestKey = (config) => {
  const { method, url, params, data } = config
  return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}
// 添加请求到pendingRequests的函数
const addPendingRequest = (config) => {
  // 如果已经有这个请求了,先取消上一个
  const requestKey = generateRequestKey(config)
  if (pendingRequests.has(requestKey)) {
    pendingRequests.get(requestKey).abort()
    pendingRequests.delete(requestKey)
  }
  // 创建新的AbortController实例,添加到pendingRequests
  const controller = new AbortController()
  config.signal = controller.signal
  pendingRequests.set(requestKey, controller)
}
// 从pendingRequests中移除请求的函数
const removePendingRequest = (config) => {
  const requestKey = generateRequestKey(config)
  if (pendingRequests.has(requestKey)) {
    pendingRequests.delete(requestKey)
  }
}
// 请求拦截器
service.interceptors.request.use(
  config => {
    // 添加请求到pendingRequests
    addPendingRequest(config)
    // 这里可以添加token之类的逻辑
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)
// 响应拦截器
service.interceptors.response.use(
  response => {
    // 从pendingRequests中移除请求
    removePendingRequest(response.config)
    const res = response.data
    // 这里可以根据后端返回的状态码做判断
    if (res.code !== 200) {
      ElMessage.error(res.message || '请求失败')
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res
  },
  error => {
    // 如果是请求被取消的,就不做处理
    if (axios.isCancel(error) || error.name === 'AbortError') {
      console.log('请求被取消')
    } else {
      // 从pendingRequests中移除请求
      if (error.config) {
        removePendingRequest(error.config)
      }
      ElMessage.error(error.message || '网络错误')
    }
    return Promise.reject(error)
  }
)
export default service

这样修改之后,只要是重复的请求(method、url、params、data都一样),新的请求发起时就会自动取消上一个请求,完美解决竞态问题。

不过这里的请求唯一标识是根据method、url、params、data生成的,如果你的query参数变化很快,params也会变化很快,那每次的请求唯一标识都不一样,自动取消就不管用了,这时候可以给请求加一个自定义的ID,比如用时间戳或者一个全局的计数器,然后在请求拦截器里根据这个自定义ID来取消上一个请求。

修改一下@/api/list.js:

import request from '@/utils/request'
// 全局的计数器,每次请求加1
let requestId = 0
export const getList = (params) => {
  return request({
    url: '/api/list',
    method: 'get',
    params,
    // 自定义的请求ID,用于取消上一个同类型的请求
    customRequestId: 'getList'
  })
}

然后修改@/utils/request.js里的generateRequestKey、addPendingRequest、removePendingRequest函数:

// 生成请求的唯一标识的函数
const generateRequestKey = (config) => {
  // 如果有customRequestId,就用customRequestId作为唯一标识
  if (config.customRequestId) {
    return config.customRequestId
  }
  // 否则用原来的方法
  const { method, url, params, data } = config
  return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}

这样不管params怎么变,只要customRequestId是'getList',新的请求发起时就会自动取消上一个同类型的请求,彻底解决竞态问题。

优化3:精准监听指定属性,减少不必要的递归监听

刚才说过,加了deep:true之后,Vue会递归监听query对象的所有属性,虽然性能损耗不大,但如果能精准监听我们需要的属性,不是更优雅吗?这时候可以用toRef或者toRefs来把我们需要的属性转成响应式引用,然后直接监听这些引用,不用加deep:true。

比如我们的页面只需要监听page、keyword、status这三个属性,pageSize是固定的,那就可以这么写:

import { watch, ref, toRef, toRefs, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { getList } from '@/api/list'
import { debounce } from 'lodash-es'
const route = useRoute()
// 方法1:用toRefs把整个route.query的属性转成响应式引用,然后解构出来
const { page, keyword, status } = toRefs(route.query)
// 方法2:如果只需要几个属性,用toRef更省内存
// const page = toRef(route.query, 'page')
// const keyword = toRef(route.query, 'keyword')
// const status = toRef(route.query, 'status')
const list = ref([])
const loading = ref(false)
const fetchList = async () => {
  loading.value = true
  try {
    const params = {
      page: parseInt(page.value) || 1,
      pageSize: 10,
      keyword: keyword.value?.trim() || '',
      status: status.value || 'all'
    }
    const res = await getList(params)
    list.value = res.data.list
  } catch (err) {
    console.error('获取列表失败', err)
  } finally {
    loading.value = false
  }
}
const debouncedFetchList = debounce(fetchList, 300)
onUnmounted(() => {
  debouncedFetchList.cancel()
})
// 直接监听这三个响应式引用组成的数组,不用加deep:true
watch(
  [page, keyword, status],
  () => {
    debouncedFetchList()
  },
  { immediate: true }
)

这样写之后,Vue只会监听这三个属性的变化,不会递归监听其他属性,更优雅也更高效,而且如果这三个属性中有一个是undefined,后来变成了有值,或者反过来,也会触发watch,比普通watch+deep:true更精准。


watch route.query的进阶场景:监听部分变化/路由回退时的特殊处理

刚才讲的都是通用场景,接下来讲两个比较常见的进阶场景,帮大家解决更细的需求。

进阶场景1:只监听部分属性变化,忽略其他属性变化

比如我们的页面有page、pageSize、keyword、status四个属性,其中pageSize是固定的下拉框,只有用户主动选择才会变化,变化的时候要重置page为1,然后再请求数据;而keyword和status变化的时候,也要重置page为1,然后再请求数据;但page变化的时候,不需要重置其他属性,直接请求数据就行,这时候怎么处理?

这里可以拆成两个watch:一个监听keyword、status、pageSize,变化的时候重置page为1,然后请求数据;另一个监听page,变化的时候直接请求数据,不过要注意,如果重置page为1的时候,会触发page的watch,导致请求两次数据,所以要加个判断或者用watchEffect?不对,watchEffect也会触发两次,更好的做法是用watch的第三个参数里的flush选项?或者在重置page的时候,用router.replace,并且设置replace的query里的page为1,然后让page的watch不要在这时候触发?

或者更简单的做法是,把page的变化和其他属性的变化分开处理,并且在处理其他属性变化的时候,先判断page是不是已经是1了,如果不是,就重置为1,并且不要在这时候触发page的watch?不对,好像不行,因为router.replace会修改route.query,route.query的page属性会变,不管用什么方法,page的watch都会触发,那怎么办?可以加一个全局的标志位,比如isResettingPage,当处理其他属性变化的时候,把isResettingPage设为true,然后重置page为1,这时候page的watch会判断isResettingPage,如果是true,就不请求数据,然后把isResettingPage设为false。

对,这个方法可行:

import { watch, ref, toRefs, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getList } from '@/api/list'
import { debounce } from 'lodash-es'
const route = useRoute()
const router = useRouter()
const { page, pageSize, keyword, status } = toRefs(route.query)
const list = ref([])
const loading = ref(false)
// 全局标志位,判断是否正在重置page
const isResettingPage = ref(false)
const fetchList = async () => {
  loading.value = true
  try {
    const params = {
      page: parseInt(page.value) || 1,
      pageSize: parseInt(pageSize.value) || 10,
      keyword: keyword.value?.trim() || '',
      status: status.value || 'all'
    }
    const res = await getList(params)
    list.value = res.data.list
  } catch (err) {
    console.error('获取列表失败', err)
  } finally {
    loading.value = false
  }
}
const debouncedFetchList = debounce(fetchList, 300)
onUnmounted(() => {
  debouncedFetchList.cancel()
})
// 第一个watch:监听page变化,直接请求数据,但如果是正在重置page,就不请求
watch(
  page,
  () => {
    if (!isResettingPage.value) {
      debouncedFetchList()
    }
  },
  { immediate: true }
)
// 第二个watch:监听pageSize、keyword、status变化,重置page为1,然后请求数据
watch(
  [pageSize, keyword, status],
  (newVals, oldVals) => {
    // 注意oldVals可能是undefined,所以要加判断
    const oldPageSize = oldVals?.[0]
    const oldKeyword = oldVals?.[1]
    const oldStatus = oldVals?.[2]
    // 如果三个属性都没变,就不执行(刚挂载的时候immediate会触发,但oldVals是undefined,所以会执行)
    if (oldPageSize === pageSize.value && oldKeyword === keyword.value && oldStatus === status.value) {
      return
    }
    // 把标志位设为true
    isResettingPage.value = true
    // 重置page为1,并且保持其他参数不变
    router.replace({
      query: {
        ...route.query,
        page: 1
      }
    })
    // 这里注意!router.replace是异步的,所以要在下一个宏任务里把标志位设为false,并且执行请求
    setTimeout(() => {
      isResettingPage.value = false
      debouncedFetchList()
    }, 0)
  },
  { immediate: false } // 刚挂载的时候不要触发,因为第一个watch已经触发了
)

这样修改之后,不管是改pageSize、keyword还是status,都会先重置page为1,然后只请求一次数据;改page的时候,直接请求一次数据,完美解决需求。

进阶场景2:路由回退时的特殊处理(比如保留滚动位置)

比如我们的列表页是长列表,用户滚动到第100条数据的位置,然后点进去看详情页,看完之后回退到列表页,这时候我们希望滚动位置自动还原,而不是回到顶部,这时候怎么处理?

这里可以配合Vue Router 4的scrollBehavior函数和sessionStorage来实现,首先在路由配置文件里设置scrollBehavior函数,当路由回退时,自动滚动到之前保存的位置:

// @/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import List from '@/views/List.vue'
import Detail from '@/views/Detail.vue'
const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/list', name: 'List', component: List },
  { path: '/detail/:id', name: 'Detail', component: Detail }
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
  // scrollBehavior函数,接收to、from、savedPosition三个参数
  scrollBehavior(to, from, savedPosition) {
    // 如果有savedPosition,说明是路由回退,滚动到savedPosition
    if (savedPosition) {
      return savedPosition
    } else {
      // 否则滚动到顶部
      return { top: 0 }
    }
  }
})
export default router

不过Vue Router 4的savedPosition只在使用浏览器的前进/后退按钮或者router.go(-1)/router.forward()的时候才会有,如果是用router.push('/list')或者router.replace('/list')跳转到列表页,savedPosition是undefined,而且如果我们的列表页是keep-alive缓存的,那组件不会重新mount,scrollBehavior会自动生效;但如果不是keep-alive缓存的,那组件会重新mount,接口会重新请求,数据会重新加载,这时候虽然scrollBehavior会执行,但列表还没渲染出来,滚动位置就会失效。

这时候怎么办?可以配合watch route.query和sessionStorage来实现:当列表页的scroll事件触发时,把当前的滚动位置保存到sessionStorage里,key是路由的path加上所有的query参数(这样不同的搜索/筛选/分页状态有不同的滚动位置);当组件重新mount或者watch route.query执行完请求之后,检查sessionStorage里有没有对应的滚动位置,如果有,就滚动到那个位置。

先修改列表页的template,给滚动容器加个ref(假设你的滚动容器是div,不是window):

<template>
  <div class="list-container" ref="listContainerRef">
    <div v-loading="loading" element-loading-text="加载中...">
      <div v-for="item in list" :key="item.id" class="list-item">
        <h3>{{ item.title }}</h3>
        <p>{{ item.content }}</p>
        <router-link :to="`/detail/${item.id}`">查看详情</router-link>
      </div>
    </div>
  </div>
</template>

然后修改列表页的script:

import { watch, ref, toRefs, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getList } from '@/api/list'
import { debounce, throttle } from 'lodash-es'
const route = useRoute()
const router = useRouter()
const { page, pageSize, keyword, status } = toRefs(route.query)
const listContainerRef = ref(null)
const list = ref([])
const loading = ref(false)
const isResettingPage = ref(false)
// 生成sessionStorage的key的函数,key是路由的path加上所有的query参数
const generateScrollKey = () => {
  return `scrollPosition_${route.path}_${JSON.stringify(route.query)}`
}
// 保存滚动位置的函数,用throttle包裹,防止频繁保存(比如每100毫秒保存一次)
const saveScrollPosition = throttle(() => {
  if (listContainerRef.value) {
    const scrollTop = listContainerRef.value.scrollTop
    sessionStorage.setItem(generateScrollKey(), scrollTop)
  }
}, 100)
const fetchList = async () => {
  loading.value = true
  try {
    const params = {
      page: parseInt(page.value) || 1,
      pageSize: parseInt(pageSize.value) || 10,
      keyword: keyword.value?.trim() || '',
      status: status.value || 'all'
    }
    const res = await getList(params)
    list.value = res.data.list
    // 等列表渲染完成之后,检查sessionStorage里有没有对应的滚动位置,如果有,就滚动到那个位置
    await nextTick()
    const scrollTop = sessionStorage.getItem(generateScrollKey())
    if (scrollTop && listContainerRef.value) {
      listContainerRef.value.scrollTop = parseInt(scrollTop)
    }
  } catch (err) {
    console.error('获取列表失败', err)
  } finally {
    loading.value = false
  }
}
const debouncedFetchList = debounce(fetchList, 300)
onUnmounted(() => {
  debouncedFetchList.cancel()
  saveScrollPosition.cancel()
})
// 给滚动容器添加scroll事件监听
watch(
  listContainerRef,
  (newContainer) => {
    if (newContainer) {
      newContainer.addEventListener('scroll', saveScrollPosition)
    }
  },
  { immediate: true }
)
// 第一个watch:监听page变化
watch(
  page,
  () => {
    if (!isResettingPage.value) {
      debouncedFetchList()
    }
  },
  { immediate: true }
)
// 第二个watch:监听pageSize、keyword、status变化
watch(
  [pageSize, keyword, status],
  (newVals, oldVals) => {
    const oldPageSize = oldVals?.[0]
    const oldKeyword = oldVals?.[1]
    const oldStatus = oldVals?.[2]
    if (oldPageSize === pageSize.value && oldKeyword === keyword.value && oldStatus === status.value) {
      return
    }
    isResettingPage.value = true
    router.replace({
      query: {
        ...route.query,
        page: 1
      }
    })
    setTimeout(() => {
      isResettingPage.value = false
      debouncedFetchList()
    }, 0)
  },
  { immediate: false }
)

这样修改之后,不管列表页是不是keep-alive缓存的,路由回退或者重新进入相同的搜索/筛选/分页状态时,滚动位置都会自动还原,体验感非常好。


Vue3 watch route query的正确打开方式

最后咱们总结一下Vue3 watch route query的正确打开方式,方便大家快速回顾:

  1. 别用生命周期组合拳和直接访问route.query,前者分散难维护,后者有浅层响应式的坑;
  2. 90%的场景用“万金油”写法:watch route.query + { deep: true, immediate: true },拿到query先转类型;
  3. 优化性能:用防抖解决频繁请求,用请求取消解决竞态问题,用toRef/toRefs精准监听指定属性;
  4. 处理进阶场景:用多个watch配合标志位处理部分变化,用scrollBehavior+sessionStorage处理路由回退时的滚动位置。

好了,今天关于Vue3 watch route query的内容就讲完了,从基础到进阶再到完整实战,所有坑都填实了,大家赶紧去自己的项目里试试吧!如果还有什么问题,欢迎在评论区留言哦!

版权声明

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

热门