知

模块二 · Hooks 与数据

第 8 / 18 节19 分钟阅读型

请求与缓存:把服务端状态交给 TanStack Query

服务端状态和客户端状态不是一类东西。这一节讲清 queryKey、staleTime 与失效机制,用乐观更新和分页把列表页面写对,并说明什么时候不该引入请求库。

读完这一节,你能做到

  1. 区分服务端状态与客户端状态,不再把它们塞进同一个 store。
  2. 用 queryKey 与 queryFn 描述一份数据,理解缓存命中的条件。
  3. 用失效与乐观更新处理增删改后的数据一致性。
  4. 判断请求库与手写 useEffect 的边界。

一、两类状态,两种规则

维度客户端状态服务端状态
例子弹窗开关、表单输入、选中项订单列表、用户信息、配置
真相来源前端后端
是否可能过期不会随时可能被别人改掉
需要的能力同步、持久化缓存、失效、重试、去重
适合放哪useState / store请求缓存库

把服务端数据抄进 Redux,会立刻产生两个问题:两份真相与手工同步。请求库的定位就是"服务端状态的管理层",它不替代全局状态,而是补上后者做不了的部分。

二、最小用法:queryKey 就是缓存地址

import { useQuery } from "@tanstack/react-query";

function useOrders(params: { status: string; page: number }) {
  return useQuery({
    queryKey: ["orders", params],
    queryFn: ({ signal }) =>
      fetch(`/api/orders?status=${params.status}&page=${params.page}`, {
        signal,
      }).then((r) => r.json()),
    staleTime: 30_000,
  });
}

四个概念:

概念含义常见误解
queryKey缓存键,数组序列化后作为地址以为只是给 devtools 看的标签
queryFn真正取数的函数,接收 signal忘了透传 signal,切换时无法取消
staleTime多久内认为数据"新鲜",不重新请求与 gcTime 混为一谈
gcTime没有组件使用后,缓存保留多久以为调大就能"永久缓存"

queryKey 的设计原则:把影响结果的参数全部放进去,顺序稳定。

["orders", { status, page, keyword }][("order", id)][
  ("user", userId, "profile")
];

不要把整个 params 对象(含函数、Date 实例)直接塞进去,序列化不稳定会导致缓存永远不命中。

三、四个状态,别再用 loading 布尔

const { data, isPending, isFetching, isError, error, refetch } =
  useOrders(params);
状态含义UI 表现
isPending首次请求,还没有任何数据骨架屏
isFetching正在请求(可能已有旧数据)右上角小 spinner
isError请求失败错误提示 + 重试
isSuccess + 空数组成功但无数据空状态

关键区别:isPending 和 isFetching 不是一回事。翻页时 isPending 为假、isFetching 为真,此时应保留旧列表并显示加载指示,而不是整页变骨架屏。这正是手写 useEffect 最容易做错的地方。

四、失效:增删改之后的正确动作

const qc = useQueryClient();

const mutation = useMutation({
  mutationFn: (id: string) => fetch(`/api/orders/${id}`, { method: "DELETE" }),
  onSuccess: () => {
    qc.invalidateQueries({ queryKey: ["orders"] }); // 让列表重新取
  },
});

invalidateQueries 会标记匹配的缓存为过期,并立即重新请求正在使用的查询。相比手动 refetch,它不需要知道当前有哪些页面在用这份数据。

两种更新策略:

策略做法适用
失效重取invalidateQueries数据由服务端计算,前端算不准
直接改缓存setQueryData结果可预测,想省一次请求

五、乐观更新:先改界面,失败回滚

const qc = useQueryClient();

const updateStatus = useMutation({
  mutationFn: (input: { id: string; status: string }) =>
    fetch(`/api/orders/${input.id}`, {
      method: "PATCH",
      body: JSON.stringify({ status: input.status }),
    }).then((r) => r.json()),

  onMutate: async (input) => {
    await qc.cancelQueries({ queryKey: ["orders"] }); // 取消进行中的请求,避免覆盖
    const prev = qc.getQueryData(["orders"]); // 快照
    qc.setQueryData(["orders"], (old: any) =>
      old?.map((o: any) =>
        o.id === input.id ? { ...o, status: input.status } : o,
      ),
    );
    return { prev }; // 传给 onError
  },
  onError: (_e, _input, ctx) => {
    qc.setQueryData(["orders"], ctx?.prev); // 回滚
  },
  onSettled: () => {
    qc.invalidateQueries({ queryKey: ["orders"] }); // 最终以服务端为准
  },
});

三个步骤缺一不可:

  1. cancelQueries:否则旧请求返回后会把乐观更新覆盖掉。
  2. 快照回滚:失败时界面必须恢复,否则用户以为成功了。
  3. onSettled 失效:乐观更新只是猜测,最终要与服务端对齐。

乐观更新只适合"成功率高、失败代价小"的操作(点赞、勾选、拖拽排序)。支付、删除账户这类操作不要用。

六、分页与无限滚动

// 分页:保留上一页数据,避免闪白
useQuery({
  queryKey: ["orders", page],
  queryFn: () => fetchOrders(page),
  placeholderData: (prev) => prev,
});

// 无限滚动
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam = 0 }) => fetchFeed(pageParam),
    initialPageParam: 0,
    getNextPageParam: (last) => last.nextCursor ?? undefined,
  });

分页设计的三个要点:

  • 用 placeholderData 保留旧页,翻页时不会整块消失。
  • 用游标而不是页码做无限滚动,避免数据插入导致重复。
  • 列表项 key 必须稳定,否则滚动位置与展开状态会错乱。

七、什么时候不用请求库

请求库不是默认答案。以下情况手写更合适:

场景原因
全站只有两三个一次性请求引入库的配置成本高于收益
请求结果不共享、不需要缓存每次都要最新数据
项目已有成熟的数据层(如 GraphQL 客户端)避免两套缓存
极小的脚本或静态页增加包体不划算

判断标准:是否出现"同一份数据多处使用 + 需要失效与重试 + 需要加载态"这三件事中的两件。出现两件,就该上库。

八、常见坑

  1. queryKey 里漏了参数,切换筛选后拿到的还是旧缓存。
  2. 把 staleTime 当 gcTime 用,以为数据永不过期。
  3. 用 isPending 判断"正在加载",翻页时整页变骨架屏。
  4. 乐观更新忘了 cancelQueries,结果被旧请求覆盖。
  5. 失败后不回滚,界面显示成功但服务端没改。
  6. 每次渲染创建新的 queryFn 对象,导致缓存键不稳定。
  7. 把请求库缓存的数据再抄一份进 Redux,制造第二份真相。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 useQuery 重写一个 useEffect + fetch 的列表页,对比代码量与状态分支数量。
  2. 给列表加"标记完成"操作,用乐观更新实现,并故意让接口返回 500 验证回滚。
  3. 给列表加分页,要求翻页时保留旧数据并显示小 spinner。
  4. 设计一组 queryKey,覆盖列表、详情、用户信息三类数据,说明为什么这样拆。

② 让 AI 写同一题

把第 2 题(乐观更新)交给 AI,补一句 成功与失败都要有明确回滚路径。

③ 对比要盯的点

  • 它在 onMutate 里 cancelQueries 并拍快照了吗?没有快照,失败就没得回滚。
  • onError 它是恢复快照,还是简单 invalidateQueries?后者会让列表先闪一下旧数据。
  • 翻页加载它用 isPending 还是 isFetching?用错会出现“翻页时整页变白”。

④ 一句话取舍

如果标记完成的接口要 2 秒,它那版会闪什么?你会把哪一步改成“先本地改、失败再回退”?

十、自测

  1. 服务端状态与客户端状态的核心区别是什么?
  2. staleTime 与 gcTime 分别控制什么?
  3. 为什么翻页时不该用 isPending 判断加载?
  4. 乐观更新为什么必须 cancelQueries?
  5. 什么情况下不该引入请求库?

参考答案要点:真相来源与是否过期;新鲜期与回收期;已有旧数据,应该保留;避免旧请求覆盖乐观结果;请求少且不共享缓存时。

附:缓存键的五个反模式

缓存不命中、缓存串数据、失效不生效,根因几乎都在 queryKey。五种典型写法要避免:

反模式问题正确做法
只写字符串 ["orders"]不同筛选共用一份缓存["orders", { status, page }]
把整个对象塞进去含函数/Date,序列化不稳定只放影响结果的原值
键里放随机值每次都新键,永不命中随机值放 queryFn 里
层级顺序混乱["order", id] 与 [id, "order"] 并存固定前缀 + 参数顺序
失效范围过大改一条就失效整个列表用前缀失效,必要时精确定位

层级设计有个实用技巧:把“资源类型”放第一层,把“参数”放第二层。这样失效时可以用前缀一次命中整类数据:

qc.invalidateQueries({ queryKey: ["orders"] }); // 所有订单相关
qc.invalidateQueries({ queryKey: ["orders", id] }); // 只失效某一单

另一个容易混淆的点是“同一个接口、不同参数”到底算不算同一份数据。判断标准是:用户看到的内容是否相同。列表第 1 页与第 2 页是两份数据,必须分开缓存;而“当前用户信息”与“用户 ID + 头像”如果来自同一接口,就应该共用一键,避免两处数据不同步。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解