Vue Query从入门到实战踩坑总结

Tr° 闪闪 框架 阅读 1,519
赞 5 收藏
二维码
手机扫码查看
反馈

Vue Query配置的最佳姿势,折腾半天才搞明白

刚开始用Vue Query的时候,我是直接按照官方文档来配的,结果项目一上线就发现各种问题。最大的坑就是缓存策略没搞对,用户刷新页面后数据还是老的,体验差得要命。

Vue Query从入门到实战踩坑总结

我的写法,亲测靠谱:

import { QueryClient, QueryClientProvider } from '@tanstack/vue-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分钟变stale
      cacheTime: 10 * 60 * 1000, // 10分钟后清理缓存
      refetchOnWindowFocus: false, // 关闭窗口聚焦自动刷新
      retry: 1,
      retryDelay: 1000
    }
  }
})

// 在main.js里注入
app.use(queryClientProvider, { client: queryClient })

为什么要这么配置?staleTime设置成5分钟,意思是数据超过5分钟就认为过期,这时候如果有组件需要这个数据,就会触发重新请求。cacheTime设置比staleTime长一点,避免刚过期就被清除。

refetchOnWindowFocus这个参数特别坑,移动端切换到后台再回来,所有查询都会重新发起,网络请求爆增,我之前就这样被产品骂过。

这种写法更靠谱:自定义Query Key管理

刚开始我把query key直接写在组件里,比如['user', id]这样,结果项目一大,key到处都是,维护起来要疯。

我现在的做法是专门建一个文件管理所有的key:

// composables/queryKeys.js
export const queryKeys = {
  user: (id) => ['user', id],
  posts: (userId, page = 1) => ['posts', userId, page],
  comments: (postId) => ['comments', postId],
  notifications: ['notifications'],
  profile: () => ['profile']
}
// 在组件中使用
import { useQuery } from '@tanstack/vue-query'
import { queryKeys } from '@/composables/queryKeys'

const { data, isLoading } = useQuery({
  queryKey: queryKeys.user(userId),
  queryFn: () => fetchUser(userId)
})

这样做的好处显而易见:集中管理,不容易写错,重构也方便。而且IDE还能帮你做自动补全,爽得很。

这几种错误写法,别再踩坑了

最常见的错误就是把复杂的对象直接作为query key的一部分:

// 错误示范 - 千万别这么写
const { data } = useQuery({
  queryKey: ['search', searchParams], // searchParams是个复杂对象
  queryFn: () => search(searchParams)
})

这样写的问题是,每次render时searchParams引用都变了,即使内容没变也会被认为是不同的key,导致重复请求。正确做法应该是只取关键字段或者转成JSON字符串:

// 正确写法
const { data } = useQuery({
  queryKey: ['search', JSON.stringify(searchParams)],
  queryFn: () => search(searchParams)
})

// 或者只取关键字段
const { data } = useQuery({
  queryKey: ['search', searchParams.keyword, searchParams.category],
  queryFn: () => search(searchParams)
})

另一个常见错误是在queryFn里直接引用组件内的响应式数据:

// 错误示范
export default {
  setup() {
    const filters = ref({ status: 'active', type: 'article' })
    
    const { data } = useQuery({
      queryKey: ['list', filters.value], // 这里有问题
      queryFn: async () => {
        return await api.getList(filters.value) // filters可能变化
      }
    })
  }
}

这样写可能导致queryFn里的filters不是最新的值。正确的做法是把依赖项明确写在queryKey里:

// 正确写法
export default {
  setup() {
    const filters = ref({ status: 'active', type: 'article' })
    
    const { data } = useQuery({
      queryKey: ['list', filters.value.status, filters.value.type],
      queryFn: async () => {
        return await api.getList(filters.value)
      }
    })
  }
}

实际项目中的坑:错误处理和loading状态

Vue Query的错误处理机制挺好的,但实际用的时候需要注意一些细节。特别是对于那种”用户不存在”的场景,不应该当作错误处理:

const { data, error, isLoading } = useQuery({
  queryKey: ['user', id],
  queryFn: async ({ signal }) => {
    try {
      const response = await fetch(https://jztheme.com/api/user/${id}, { signal })
      if (!response.ok) {
        if (response.status === 404) {
          return null // 404不算是错误,返回null
        }
        throw new Error(HTTP ${response.status})
      }
      return response.json()
    } catch (error) {
      if (error.name !== 'AbortError') {
        throw error
      }
    }
  },
  // 只有真正的错误才retry
  retry: (failureCount, error) => {
    return error.message.includes('HTTP') && !error.message.includes('404')
  }
})

loading状态的处理也有讲究。对于分页列表,我不希望每次翻页都显示整个页面的loading,只希望当前区域loading:

<template>
  <div>
    <!-- 首次加载才显示整体loading -->
    <div v-if="isLoading && !data">
      <LoadingSpinner />
    </div>
    
    <!-- 数据存在时,翻页只显示局部loading -->
    <div v-else>
      <PostList :posts="data?.items || []" />
      <button 
        @click="fetchNextPage"
        :disabled="isFetchingNextPage"
      >
        {{ isFetchingNextPage ? '加载中...' : '加载更多' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'

const { 
  data, 
  isLoading, 
  isFetchingNextPage, 
  fetchNextPage,
  hasNextPage 
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextCursor
})
</script>

这里的关键是区分isLoading和isFetchingNextPage。isLoading只在首次获取数据时为true,而isFetchingNextPage在每次fetchMore时都为true,这样就能实现局部loading的效果。

缓存预填充和手动更新,提升用户体验

对于那些频繁更新的数据,我会结合WebSocket或者Server-Sent Events来手动更新缓存,避免频繁的轮询请求:

// 在WebSocket消息处理的地方
const { queryClient } = useQueryClient()

// 当收到新消息时更新缓存
socket.on('newMessage', (message) => {
  queryClient.setQueryData(['messages'], (old) => {
    if (!old) return [message]
    return [message, ...old]
  })
  
  // 同时更新对应用户的未读消息数
  queryClient.setQueryData(['unread-count', message.userId], (count) => count + 1)
})

// 或者直接无效化相关查询,让它下次自动重新获取
socket.on('postUpdated', (postId) => {
  queryClient.invalidateQueries({ queryKey: ['post', postId] })
  queryClient.invalidateQueries({ queryKey: ['feed'] })
})

这种做法能让UI瞬间响应,用户体验比等API返回要好太多。但要注意更新时机,避免更新过快导致界面闪烁。

内存泄漏和垃圾回收,不能忽视的问题

大型应用中,如果查询很多,缓存会占用大量内存。我一般会给全局配置加一些限制:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 5 * 60 * 1000, // 5分钟后自动清理
    }
  },
  // 控制最大缓存数量
  queryCache: new QueryCache({
    onFocus: () => console.log('focus'),
    onOnline: () => console.log('online')
  })
})

// 页面销毁时手动清理不需要的缓存
onUnmounted(() => {
  queryClient.removeQueries({ 
    queryKey: ['temporary-data'], 
    exact: true 
  })
})

另外,在路由切换时,有些页面级别的查询可以立即清理:

// 路由守卫中清理缓存
router.beforeEach((to, from) => {
  // 清理上个页面的临时查询
  queryClient.removeQueries({ 
    queryKey: ['page-data', from.params.id] 
  })
})

以上是我使用Vue Query这么久总结的最佳实践,包括配置、错误处理、缓存管理等几个方面。有些方案不是最优的,但确实解决了我遇到的实际问题。有更好的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论