Vue Query 缓存机制实战:别再让 gcTime 和 staleTime 背锅了

Vue Query 缓存机制实战:别再让 gcTime 和 staleTime 背锅了

背景:最近在一个 Vue3 + Element Plus 的中后台项目里做表格 loading 优化时,踩了 @tanstack/vue-query 缓存的几个坑。发现团队里很多人(包括我自己)对 gcTimestaleTimeisLoadingisFetching 的理解都是模糊的------知道有缓存,但说不清缓存到底在什么时候起作用。这篇把踩过的坑和验证过程整理出来,供后续参考。


一、先从一个踩坑现场说起

某业务配置列表页,原本是直接调 API:

ts 复制代码
// 原始写法
onMounted(() => {
  api.getConfigList(params).then(res => {
    tableData.value = res.data.list
  })
})

后来改成 useQuery,想顺便把 loading 状态交给 Vue Query 管理:

ts 复制代码
const { data, isLoading, refetch } = useConfigList(params)

模板里绑了 v-loading="isLoading",然后发现一个问题------

反复进出页面时,isLoading 永远是 false,表格 loading 根本不出现。

第一反应是:是不是解构 const { isLoading } = useQuery(...) 丢了响应性?

翻源码确认:useBaseQuery 返回的是 toRefs(readonlyState),解构出来的每个属性都是正经的 Ref,响应性没问题。

真正的原因是:Vue Query 的缓存命中了。


二、gcTime 与 staleTime:这是两个维度的配置

很多人(包括我)一开始会把这两个概念混为一谈,以为是"缓存有效期"的两个名字。实际上它们控制的完全是两个层面:

配置 控制什么 默认值 一句话解释
staleTime 数据新鲜度 0 数据多久后算"过期",过期后组件挂载会后台刷新
gcTime 缓存存活期 5 * 60 * 1000 (5分钟) 组件卸载后,缓存数据在内存里保留多久

2.1 staleTime:决定"要不要发请求"

staleTime 解决的是:组件重新挂载时,已经有缓存了,还要不要再发一次请求?

  • staleTime: 0 (默认):数据一拿到就是"过期的"。下次组件挂载,会先展示缓存旧数据,同时后台发请求更新。用户看不到白屏,但网络面板里能看到请求。
  • staleTime: 60_000 :1 分钟内数据算"新鲜"。这 1 分钟内重新挂载组件,直接读缓存,不发请求
  • staleTime: Infinity :永远新鲜。只要缓存里有,永远不再自动请求,除非你手动 refetchinvalidateQueries

2.2 gcTime:决定"缓存留多久"

gcTime 解决的是:最后一个组件卸载后,这条缓存还要在内存里赖多久?

  • gcTime: 5_分钟(默认):离开页面后,这条数据还会在内存里躺 5 分钟。5 分钟内回来,数据还在;超过 5 分钟才被垃圾回收。
  • gcTime: 0 :组件一卸载,缓存立刻删除。再进来就跟第一次一样,从头请求。

2.3 关键区别

staleTime 影响的是还在用的时候 (组件挂载了,要不要刷新); gcTime 影响的是不用之后(组件卸载了,要不要留着)。

两者互不影响,可以任意组合:

ts 复制代码
// 组合1:数据新鲜1分钟,卸载后保留5分钟
{ staleTime: 60_000, gcTime: 300_000 }

// 组合2:永远新鲜,但卸载后只留10秒(适合不重要的辅助数据)
{ staleTime: Infinity, gcTime: 10_000 }

// 组合3:一过期就刷新,卸载后立刻清理(适合敏感数据)
{ staleTime: 0, gcTime: 0 }

三、isLoading vs isFetching:两个 loading,两条命

这是另一个高频混淆点。Vue Query v5 里:

属性 含义 什么时候为 true
isLoading 首次加载中,且没有缓存数据 这个 queryKey 从来没成功获取过数据
isFetching 请求正在进行中 只要有网络请求在跑,不管有没有缓存

3.1 四个场景的对比

场景 isLoading isFetching 用户看到什么
第一次进入页面(缓存为空) true true 全屏 loading
离开再回来,后台刷新(有缓存) false true 旧数据还在,表格右上角可能有个小 spin
分页切换(新 queryKey true true 全屏 loading
空闲状态 false false 稳定展示

3.2 该用哪个绑 v-loading?

看你要什么体验:

vue 复制代码
<!-- 方案A:isLoading -->
<!-- 有缓存时不再出全屏 loading,体验更流畅,但用户不知道你在后台刷新 -->
<el-table v-loading="isLoading" :data="tableData" />

<!-- 方案B:isFetching -->
<!-- 只要有请求就出 loading,用户感知明确,但可能频繁闪 loading -->
<el-table v-loading="isFetching" :data="tableData" />

我们项目里的核心单据列表用的是 isFetching,因为数据变动频繁,每次刷新都要让用户明确感知。而一些配置类页面(比如字典管理)用 isLoading 更合适------数据不常变,减少 loading 闪烁。

3.3 一个冷知识

Vue Query v4 里 isLoading 的定义是 isFetching && !data,v5 改成了 status === 'pending'。这个变动导致很多从 v4 迁移过来的项目踩坑------以为 isLoading 会在每次 refetch 时变为 true,实际上不会了。


四、"既然每次都请求,缓存还有什么意义?"

这是我最开始也困惑的问题。staleTime 默认是 0gcTime 默认 5 分钟,看起来每次进来都会重新请求,那缓存不是白存了吗?

验证之后发现,缓存的价值根本不在"省请求",而在用户体验

4.1 后台刷新不闪白

因为有缓存,重新进入页面时:

复制代码
有缓存 → 先展示旧数据 → 后台发请求 → 拿到新数据后静默更新

用户看到的是旧表格 → 新表格 ,而不是空白 loading → 新表格。这个体验差异非常大。

如果没有缓存,每次进来都是白屏等 loading,特别是列表页有 50 行数据时,用户会觉得"怎么每次都要等"。

4.2 多组件共享同一份数据

假设页面 A 和侧边栏弹窗 B 都监听了同一个 queryKey

ts 复制代码
// 页面A
const { data: listA } = useQuery({ queryKey: ['config.list'] })

// 弹窗B
const { data: listB } = useQuery({ queryKey: ['config.list'] })

两个组件共享同一个 QueryObserver 实例,只发一次请求,数据同步更新。如果没有缓存,各请求各的,重复发两次。

4.3 分页/筛选切换时旧数据占位

核心单据列表里用了 placeholderData: keepPreviousData

ts 复制代码
useQuery({
  queryKey: ['order.page', params],
  placeholderData: keepPreviousData, // 切分页时,先用旧数据撑着
})

切分页时,新数据还没回来,表格先用上一页的数据占位,不会变成空白。没有缓存就做不到这点。

4.4 网络抖动时兜底

如果网络抖动或接口报错,Vue Query 可以返回缓存中的旧数据 + 标记错误状态,而不是直接白屏或抛异常给用户。


五、内存焦虑:gcTime 5 分钟会不会把项目拖垮?

不会。Vue Query 的缓存只存响应数据(纯 JSON)查询状态,不存 DOM、不存组件实例,内存占用极小。

但需要警惕一个边界情况:queryKey 无限增长

ts 复制代码
// 反例:queryKey 无限增长,每次渲染都是新 key
useQuery({
  queryKey: ['search', Date.now()], // ❌ 灾难
})

这样每次都会新增一条缓存,5 分钟后才回收,长期运行内存会持续上涨。

但正常业务代码里,queryKey 是固定前缀 + 参数对象:

ts 复制代码
queryKey: ['config.list', { pageNo: 1, pageSize: 50 }]

分页来回切只会产生有限个 key(比如 10 页就 10 条缓存),5 分钟后自动清理,对内存的影响完全可以忽略。

如果你确实担心,可以在 queryClient 全局缩短:

ts 复制代码
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 60_000, // 1分钟
      staleTime: 0,
    },
  },
})

六、项目实战建议

结合中后台项目的实际情况,我的建议配置:

6.1 核心单据列表页

数据变动频繁,用户需要明确感知刷新:

ts 复制代码
useQuery({
  queryKey: ['order.page', params],
  staleTime: 0,        // 默认,一过期就刷新
  gcTime: 300_000,     // 默认5分钟,保留缓存用于后台刷新
})

模板绑 v-loading="isFetching",每次刷新、分页切换都有 loading。

6.2 配置类/字典类页面

数据不常变,但参数固定,不想保留缓存:

ts 复制代码
useQuery({
  queryKey: ['config.list', params],
  staleTime: 0,
  gcTime: 0,           // 离开页面立刻清缓存
})

模板绑 v-loading="isLoading",减少不必要的 loading 闪烁。

6.3 下拉选项等辅助数据

数据极少变动,可以长时间不刷新:

ts 复制代码
useQuery({
  queryKey: ['warehouse.select'],
  staleTime: 10 * 60_000, // 10分钟内不重复请求
  gcTime: 60_000,
})

七、总结

  1. staleTime 决定组件挂载时要不要发请求gcTime 决定组件卸载后缓存留多久。两者独立,别混为一谈。
  2. isLoading 只反映"首次无缓存的加载";isFetching 反映"任何进行中的请求"。根据业务场景选,不要无脑用 isLoading
  3. 缓存的意义不是省请求,而是让后台刷新对用户无感知。 即使 staleTime: 0,缓存依然是流畅体验的核心。
  4. 内存不用担心 ,除非你的 queryKey 设计成了无限增长模式。
  5. 没有万能配置 ,列表页、配置页、下拉框的数据特性不同,该用 gcTime: 0 的地方不要犹豫。

最后附一句:如果某个列表你确定"完全不需要缓存",不如直接回到最原始的 API 调用。useQuery 是有心智成本的,不要为了用而用。

相关推荐
Rkgua1 小时前
React中的赋值操作为什么不是=?
前端·javascript
heyCHEEMS1 小时前
记录一个 React 表单的小坑:缓存节流导致页面刷新
前端·javascript
@不误正业1 小时前
多Agent协作框架深度实战-从ReAct到Plan-and-Execute全架构演进
前端·react.js·架构·agent
唐青枫1 小时前
别再手写重复 CSS 了:SCSS 从入门到实战
前端·css·scss
huohaiyu1 小时前
HTML和CSS基础使用
前端·css·html
xiangxiongfly9152 小时前
uni-app 组件总结
前端·javascript·uni-app
SwJieJie2 小时前
Day1 从 0 搭建 VueDemo Web Admin 项目环境:技术栈、插件链与自动化脚本全解析
前端·vue.js·学习
wordbaby2 小时前
React 自定义 Hook 实践:如何优雅管理复杂列表的筛选状态?
前端