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

2 阅读8分钟

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

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


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

某业务配置列表页,原本是直接调 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:永远新鲜。只要缓存里有,永远不再自动请求,除非你手动 refetchinvalidateQueries

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 四个场景的对比

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

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 默认是 0gcTime 默认 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,
})

七、总结

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

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