跳至正文
来两杯美式
返回

React 核心(17):TanStack Query 缓存机制详解

By 来两杯美式
发布于

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

Infinity vs '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 的条件

数据过期后,不会自动请求,需要触发条件:

触发条件配置项默认值含义
组件挂载refetchOnMounttrue组件挂载时,如果数据 stale 就 refetch
窗口聚焦refetchOnWindowFocustrue浏览器窗口重新获得焦点时 refetch(例如窗口最小化后再打开)
网络恢复refetchOnReconnecttrue网络断开又恢复时 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 的回收时间。行为和默认值不变。

关键行为

  1. 组件卸载时不会清除缓存,只是开始倒计时
  2. 倒计时期间有任何组件订阅,计时器重置
  3. 计时器归零且无进行中的请求后,缓存被清除,下次访问需要重新 fetch(显示 loading)
  4. 频繁切换页签时,缓存几乎不会被 GC(每次访问都重置计时器)

源码细节:gcTime 倒计时归零后调用 optionalRemove(),该方法要求同时满足两个条件才真正移除缓存:① observers.length === 0(无订阅者)② fetchStatus === 'idle'(无进行中的请求)。如果此时有后台 refetch 仍在进行,缓存不会被移除,等请求完成后重新调度 GC。

gcTime vs staleTime

          staleTime              gcTime
       ◄──────────►  ◄──────────────────────────►

fetch ──► [fresh] ──► [stale] ────────────────► [GC 清除]
              │           │                          │
              │           │ 满足条件时 refetch        │ 重新 fetch
              │           │ 先用缓存再更新            │ (显示 loading)
              └───────────┘                          │
              直接用缓存                              │

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 选项

refetchTypev4 引入的选项,用于替代 v3 中的 refetchActiverefetchInactive 两个独立布尔标志,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,有时候不显示?

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 等价于旧版 isInitialLoadingisPending && 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 的状态(isFetchingisPendingstatus 等)会同步更新。

Q: infinite query 的缓存机制一样吗?

一样。useInfiniteQuery 的 queryKey 包含了所有已加载的页面数据。staleTimegcTime 行为与普通 useQuery 相同。

Q: React 的两种缓存?

维度管理者官方术语你的大白话解释包含的具体内容
前端本地React 本身 / Zustand / ReduxClient State (客户端状态)“组件的状态”弹窗是开还是关、输入框里刚打的字、当前选中的标签页、网页皮肤颜色(深色/浅色)。
后端异步TanStack QueryServer State (服务器状态)“联网请求的交互数据”用户列表、审计日志、商品详情、个人设置等所有存在后端数据库里、需要发请求才能拿到的数据

分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 17 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
LLM 工作原理(一):从预测下一个字开始
下一篇
React 核心(16):TanStack Query 入门指南