TanStack Query 缓存机制详解
基于 @tanstack/react-query v5,结合项目实际配置。
一、核心概念:数据的三个阶段
每一条 query 数据都经历三个阶段:
fetch → Fresh → Stale → GC 清除
↑ ↑
staleTime gcTime
| 阶段 | 含义 | 用户感知 |
|---|---|---|
| Fresh | 数据”新鲜”,无需更新 | 始终展示缓存数据,不触发任何请求 |
| Stale | 数据”过期”,可以更新 | 展示缓存数据,满足条件时后台 refetch |
| GC 清除 | 缓存被回收,数据消失 | 重新 fetch,出现 loading 状态 |
二、staleTime — 数据保鲜期
定义
数据被获取后,在 staleTime 毫秒内被视为”新鲜”。新鲜的数据不会触发任何 refetch。
默认值
staleTime: 0 (React Query 全局默认)
即:数据拿到就立刻过期。任何挂载/聚焦都会触发 refetch。
T3 Stack 的调整
T3 Stack 考虑到 SSR 场景,将全局默认改为 30 秒:
// src/trpc/query-client.ts
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30s 内数据算新鲜
},
},
});
原因:SSR 时服务端已经请求数据并注入 HTML,客户端 hydration 后如果 staleTime: 0 会立刻重复请求同一数据,造成浪费。30 秒的窗口让 hydration 后的首次渲染直接使用 SSR 数据。
staleTime 取值参考
| 场景 | 建议值 | 理由 |
|---|---|---|
| 实时性要求高的管理页 | 5-15s | 管理员需要近实时数据 |
| 普通列表页 | 30s | 数据不常变,减少请求 |
| 用户个人设置 | 60s | 只有本人会改 |
| 静态配置/字典数据 | 'static' | 几乎不变,连 invalidateQueries() 也无法使其过期 |
| 手动控制更新的字典数据 | Infinity | 不自动过期,但 invalidateQueries() 仍可标记 stale |
Infinityvs'static'(v5 新增):Infinity不会自动过期,但invalidateQueries()仍能让其变为 stale 并触发 refetch;'static'更强——连invalidateQueries()也无法使其过期。源码中isStaleByTime()对'static'直接返回false。字典/配置类数据如果不希望通过任何方式自动刷新,用'static';如果需要手动 invalidate 的能力,用Infinity。
staleTime 不等于请求间隔
常见误区:以为 staleTime 是”每隔 N 秒请求一次”。
实际上 React Query 没有轮询机制。staleTime 只是一个状态标签,决定数据”够不够新”。没有触发条件(挂载、聚焦等)就不会发请求。
如果需要定时轮询,用 refetchInterval:
useQuery({
queryKey: ["notifications"],
queryFn: fetchNotifications,
refetchInterval: 10 * 1000, // 每 10 秒轮询一次
});
三、Stale 触发 Refetch 的条件
数据过期后,不会自动请求,需要触发条件:
| 触发条件 | 配置项 | 默认值 | 含义 |
|---|---|---|---|
| 组件挂载 | refetchOnMount | true | 组件挂载时,如果数据 stale 就 refetch |
| 窗口聚焦 | refetchOnWindowFocus | true | 浏览器窗口重新获得焦点时 refetch(例如窗口最小化后再打开) |
| 网络恢复 | refetchOnReconnect | true | 网络断开又恢复时 refetch |
refetchOnMount 的三种模式
refetchOnMount: true; // stale 时 refetch,fresh 时不动(默认)
refetchOnMount: "always"; // 无论 fresh 还是 stale 都 refetch
refetchOnMount: false; // 永远不因挂载而 refetch
完整交互示例
以下时间线以审计日志页为例(staleTime: 15s,覆盖了全局 30s 默认值):
时间线 事件 缓存状态 行为
─────────────────────────────────────────────────────────────
0s 进入审计日志页,请求数据 fresh 展示新数据
5s 切到别的页签 fresh 组件卸载,缓存保留
15s 切回审计日志页 stale refetchOnMount 触发
先展示缓存,后台刷新
30s 切到别的页签 stale 组件卸载
45s 点击浏览器其他窗口再切回来 stale refetchOnWindowFocus 触发
后台刷新
2min 一直没有访问 stale 不触发任何请求(无组件订阅)
5min+ 一直没有访问 GC 清除 缓存被回收
说明: 2min后你刚切回来的那一瞬间(第 0 秒): 屏幕上完全不会白屏,也不会闪烁 Loading。你看到的是 2 分钟前的旧数据(因为缓存还在内存里)。 随后的 1-2 秒(后台请求中): 页面角落可能会有一个很不明显的小转圈(isFetching: true),但绝对不影响你浏览旧内容。 数据请求成功后: 屏幕上的数字或列表静默闪变成最新的。 这种“先看旧的,后台悄悄刷新的体验”,就是前端常说的 SWR (Stale-While-Revalidate) 策略。
6min后切回来(缓存超过5分钟,被 GC 回收了) 你刚切回来的那一瞬间(第 0 秒): 屏幕上是一片空白,或者直接弹出一个大大的 Loading 骨架屏/转圈圈(因为内存被清空,旧数据彻底没了)。 随后的 1-2 秒(首次加载中): 用户必须盯着 Loading 动画死等(isLoading: true)。 数据请求成功后: 页面从空白/Loading 状态变成完整展示新数据。
四、gcTime — 缓存回收时间
定义
组件卸载后,如果没有任何组件订阅该 query,经过 gcTime 毫秒后缓存被垃圾回收。
默认值
gcTime: 5 * 60 * 1000 (5 分钟)
v5 改名:v4 中此选项叫
cacheTime,v5 重命名为gcTime以消除误解——cacheTime听起来像”缓存保留多久”,但实际只影响无人订阅的 query 的回收时间。行为和默认值不变。
关键行为
- 组件卸载时不会清除缓存,只是开始倒计时
- 倒计时期间有任何组件订阅,计时器重置
- 计时器归零且无进行中的请求后,缓存被清除,下次访问需要重新 fetch(显示 loading)
- 频繁切换页签时,缓存几乎不会被 GC(每次访问都重置计时器)
源码细节:gcTime 倒计时归零后调用
optionalRemove(),该方法要求同时满足两个条件才真正移除缓存:①observers.length === 0(无订阅者)②fetchStatus === 'idle'(无进行中的请求)。如果此时有后台 refetch 仍在进行,缓存不会被移除,等请求完成后重新调度 GC。
gcTime vs staleTime
staleTime gcTime
◄──────────► ◄──────────────────────────►
fetch ──► [fresh] ──► [stale] ────────────────► [GC 清除]
│ │ │
│ │ 满足条件时 refetch │ 重新 fetch
│ │ 先用缓存再更新 │ (显示 loading)
└───────────┘ │
直接用缓存 │
staleTime影响是否 refetch(数据新鲜度)gcTime影响缓存是否保留(数据存在性)
gcTime 取值参考
| 场景 | 建议值 | 理由 |
|---|---|---|
| 普通页面 | 5min(默认) | 够用 |
| 频繁切换的页签 | 10-30min | 避免 loading 闪烁 |
| 内存敏感 / 数据量大 | 1-2min | 尽早释放内存 |
| 预加载(prefetch) | 30min+ | 预加载的数据要留久一点 |
五、Stale-While-Revalidate 策略
这是 React Query 的核心用户体验策略:
1. 优先展示缓存数据(无论是否 stale) → 用户立刻看到内容
2. 满足触发条件时,后台发请求获取新数据 → 用户无感知等待
3. 新数据到达后替换缓存 → 页面静默更新
触发条件:步骤 2 中的”后台发请求”不是自动的——数据变 stale 后,只有在组件挂载(
refetchOnMount)、窗口聚焦(refetchOnWindowFocus)、网络恢复(refetchOnReconnect)等条件满足时才会发起。没有触发条件,stale 数据不会自行刷新。
对比传统 loading 模式:
| 策略 | 体验 | 请求次数 |
|---|---|---|
| 传统:每次都 loading | 切页闪白屏,体验差 | 多 |
| SWR:先用缓存再更新 | 切页秒开,内容静默刷新 | 少 |
这也是为什么 stale 数据仍然有用 — 它确保用户永远不会面对空白 loading 状态。
六、项目中如何配置
全局配置
// src/trpc/query-client.ts
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 全局 30s
},
},
});
单个 Query 覆盖
// 审计日志页:15s 过期
api.auditLog.list.useInfiniteQuery(
{ limit: 20 },
{
staleTime: 15 * 1000, // 覆盖全局默认
}
);
SSR 配置
T3 Stack 的 createQueryClient 用于客户端,还需要处理 SSR 数据脱水/补水:
脱水:官方翻译“去水合”,数据序列化 + 服务器渲染 HTML
补水:官方翻译“水合”,数据反序列化 + 浏览器激活 React 事件
// 脱水
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
// 补水
hydrate: {
deserializeData: SuperJSON.deserialize,
},
shouldDehydrateQuery 中额外包含 pending 状态的 query,这样 SSR 时的 loading 状态也能传递到客户端,避免 hydration 闪烁。
注意:脱水中包含
pending状态不是普适的常规操作。传统 SSR(如 Next.js Pages Router)通常会 await 数据加载完毕变成success后再脱水。脱水pending状态主要是为了配合 RSC 的 Streaming SSR 或 Suspense,将服务端的 Loading 边界传递给客户端。如果你的项目没有开启 Streaming SSR,可以去掉query.state.status === "pending"这个条件。
SSR 的 retry 默认值变更
v5 中,SSR 场景下 retry 默认值从 3 改为 0。原因是 React 18 支持服务端 Suspense,query 可能在服务端直接执行,重试会导致请求阻塞。如果需要服务端重试,需显式设置 retry。
七、Mutation 对缓存的联动影响
前面讲的都是 Query(读)的缓存机制,但实际项目中缓存最容易出问题的地方是 Mutation(写)之后如何更新缓存。
Mutation 成功后,相关的 Query 缓存不会自动失效。如果不手动处理,用户在执行写操作后看到的仍是旧数据,直到 staleTime 过期后才刷新 — 这通常不是期望的行为。
标准范式:invalidateQueries
最常用的方式是在 onSuccess 中让相关 query 失效,触发 refetch:
const mutation = useMutation({
mutationFn: createApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["apiKey", "list"] });
},
});
invalidateQueries 的效果是将匹配的 query 标记为 stale。默认情况下,活跃的 query(有组件订阅的)会立即 refetch;非活跃的 query 仅标记 stale,等下次挂载时 refetch。
invalidateQueries 的 refetchType 选项
refetchType 是 v4 引入的选项,用于替代 v3 中的 refetchActive 和 refetchInactive 两个独立布尔标志,v5 沿用此 API。精细控制哪些 query 需要 refetch:
// 默认行为:仅活跃 query refetch
queryClient.invalidateQueries({
queryKey: ["apiKey", "list"],
refetchType: "active",
});
// 所有匹配 query 都 refetch(包括非活跃的)
queryClient.invalidateQueries({
queryKey: ["apiKey", "list"],
refetchType: "all",
});
// 仅标记 stale,不触发任何 refetch
queryClient.invalidateQueries({
queryKey: ["apiKey", "list"],
refetchType: "none",
});
// 仅非活跃 query refetch
queryClient.invalidateQueries({
queryKey: ["apiKey", "list"],
refetchType: "inactive",
});
refetchType: 'none' 在某些场景下很有用——比如只想标记 stale 但不想立即触发请求(等用户下次访问时再 refetch)。
进阶范式:乐观更新
当需要即时反馈(不等 refetch 完成)时,可以直接写入缓存:
const mutation = useMutation({
mutationFn: deleteApiKey,
onMutate: async variables => {
// 取消进行中的 query,避免覆盖我们的乐观更新
await queryClient.cancelQueries({ queryKey: ["apiKey", "list"] });
// 保存当前快照,用于回滚
const snapshot = queryClient.getQueryData(["apiKey", "list"]);
// 乐观更新:从列表中移除该项
queryClient.setQueryData(["apiKey", "list"], old => ({
...old,
items: old.items.filter(item => item.id !== variables.keyId),
}));
return { snapshot };
},
onError: (err, variables, context) => {
// 失败时回滚到快照
queryClient.setQueryData(["apiKey", "list"], context.snapshot);
},
onSettled: () => {
// 无论成功失败,都 refetch 确保数据一致
queryClient.invalidateQueries({ queryKey: ["apiKey", "list"] });
},
});
tRPC 项目中的注意点
本项目使用 tRPC,mutation 通过 api.xxx.useMutation() 调用。tRPC 的 queryKey 自动生成,格式为 [[router, procedure], input]。invalidate 时可以用精确的 key,也可以用通配:
// 精确失效
queryClient.invalidateQueries({ queryKey: [["apiKey", "list"]] });
// 通配:失效 apiKey 路由下所有 query
queryClient.invalidateQueries({ queryKey: [["apiKey"]] });
八、手动控制缓存
让数据立即过期
const queryClient = useQueryClient();
// 让特定 query 过期,下次访问时 refetch
queryClient.invalidateQueries({ queryKey: ["auditLog", "list"] });
// 让所有 query 过期
queryClient.invalidateQueries();
// 让匹配前缀的所有 query 过期
queryClient.invalidateQueries({ queryKey: ["auditLog"] });
写入缓存(乐观更新)
queryClient.setQueryData(["auditLog", "list"], old => ({
...old,
items: [newItem, ...old.items],
}));
预加载
queryClient.prefetchQuery({
queryKey: ["auditLog", "list"],
queryFn: fetchAuditLogs,
});
注意:
prefetchQuery返回Promise<void>,不会返回数据也不会抛错。如果需要获取数据,用fetchQuery。此外,prefetch 时传入的staleTime只影响本次预取判断,不影响后续useQuery的行为。
四种缓存控制方法对比
React Query 提供了多种缓存控制 API,行为差异显著:
| 方法 | 标记 stale | 触发 refetch | 删除缓存数据 | 通知订阅者 |
|---|---|---|---|---|
invalidateQueries | ✅ | 活跃 query(默认) | ❌ | ✅ |
removeQueries | ❌ | ❌ | ✅ 立即删除 | ❌ |
resetQueries | ❌ | 活跃 query | ❌(重置到初始状态) | ✅ |
clear | ❌ | ❌ | ✅ 清空所有缓存 | ❌(移除所有订阅) |
⚠️ removeQueries 对活跃 query 的影响:
removeQueries不区分活跃与非活跃 query。如果对正在被组件订阅的 query 调用removeQueries,缓存条目会被立即删除,observer 在下一个 render 周期从 cache 重新查找时发现 query 已不存在,会创建一个全新的 query(status: 'pending',无数据),导致组件回退到 loading 状态。因此,建议始终搭配type: 'inactive'过滤器使用,避免误删正在渲染的 query:// ✅ 安全:只删除没有组件订阅的 query queryClient.removeQueries({ queryKey: ["user"], type: "inactive" }); // ❌ 危险:可能删除活跃 query,导致当前页面闪烁 loading queryClient.removeQueries({ queryKey: ["user"] });
典型用法:
// 1. invalidateQueries:最常用,标记过期 + 按需 refetch
queryClient.invalidateQueries({ queryKey: ["auditLog"] });
// 2. removeQueries:彻底删除缓存,下次访问必须重新 fetch
// 适用于登出后清理用户数据(建议加 type: 'inactive')
queryClient.removeQueries({ queryKey: ["user"], type: "inactive" });
// 3. resetQueries:重置到初始状态(如有 initialData 则恢复)
// 适用于需要完全重新开始的场景
queryClient.resetQueries({ queryKey: ["auditLog"] });
// 4. clear:核弹级,清空所有 query + mutation 缓存
// 适用于用户登出
queryClient.clear();
九、常见问题
Q: 为什么切回来还是老数据?
检查 staleTime。如果还在 fresh 窗口内,不会 refetch。调小 staleTime 或设 refetchOnMount: "always"。
Q: 为什么有时候显示 loading,有时候不显示?
- 有缓存(fresh 或 stale)→ 不显示 loading,直接展示缓存
- 缓存被 GC 清除 → 显示 loading,重新 fetch
- 频繁切换的页面有缓存,长时间没访问的页面缓存已被 GC
Q: isLoading、isPending 和 isFetching 有什么区别?
v5 中这三个状态代表不同维度,对应不同的 UI 表现:
| 状态 | 含义 | 等价表达式 | UI 建议 |
|---|---|---|---|
isPending(status) | 缓存中没有数据 | status === 'pending' | — |
isLoading(status + fetchStatus) | 首次加载中,无缓存且正在请求 | isPending && isFetching | 骨架屏 / 全局 Spinner |
isFetching(fetchStatus) | 网络请求正在进行 | fetchStatus === 'fetching' | 角落小加载圈,不阻断操作 |
关键:控制全局 loading spinner 应该用 isLoading,而不是 isPending。
isPending 在 query 被 enabled: false 禁用、或网络暂停(isPaused)时也为 true,但这些场景不应显示 spinner。isLoading(= isPending && isFetching)更精确——它仅在”没有缓存数据 + 正在请求”时为 true。
v4 → v5 变更:v4 的
isLoading(首次加载)在 v5 中改名为isPending;v5 新增的isLoading等价于旧版isInitialLoading(isPending && isFetching)。
组合情况:
isPending=true + isFetching=true → 首次加载,无缓存,请求中 → 全局 loading
isPending=true + isFetching=false → query 禁用 / 请求失败 / 网络暂停 → 按场景处理
isPending=false + isFetching=true → 有缓存,后台刷新中 → 静默刷新提示
isPending=false + isFetching=false → 有缓存,无请求 → 正常展示
常见错误:用 isFetching 控制全局 loading → 用户每次切页都会看到闪烁的 loading(因为 stale 数据也会触发后台 refetch)。正确做法是用 isLoading 控制全局 loading,isFetching 仅用于非阻断式的刷新提示。
Q: staleTime 设为 0 会怎样?
数据拿到就过期,每次组件挂载都会 refetch。SSR 场景下会导致 hydration 后立即重复请求。
Q: 多个组件用同一个 queryKey 会怎样?
共享同一份缓存。第一个组件挂载时 fetch,后续组件直接用缓存。所有组件卸载后才开始 gcTime 倒计时。所有共享同一 queryKey 的 observer 的状态(isFetching、isPending、status 等)会同步更新。
Q: infinite query 的缓存机制一样吗?
一样。useInfiniteQuery 的 queryKey 包含了所有已加载的页面数据。staleTime 和 gcTime 行为与普通 useQuery 相同。
Q: React 的两种缓存?
| 维度 | 管理者 | 官方术语 | 你的大白话解释 | 包含的具体内容 |
|---|---|---|---|---|
| 前端本地 | React 本身 / Zustand / Redux | Client State (客户端状态) | “组件的状态” | 弹窗是开还是关、输入框里刚打的字、当前选中的标签页、网页皮肤颜色(深色/浅色)。 |
| 后端异步 | TanStack Query | Server State (服务器状态) | “联网请求的交互数据” | 用户列表、审计日志、商品详情、个人设置等所有存在后端数据库里、需要发请求才能拿到的数据。 |
- 相关:Suspense机制详解, T3-Stack开发指南, Auth-Guard与tRPC中间件, 学习路径总览