首页 / React 学习笔记 / 33

REACT · Vol.V · LESSON 33 · 路由与数据获取

TanStack Query:服务端缓存管理

实战核心#数据#缓存

第 32 篇欠下的账,该还了

上一篇第 32 篇用 useEffect + fetch 手写取数,写到最后你也觉得别扭:一个「拿到列表、渲染出来」的需求,真正和业务相关的代码不到三分之一,其余全在伺候请求本身。把痛点列成清单:

  • 竞态:关键词快速变化,两个请求乱序返回,慢的反杀了快的;
  • 无缓存:列表页进详情页再返回,列表重新请求一遍,白屏重来;
  • 三态手写:每个组件都要 useState 一对 loading/error,复制粘贴;
  • 重复请求:三个组件同时挂载,同一个接口被打三次。

这些痛点的共同根因,是你在手搓一个缓存客户端。这事后端有历史版本:没有 Spring Cache 的年代,大家也自己 new 一个 ConcurrentHashMap 当缓存,过期、并发全要自己处理。后来 @Cacheable 一出,这些逻辑被抽进框架,业务代码里只剩一个注解。

前端同样的抽离发生在 TanStack Query(npm 包名 @tanstack/react-query)身上:它把自己定位成「服务端状态管理库」,接口数据的缓存、去重、失效、重取,全部收编。第 17 篇说过服务端状态不该塞进 useState,第 18 篇又叮嘱过别塞进 Zustand——一直欠着的那块拼图,就是它。

后端类比:手写缓存 → Spring Cache 抽象

第 32 篇的手写取数 = 手写 ConcurrentHashMap 缓存:能用,但过期、并发、重试全要自己管。TanStack Query 是前端的 Spring Cache,也是 MyBatis 二级缓存的前端版:你只声明「这个 key 的数据怎么取」(queryFn),缓存多久、何时失效、失败重试,框架按统一策略处理——可配置、全局一致。

装配:QueryClient 就是前端的缓存容器

用之前先装配。两步:创建一个 QueryClient 全局实例,再用 Provider 把它注入组件树——这套路数你在 Spring 里熟透了:

main.tsx · 创建并注入缓存客户端
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

// 1. 缓存客户端:整个应用就一份,所有缓存条目都存在它肚子里
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,  // 数据 30 秒内视为新鲜,不重复请求
      retry: 1,           // 失败重试 1 次(库默认 3 次)
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    // 2. 注入:组件树内任何 useQuery 都能拿到同一个实例
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
);
后端类比:容器与 Bean 装配

QueryClient 相当于亲手 new 出来的 CacheManager 单例;QueryClientProvider 把它放进容器供全局注入;defaultOptions 是 application.yml 里的全局缓存配置,单个 useQuery 里写的参数就是方法级覆盖——全局默认、局部覆盖、优先级就近,Spring 里天天用。

装配完,取数组件长这样:

TodoList.tsx · useQuery 声明一次,缓存全自动
import { useQuery } from "@tanstack/react-query";

interface Todo { id: number; title: string; done: boolean; }

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch("/api/todos");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function TodoList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["todos"],  // 这份数据的缓存 key
    queryFn: fetchTodos,  // 缓存未命中时怎么取
  });

  if (isPending) return <p>加载中…</p>;
  if (isError) return <p>加载失败:{error.message}</p>;

  return (
    <ul>
      {data.map((t) => <li key={t.id}>{t.title}</li>)}
    </ul>
  );
}

注意两件事。第一,fetchTodos 抛出的异常不会被吞掉——Query 接住它放进 error,isError 变 true,你不需要 try-catch。第二,v5 把「还没有任何数据」的标志位改名为 isPending(老教程的 isLoading 在 v5 是 isPending 与 isFetching 的组合),看文档先对版本。三态标志位全由库供给,第 32 篇那对 useState 可以删了。

组件卸载时,缓存并不跟着走。跳去详情页再点返回,TodoList 重新挂载,先立刻用缓存渲染,再按新鲜度决定要不要后台刷新——「返回不再白屏」是免费拿到的。

queryKey:缓存世界的唯一身份证

@Cacheable 你写过:@Cacheable(cacheNames = "user", key = "'user:' + #id")。Query 里的 queryKey 扮演同一个角色——key 唯一确定一份缓存。但它比字符串 key 强在「层级」:key 是数组,从粗到细排列,像文件夹路径:

keys.ts · 数组 key 的层级设计
useQuery({ queryKey: ["todos"], ... })      // 全量列表

useQuery({ queryKey: ["todo", id], ... })   // 详情:id 变 = 换一条缓存

useQuery({
  queryKey: ["todos", { page, keyword }],   // 分页、搜索参数也进 key
  queryFn: () => fetchTodos({ page, keyword }),
});

数组元素做深度比较决定是不是同一条缓存:id 从 1 变 2,key 不同,自动换缓存、重新请求——不用写任何「key 变了重新取」的代码。失效时还有福利:invalidateQueries前缀匹配,首项是 "todos" 的所有缓存(列表、各种分页)一次全失效,像按 cacheNames 整组清理。key 的设计原则和包结构一样:从粗到细、可序列化、参数全进 key。

一条缓存条目的完整一生,用一张图看懂:

无缓存 fresh 新鲜 stale 过期 gc 倒计时 未命中,发请求 staleTime 到期 再次使用:先给旧值,后台重取 所有订阅组件卸载 gcTime 开始倒计时 倒计时内重新挂载:直接复用 倒计时结束:条目回收,下次访问重新请求
图 1一条缓存条目的一生:fresh 期内直接复用;过期后下次使用先给旧值、后台重取;订阅者全卸载后 gcTime 倒计时回收。

控制这条生命线的旋钮一共五个,覆盖 90% 的场景:

配置默认值管什么后端对应物
staleTime0数据多久内算「新鲜」,新鲜期内直接用缓存不发请求缓存 TTL
gcTime5 分钟没有组件订阅后,缓存再留多久才回收空闲键驻留时间
refetchOnWindowFocustrue切回浏览器标签页时,自动重取已过期的数据主动刷新策略
retry3 次失败自动重试,指数退避Resilience4j / Feign 重试
enabledtruefalse 时不发起请求,等条件满足再发条件装配

一句话记住最易混的两个:staleTime 管「要不要重新请求」,gcTime 管「缓存还留不留」——前者是新鲜度,后者是内存回收。默认值整体偏激进(staleTime=0 意味着每次挂载都后台重取),后端出身的你大概想调大 staleTime 换稳定,defaultOptions 一行搞定。

写操作:useMutation 与失效重取

读用 useQuery,写用 useMutation。关键动作在 onSuccess 里:写库成功后,把相关查询缓存标记为失效——正在挂载的相关组件自动重取:

AddTodo.tsx · 写库成功后失效列表缓存
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addTodoApi } from "./api";

function AddTodo() {
  const [title, setTitle] = useState("");
  const queryClient = useQueryClient();  // 从「容器」拿到那个全局 client

  const addTodo = useMutation({
    mutationFn: (title: string) => addTodoApi(title),
    onSuccess: () => {
      // 等价于「更新 DB 后 @CacheEvict("todos")」:
      // 前缀匹配,所有以 "todos" 开头的缓存一起失效
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!title.trim()) return;
    addTodo.mutate(title);
    setTitle("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button disabled={addTodo.isPending}>
        {addTodo.isPending ? "提交中…" : "添加"}
      </button>
    </form>
  );
}

为什么失效、而不是手动把新数据塞进缓存?因为服务端才是数据的唯一真源。手动改缓存意味着前端要复刻一遍服务端逻辑(排序、分页总数、统计字段),复刻错了,界面就和数据库悄悄分叉;失效重取永远对齐服务端——与「@CachePut 容易脏、@CacheEvict 粗暴但不会错」是同一个权衡。

产品经理若要求「点添加立即看到新条目、不等重取」,那是乐观更新:onMutate 里先用 setQueryData 把假想结果写进缓存(UI 立即变化),失败在 onError 里用快照回滚,onSettled 里统一 invalidate 兜底。三段式套路固定——先改缓存、失败回滚、终态对账——记住思路即可,用到再查官方文档 Optimistic Updates 一节。

后端类比:失效优先,更新靠后

缓存领域的老原则「invalidate 优先于 update」:多端写同一份数据时,主动计算缓存新值容易算错;先失效、让下一次读来重建,永远收敛到正确值。invalidateQueries 就是这个原则的前端实现——提交成功不猜结果,把缓存作废,让真源重新说话。

销账:第 32 篇的痛点清单逐条对账

回到第 1 站那张痛点清单,逐条销账:

第 32 篇的痛点手写时代的方案TanStack Query 的答案
竞态:慢请求反杀ignore 标志 + 闭包判断queryKey 变化自动隔离,配合 signal 真取消请求(第 34 篇细讲)
无缓存:返回页白屏重来没人写,或手搓 MapqueryKey 命中缓存:先渲染旧数据,再按 staleTime 决定后台刷新
三态手写:每个组件一对 useState复制粘贴 loading / errorisPending / isError / isSuccess 标志位统一供给
重复请求:同接口打多次防抖、手写去重 Set相同 key 的并发请求自动合并成一次
失败重试:几乎没人写裸奔retry 默认 3 次指数退避,可全局配置
数据过期:完全没管裸奔窗口聚焦、断网重连时自动重取过期数据

清完账,第 17 篇悬着的问题也该收尾了:「服务端状态到底放哪」——答案是哪也不放,交给 Query。Zustand / Redux 从此只放真正的客户端状态:登录态、购物车、主题、抽屉开关。判断标准还是第 17 篇那句话:真源在服务端数据库吗?是,就别进 store、也别进 useState——用 useQuery 声明一份,缓存、失效、重试全部外包。

核心要点
  • TanStack Query 是服务端状态的缓存客户端:装配全局 QueryClient、Provider 注入,组件只声明 queryKey + queryFn。
  • queryKey 是数组层级的缓存 key(类比 @Cacheable key):key 变即换缓存自动重取;invalidateQueries 按前缀整组失效。
  • 五要素:staleTime 管新鲜度、gcTime 管回收、refetchOnWindowFocus 管聚焦刷新、retry 管重试、enabled 管条件发起。
  • 写操作 useMutation + onSuccess 里 invalidateQueries,等价于「更新 DB 后 @CacheEvict」;乐观更新 onMutate 先改缓存、失败回滚。
  • 服务端状态不再进 Redux / Zustand / useState——store 只留客户端状态,第 17 篇分类的最终落地。

本章回顾

第 32 篇手写取数暴露的所有痛点——竞态、缓存、三态、重复请求——在本篇被一个声明式缓存客户端批量销账:读用 useQuery,写用 useMutation + invalidateQueries,你从「伺候请求」回到「描述数据」。但标志位给了,不代表 UI 就讲究了:骨架屏还是转圈?错误怎么给重试出口?竞态怎么被 key 隔离?下一篇第 34 篇,把「请求的三种状态」在界面上建模清楚,你的前端代码会稳妥一个档次。

Comments · 评论