Vue Query 缓存机制实战:别再让 gcTime 和 staleTime 背锅了
背景:最近在一个 Vue3 + Element Plus 的中后台项目里做表格 loading 优化时,踩了
@tanstack/vue-query缓存的几个坑。发现团队里很多人(包括我自己)对gcTime、staleTime、isLoading、isFetching的理解都是模糊的——知道有缓存,但说不清缓存到底在什么时候起作用。这篇把踩过的坑和验证过程整理出来,供后续参考。
一、先从一个踩坑现场说起
某业务配置列表页,原本是直接调 API:
// 原始写法
onMounted(() => {
api.getConfigList(params).then(res => {
tableData.value = res.data.list
})
})
后来改成 useQuery,想顺便把 loading 状态交给 Vue Query 管理:
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:永远新鲜。只要缓存里有,永远不再自动请求,除非你手动refetch或invalidateQueries。
2.2 gcTime:决定"缓存留多久"
gcTime 解决的是:最后一个组件卸载后,这条缓存还要在内存里赖多久?
gcTime: 5_分钟(默认):离开页面后,这条数据还会在内存里躺 5 分钟。5 分钟内回来,数据还在;超过 5 分钟才被垃圾回收。gcTime: 0:组件一卸载,缓存立刻删除。再进来就跟第一次一样,从头请求。
2.3 关键区别
staleTime 影响的是还在用的时候(组件挂载了,要不要刷新);
gcTime 影响的是不用之后(组件卸载了,要不要留着)。
两者互不影响,可以任意组合:
// 组合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?
看你要什么体验:
<!-- 方案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 默认是 0,gcTime 默认 5 分钟,看起来每次进来都会重新请求,那缓存不是白存了吗?
验证之后发现,缓存的价值根本不在"省请求",而在用户体验:
4.1 后台刷新不闪白
因为有缓存,重新进入页面时:
有缓存 → 先展示旧数据 → 后台发请求 → 拿到新数据后静默更新
用户看到的是旧表格 → 新表格,而不是空白 loading → 新表格。这个体验差异非常大。
如果没有缓存,每次进来都是白屏等 loading,特别是列表页有 50 行数据时,用户会觉得"怎么每次都要等"。
4.2 多组件共享同一份数据
假设页面 A 和侧边栏弹窗 B 都监听了同一个 queryKey:
// 页面A
const { data: listA } = useQuery({ queryKey: ['config.list'] })
// 弹窗B
const { data: listB } = useQuery({ queryKey: ['config.list'] })
两个组件共享同一个 QueryObserver 实例,只发一次请求,数据同步更新。如果没有缓存,各请求各的,重复发两次。
4.3 分页/筛选切换时旧数据占位
核心单据列表里用了 placeholderData: keepPreviousData:
useQuery({
queryKey: ['order.page', params],
placeholderData: keepPreviousData, // 切分页时,先用旧数据撑着
})
切分页时,新数据还没回来,表格先用上一页的数据占位,不会变成空白。没有缓存就做不到这点。
4.4 网络抖动时兜底
如果网络抖动或接口报错,Vue Query 可以返回缓存中的旧数据 + 标记错误状态,而不是直接白屏或抛异常给用户。
五、内存焦虑:gcTime 5 分钟会不会把项目拖垮?
不会。Vue Query 的缓存只存响应数据(纯 JSON)和查询状态,不存 DOM、不存组件实例,内存占用极小。
但需要警惕一个边界情况:queryKey 无限增长。
// 反例:queryKey 无限增长,每次渲染都是新 key
useQuery({
queryKey: ['search', Date.now()], // ❌ 灾难
})
这样每次都会新增一条缓存,5 分钟后才回收,长期运行内存会持续上涨。
但正常业务代码里,queryKey 是固定前缀 + 参数对象:
queryKey: ['config.list', { pageNo: 1, pageSize: 50 }]
分页来回切只会产生有限个 key(比如 10 页就 10 条缓存),5 分钟后自动清理,对内存的影响完全可以忽略。
如果你确实担心,可以在 queryClient 全局缩短:
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 60_000, // 1分钟
staleTime: 0,
},
},
})
六、项目实战建议
结合中后台项目的实际情况,我的建议配置:
6.1 核心单据列表页
数据变动频繁,用户需要明确感知刷新:
useQuery({
queryKey: ['order.page', params],
staleTime: 0, // 默认,一过期就刷新
gcTime: 300_000, // 默认5分钟,保留缓存用于后台刷新
})
模板绑 v-loading="isFetching",每次刷新、分页切换都有 loading。
6.2 配置类/字典类页面
数据不常变,但参数固定,不想保留缓存:
useQuery({
queryKey: ['config.list', params],
staleTime: 0,
gcTime: 0, // 离开页面立刻清缓存
})
模板绑 v-loading="isLoading",减少不必要的 loading 闪烁。
6.3 下拉选项等辅助数据
数据极少变动,可以长时间不刷新:
useQuery({
queryKey: ['warehouse.select'],
staleTime: 10 * 60_000, // 10分钟内不重复请求
gcTime: 60_000,
})
七、总结
staleTime决定组件挂载时要不要发请求;gcTime决定组件卸载后缓存留多久。两者独立,别混为一谈。isLoading只反映"首次无缓存的加载";isFetching反映"任何进行中的请求"。根据业务场景选,不要无脑用isLoading。- 缓存的意义不是省请求,而是让后台刷新对用户无感知。 即使
staleTime: 0,缓存依然是流畅体验的核心。 - 内存不用担心,除非你的
queryKey设计成了无限增长模式。 - 没有万能配置,列表页、配置页、下拉框的数据特性不同,该用
gcTime: 0的地方不要犹豫。
最后附一句:如果某个列表你确定"完全不需要缓存",不如直接回到最原始的 API 调用。useQuery 是有心智成本的,不要为了用而用。