模块二 · Hooks 与数据
请求与缓存:把服务端状态交给 TanStack Query
服务端状态和客户端状态不是一类东西。这一节讲清 queryKey、staleTime 与失效机制,用乐观更新和分页把列表页面写对,并说明什么时候不该引入请求库。
读完这一节,你能做到
- 区分服务端状态与客户端状态,不再把它们塞进同一个 store。
- 用
queryKey与queryFn描述一份数据,理解缓存命中的条件。 - 用失效与乐观更新处理增删改后的数据一致性。
- 判断请求库与手写
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"] }); // 最终以服务端为准
},
});
三个步骤缺一不可:
cancelQueries:否则旧请求返回后会把乐观更新覆盖掉。- 快照回滚:失败时界面必须恢复,否则用户以为成功了。
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 客户端) | 避免两套缓存 |
| 极小的脚本或静态页 | 增加包体不划算 |
判断标准:是否出现"同一份数据多处使用 + 需要失效与重试 + 需要加载态"这三件事中的两件。出现两件,就该上库。
八、常见坑
queryKey里漏了参数,切换筛选后拿到的还是旧缓存。- 把
staleTime当gcTime用,以为数据永不过期。 - 用
isPending判断"正在加载",翻页时整页变骨架屏。 - 乐观更新忘了
cancelQueries,结果被旧请求覆盖。 - 失败后不回滚,界面显示成功但服务端没改。
- 每次渲染创建新的
queryFn对象,导致缓存键不稳定。 - 把请求库缓存的数据再抄一份进 Redux,制造第二份真相。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用
useQuery重写一个useEffect + fetch的列表页,对比代码量与状态分支数量。 - 给列表加"标记完成"操作,用乐观更新实现,并故意让接口返回 500 验证回滚。
- 给列表加分页,要求翻页时保留旧数据并显示小 spinner。
- 设计一组
queryKey,覆盖列表、详情、用户信息三类数据,说明为什么这样拆。
② 让 AI 写同一题
把第 2 题(乐观更新)交给 AI,补一句 成功与失败都要有明确回滚路径。
③ 对比要盯的点
- 它在
onMutate里cancelQueries并拍快照了吗?没有快照,失败就没得回滚。 onError它是恢复快照,还是简单invalidateQueries?后者会让列表先闪一下旧数据。- 翻页加载它用
isPending还是isFetching?用错会出现“翻页时整页变白”。
④ 一句话取舍
如果标记完成的接口要 2 秒,它那版会闪什么?你会把哪一步改成“先本地改、失败再回退”?
十、自测
- 服务端状态与客户端状态的核心区别是什么?
staleTime与gcTime分别控制什么?- 为什么翻页时不该用
isPending判断加载? - 乐观更新为什么必须
cancelQueries? - 什么情况下不该引入请求库?
参考答案要点:真相来源与是否过期;新鲜期与回收期;已有旧数据,应该保留;避免旧请求覆盖乐观结果;请求少且不共享缓存时。
附:缓存键的五个反模式
缓存不命中、缓存串数据、失效不生效,根因几乎都在 queryKey。五种典型写法要避免:
| 反模式 | 问题 | 正确做法 |
|---|---|---|
只写字符串 ["orders"] | 不同筛选共用一份缓存 | ["orders", { status, page }] |
| 把整个对象塞进去 | 含函数/Date,序列化不稳定 | 只放影响结果的原值 |
| 键里放随机值 | 每次都新键,永不命中 | 随机值放 queryFn 里 |
| 层级顺序混乱 | ["order", id] 与 [id, "order"] 并存 | 固定前缀 + 参数顺序 |
| 失效范围过大 | 改一条就失效整个列表 | 用前缀失效,必要时精确定位 |
层级设计有个实用技巧:把“资源类型”放第一层,把“参数”放第二层。这样失效时可以用前缀一次命中整类数据:
qc.invalidateQueries({ queryKey: ["orders"] }); // 所有订单相关
qc.invalidateQueries({ queryKey: ["orders", id] }); // 只失效某一单
另一个容易混淆的点是“同一个接口、不同参数”到底算不算同一份数据。判断标准是:用户看到的内容是否相同。列表第 1 页与第 2 页是两份数据,必须分开缓存;而“当前用户信息”与“用户 ID + 头像”如果来自同一接口,就应该共用一键,避免两处数据不同步。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★☆☆ 自定义 Hook 的本质是什么?需要遵守哪些规则?
- ★★★★★ SSR、SSG、ISR 有什么区别?分别适合什么场景?
- ★★★☆☆ 微前端、模块联邦和 Monorepo 各自解决什么问题?
提示:读完可以顺手做几道对应题目,检验是否真的理解