
一句话:缓存的核心是「这份数据什么时候算新鲜、什么时候该重新拉、没人用的时候什么时候回收」——对应
staleTime(新鲜期)、失效(invalidateQueries)与gcTime(回收时间);重试则要「只重试值得重试的错误」。
一、缓存的三个状态
一个查询的缓存有三种状态:
① fresh(新鲜)
· 在 staleTime 内
· 使用它【不会】触发新的请求
· 是「最省钱」的状态
② stale(过期但仍保留)
· 超过 staleTime,但缓存还在
· 【先用缓存渲染】(用户立刻看到内容)
· 同时【后台重新请求】(revalidate)→ 拿到新数据再更新界面
③ inactive(无人订阅)
· 没有组件在用它(都卸载了)
· 开始计时 gcTime,超时后【从缓存中删除】
⭐ 这是「stale-while-revalidate(SWR)」策略:
先给用户旧数据(快),再在后台更新(新)。
时间轴示意(staleTime = 60s,gcTime = 5min):
t=0 组件挂载 → 请求 → 拿到数据(fresh 开始)
t=0~60s 组件挂载/切回标签页 → 直接用缓存,不发请求
t=60s 数据变成 stale(但仍在缓存里)
t=70s 组件重新挂载 → 先用旧数据显示 → 后台重新请求 → 更新
t=120s 组件卸载 → 进入 inactive 状态,gcTime 倒计时开始
t=420s gcTime 到期 → 缓存被删除
二、staleTime 与 gcTime
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 60_000, // ⭐ 新鲜期:1 分钟内不发新请求
gcTime: 5 * 60_000, // ⭐ 无订阅后保留 5 分钟(默认值)
});
| 配置 | 含义 | 影响 |
|---|---|---|
staleTime |
数据「新鲜」的时长 | 越长 → 请求越少、数据越可能旧 |
gcTime |
无订阅后缓存的保留时长 | 越长 → 内存占用越多、返回页面越快 |
✅ 按「数据变化频率」设置 staleTime:
· 几乎不变(配置、枚举、字典) → staleTime: Infinity(或很长)
· 偶尔变化(用户资料、详情) → staleTime: 5 * 60_000(5 分钟)
· 经常变化(列表、通知) → staleTime: 30_000(30 秒)
· 实时性要求高(库存、价格) → staleTime: 0 + refetchInterval
· 只需要一次(如「字典」) → staleTime: Infinity + gcTime: Infinity
⚠️ 为什么默认 staleTime 是 0?
TanStack Query 的默认假设是「数据随时可能过期」——
所以「组件挂载」时会立即重新请求(先用缓存渲染,再后台更新)。
如果你的接口「变化很少」,应该显式调大 staleTime(否则会请求过多)。
三、失效的三种方式
const queryClient = useQueryClient();
// ① invalidateQueries:标记失效 + 重新获取(最常用)
queryClient.invalidateQueries({ queryKey: ['users'] });
// ⭐ 前缀匹配:['users'] 会失效 ['users'] / ['users', 1] / ['users', { page: 1 }]
// 精确匹配某个查询
queryClient.invalidateQueries({ queryKey: ['users', 1], exact: true });
// 只标记失效但不立即重新获取(下次使用时才拉)
queryClient.invalidateQueries({ queryKey: ['users'], refetchType: 'none' });
// ② setQueryData:直接写入缓存(乐观更新 / 局部更新)
queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, newUser]);
queryClient.setQueryData(['users', id], newUser);
// ③ removeQueries:删除缓存(不重新请求)
queryClient.removeQueries({ queryKey: ['users', id] });
// ⚠️ 与 invalidate 的区别:remove 是「删掉」,下次用时会重新请求(像从没加载过)
| 方式 | 缓存 | 是否重新请求 | 用途 |
|---|---|---|---|
invalidateQueries |
标记 stale + 保留 |
✅(默认立即) | mutation 后刷新列表(最常用) |
setQueryData |
直接覆盖 | ❌ | 乐观更新、已知新数据 |
removeQueries |
删除 | 下次用时 | 数据「不应保留」(如登出) |
resetQueries |
重置为初始态 | ✅ | 需要「彻底重来」 |
// ✅ 失效的粒度:用「前缀」批量失效
// 所有用户相关查询(列表 + 详情)
queryClient.invalidateQueries({ queryKey: ['users'] });
// 只有某个用户的详情
queryClient.invalidateQueries({ queryKey: ['users', id] });
// ✅ mutation 里的标准用法(见 7.4)
useMutation({
mutationFn: updateUser,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // ⭐ 无论成败都刷新
},
});
四、自动重新验证的触发点
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: true, // 组件挂载时(数据 stale 才请求)
refetchOnWindowFocus: true, // 切回标签页时
refetchOnReconnect: true, // 网络恢复时
refetchInterval: false, // 定时轮询(默认关闭)
refetchIntervalInBackground: false, // 后台(标签页不可见)是否继续轮询
},
},
});
| 触发点 | 默认 | 说明 |
|---|---|---|
| 组件挂载 | ✅ | 数据 stale 才会发请求(fresh 时用缓存) |
| 窗口聚焦 | ✅ | 用户切回标签页 → 数据可能已变 |
| 网络重连 | ✅ | 断网期间的数据可能已过期 |
| 定时轮询 | ❌ | 用 refetchInterval 显式开启 |
| 手动 | — | refetch() |
// ✅ 实时数据的轮询写法
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30_000, // 每 30 秒
refetchIntervalInBackground: false, // 标签页不可见时暂停(省资源)
staleTime: 0, // 每次都要最新
});
// ✅ 只想要「手动刷新」的场景(如后台管理列表)
useQuery({
queryKey: ['orders'],
queryFn: fetchOrders,
staleTime: Infinity, // ⭐ 永不自动刷新
refetchOnWindowFocus: false,
refetchOnMount: false,
// 只在 mutation 后 invalidate,或用户手动 refetch
});
五、重试策略
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: 3, // 失败重试 3 次(默认 3)
retryDelay: (attemptIndex) => // ⭐ 指数退避
Math.min(1000 * 2 ** attemptIndex, 30_000),
// 0s → 1s → 2s → 4s(上限 30s)
});
// ✅ 「只重试值得重试的错误」——这是最重要的配置
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
retry: (failureCount, error) => {
const status = (error as { status?: number }).status;
// ❌ 4xx:参数错误、无权限、不存在 → 重试也不会成功
if (status && status >= 400 && status < 500) return false;
// ✅ 网络错误(无 status)与 5xx → 可以重试
return failureCount < 3;
},
retryDelay: (i) => Math.min(1000 * 2 ** i, 30_000),
});
| 错误类型 | 是否重试 | 理由 |
|---|---|---|
| 网络错误(断网/DNS) | ✅ | 可能恢复 |
| 超时 | ✅ | 可能恢复 |
429 限流 |
✅(但要尊重 Retry-After) |
过一会儿能成功 |
5xx 服务端错误 |
✅ | 可能是瞬时故障 |
400 参数错误 |
❌ | 重试结果一样 |
401 未认证 |
❌ | 需要重新登录(由拦截器处理) |
403 无权限 |
❌ | 重试也没权限 |
404 不存在 |
❌ | 资源本身没有了 |
⚠️ 三个重试相关的注意点:
① 幂等性:只有「GET 类查询」适合自动重试;
mutation(写操作)默认【不重试】(可能重复下单/重复创建)
→ 如果接口设计为幂等(带幂等键),才可以开启 mutation 的 retry
② 退避:重试间隔要「指数增长 + 加抖动」,避免雪崩
(大量客户端同时重试会打垮服务端)
③ 上限:要有次数上限与总时长上限,否则可能一直重试
六、实践配置建议
// ✅ 一个较合理的默认配置
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 默认 1 分钟(按业务调整)
gcTime: 10 * 60_000, // 保留 10 分钟
refetchOnWindowFocus: true, // 合理(列表数据会变)
refetchOnReconnect: true,
retry: (count, error) => {
const status = (error as { status?: number }).status;
if (status && status >= 400 && status < 500) return false;
return count < 2;
},
retryDelay: (i) => Math.min(1000 * 2 ** i, 30_000),
// ⭐ 全局错误处理(可选;也可以在每个 query 里处理)
},
mutations: {
retry: 0, // 写操作默认不重试
},
},
});
// ✅ 按「数据特征」分类配置
// src/api/queries.ts
export const queryKeys = {
users: {
all: ['users'] as const,
list: (params: ListParams) => ['users', 'list', params] as const,
detail: (id: string) => ['users', 'detail', id] as const,
},
dict: {
all: ['dict'] as const,
item: (type: string) => ['dict', type] as const,
},
} as const;
// 字典类型:永不自动过期
export function useDict(type: string) {
return useQuery({
queryKey: queryKeys.dict.item(type),
queryFn: () => fetchDict(type),
staleTime: Infinity,
gcTime: Infinity,
});
}
// 列表:30 秒新鲜期 + 聚焦重验证
export function useUserList(params: ListParams) {
return useQuery({
queryKey: queryKeys.users.list(params),
queryFn: () => fetchUsers(params),
staleTime: 30_000,
});
}
七、常见坑
// ① 把 staleTime 当「缓存时间」(其实是「新鲜期」)
staleTime: 60_000; // ⚠️ 不是「缓存 60 秒就删除」
// ② staleTime 设得太大导致数据「很旧还显示」
// ③ 忘了 invalidate → mutation 后列表不更新
// ④ invalidate 的 key 粒度太粗(一次刷新所有列表)
queryClient.invalidateQueries({ queryKey: ['users'] }); // ⚠️ 所有 users 查询都刷
// ✅ 用更具体的 key
// ⑤ 对 mutation 开启自动重试(可能重复创建)
useMutation({ retry: 3 }); // ⚠️ 除非接口幂等
// ⑥ 对所有错误都重试(4xx 永远失败)
// ⑦ 重试间隔没有退避(雪崩风险)
// ⑧ 用 removeQueries 代替 invalidate(语义不同)
// ⑨ 用 setQueryData「手写」本应 invalidate 的逻辑(容易漏字段)
// ⑩ 忘记「登出要清缓存」(下个用户看到上个用户的数据)
authStore.clear();
// ✅ 还要 queryClient.clear();
// ⑪ 把 staleTime 设成 Infinity 但忘了「数据会变」(用户看到永久旧数据)
// ⑫ 在「轮询」时没关 refetchIntervalInBackground(后台标签页白耗资源)
// ⑬ 认为「invalidate 会立刻删除缓存」(其实是「标记过期 + 后台刷新」)
// ⑭ 用「时间」而不是「事件」来失效(应该 mutation 后 invalidate)
// ⑮ 在 SSR 里忘了「把预取的数据脱水给客户端」(导致客户端再请求一次)
// ⑩ 的完整处理
function useLogout() {
const queryClient = useQueryClient();
return useCallback(() => {
authStore.getState().clear();
queryClient.clear(); // ⭐ 清掉所有缓存(避免数据串用户)
navigate('/login', { replace: true });
}, [queryClient, navigate]);
}
八、面试延伸
- 「
staleTime和gcTime有什么区别?」
staleTime是「新鲜期」——数据在新鲜期内不会因为「组件挂载/窗口聚焦/重新连接」而重新请求(直接用缓存渲染);超过它数据就变成stale(会「先用旧数据渲染、同时后台重新请求」)。gcTime是「回收时间」——当查询没有任何组件订阅时,缓存还能保留多久(超时被删除)。关键词:staleTime决定「要不要重新请求」,gcTime决定「什么时候丢掉缓存」。 -
「默认的
staleTime是多少?为什么?」
默认是 0——也就是「数据一拿到就过期」。TanStack Query 的假设是「服务端数据随时可能变化」,所以「组件挂载」时会立即重新请求(先用缓存渲染,再在后台更新,即 stale-while-revalidate)。如果你的接口「变化很少」(字典、枚举、配置),应该显式把staleTime调大(甚至Infinity),否则会产生大量无意义的请求。 -
「
invalidateQueries做了什么?」
两件事:① 把匹配queryKey的所有查询标记为stale(过期);② 默认立即重新获取(refetchType: 'active'只重新获取「有组件在用的」)。它支持前缀匹配(['users']会命中['users', 1]、['users', { page: 1 }]),也可以用exact: true精确匹配。它是 mutation 之后刷新数据的标准做法(配合onSettled),比手写setQueryData更简单可靠。 -
「什么时候会『自动重新请求』?」
四个默认触发点:① 组件挂载(且数据是stale);② 窗口重新聚焦(用户切回标签页);③ 网络重新连接;④ 依赖的queryKey变化(参数变了)。另外还可以显式开启refetchInterval(轮询),或调用refetch()。实践上「聚焦重验证」和「重连重验证」很有价值(用户回到页面时数据自动是最新的),但对「不需要实时的列表」可以关掉以减少请求。 -
「重试策略应该怎么设计?」
三个原则:① 只重试值得重试的错误——网络错误、超时、429、5xx可以重试;4xx(参数、权限、不存在)不该重试(结果不会变)。② 指数退避 + 抖动——retryDelay: i => Math.min(1000 * 2 ** i, 30000),避免「大量客户端同时重试打垮服务端」。③ 有上限——次数与总时长都要限制(默认 3 次)。此外 mutation 默认不重试(避免重复写),除非接口幂等。 -
「为什么不重试 mutation?」
因为 mutation 是「写操作」——重试可能导致重复创建/重复下单/重复扣款。HTTP 语义上GET是幂等的(重试安全),POST不是。只有当你确认「接口是幂等的」(如带幂等键idempotency-key、或业务上是PUT全量替换)时,才可以开启 mutation 的重试。所以 TanStack Query 的默认是「queries.retry: 3、mutations.retry: 0」。 -
「
invalidateQueries和setQueryData怎么选?」
判断标准是「你是否已经知道新数据」:① 不知道(只改了服务端,本地没有完整的新数据)→ 用invalidateQueries(让库去重新拉取,简单可靠、不会漏字段);② 知道(比如乐观更新时你已经构造了「提交后的样子」)→ 用setQueryData(直接写入缓存,没有网络往返,体验更快)。实践中常见的是「乐观更新时用setQueryData,onSettled里再用invalidateQueries兜底」。 -
「登出时为什么要清缓存?」
因为缓存里可能存着上一个用户的私有数据(列表、详情、消息)。如果只清「鉴权状态」(token/user)而不清缓存,那么下一个用户登录后,某些查询会在「缓存新鲜期」内直接返回上一个用户的数据——这是很严重的隐私与安全问题。所以登出时必须:① 清鉴权状态;②queryClient.clear()(清所有查询缓存);③ 清 localStorage 里的用户相关数据;④ 清理其它用户相关的全局状态(如购物车)。
一句话速记
缓存有三个状态「fresh(新鲜期内不发请求)→ stale(先用旧数据渲染 + 后台刷新)→ inactive(无订阅后按
gcTime回收)」,这就是 stale-while-revalidate;staleTime决定「要不要重新请求」(默认 **0,所以「字典/枚举」要显式调大甚至Infinity)、gcTime决定「什么时候丢缓存」(默认 5 分钟);失效三方式「invalidateQueries(标记过期 + 后台刷新,最常用、支持前缀匹配)、setQueryData(直接写缓存,用于乐观更新)、removeQueries(删除)」;自动重验证触发点是「挂载 / 窗口聚焦 / 网络重连 / key 变化」(轮询要显式开refetchInterval);重试只针对「网络错误 / 超时 / 429 / 5xx」,4xx不重试,并用指数退避 + 上限;mutation 默认不重试(除非幂等);登出必须queryClient.clear()**(否则串用户数据)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!