TanStack Query 实战指南 缓存机制与 Next.js 集成
一个列表页写到第三遍,你会发现自己一直在重复同样的四段代码,useState 存数据、useState 存 loading、useState 存 error、useEffect 里发请求外加一个取消标志位。等到产品说「切回这个页面别再转圈了,先显示旧数据」,这套手写方案就开始崩。TanStack Query(原名 React Query)做的就是把这一整套接管掉,同时把缓存、去重、重试、后台刷新一并给你。这篇按 QueryKey、缓存时间、变更与乐观更新、Next.js 集成这个顺序走一遍,重点讲那些默认值背后的取舍。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 服务端状态和客户端状态到底差在哪,为什么不能用同一套工具管
- QueryKey 的设计规则,以及顺序敏感这个容易踩的点
status和fetchStatus两套状态的分工staleTime与gcTime的区别,这是最容易搞混的一对useMutation的乐观更新三段式和失败回滚- 依赖查询、并行查询、分页与无限滚动的写法
- Next.js App Router 下的服务端预取加客户端水合
- 结构化共享、保持查询活跃、窗口焦点刷新这几个优化点
# 一、服务端状态凭什么要单独管
先把概念摆清楚。Zustand、Redux 这些库管的是客户端状态,它们的隐含前提是「这份数据归你所有,你改了它就是改了」。
服务端状态不满足这个前提,它有四个特性是客户端状态没有的。数据存在你不控制的服务器上;读写都得走异步接口,没法同步拿到;它是共享的,别人随时可能改掉;你手上这份不主动刷新就会过期,而且你不知道它什么时候过期的。
正是这四条,让服务端状态成了前端里最难处理的一块。
在没有专门工具之前,常见的做法有三种。一种是组件里 useEffect 加 useState 手撸;一种是把接口数据往 Redux 这类通用状态库里塞;还有一种是用 SWR 这样的轻量方案。
三种都能跑,但代价不一样。手撸意味着加载态、错误处理、缓存、重试、请求竞态这些逻辑每个页面写一遍,写十遍就有十种不同的写法。塞进 Redux 的问题是 store 会长满 userListLoading、userListError 这种伴生字段,而且 Redux 本身对异步没有任何内建支持,得靠 thunk 或者 saga 补。关于客户端状态该怎么选库,可以看这篇 React 状态管理库选型指南,这里就不展开了。
TanStack Query 是专门为服务端状态设计的,零配置就有一套合理的默认行为,同时几乎每个行为都能改。
# 二、QueryKey 和两套状态
# QueryKey 是缓存的身份证
每个查询都要有一个唯一的 key,这个 key 决定了缓存命中、数据共享和失效范围。
基础写法很直接,取待办列表用 ['todos'],取某个用户用 ['user', userId],带筛选条件的用 ['todos', { status: 'done', page: 1 }]。TanStack Query 会对 key 做确定性哈希,相同 key 的查询共享同一份缓存,同时同一时刻只会发出一个请求。
数组元素的顺序是敏感的。['todos', status, page] 和 ['todos', page, status] 会被当成两个不同的查询。但 key 里的对象是顺序无关的,{ a: 1, b: 2 } 和 { b: 2, a: 1 } 哈希结果一样,这个设计挺贴心的。
这里有个坑要注意,很多人第一次用会把 key 写死成 ['user'],然后靠 queryFn 里的闭包变量去取不同的 userId。结果就是切换用户时缓存永远命中同一条,界面不更新。凡是 queryFn 里用到的外部变量,都要放进 key 里。
按层级组织 key 还有一个附带好处,失效的时候可以按前缀批量处理。
// 只失效第一页
queryClient.invalidateQueries({ queryKey: ['todos', 'list', 1] })
// 失效所有 todos 相关查询
queryClient.invalidateQueries({ queryKey: ['todos'] })
# 两个维度的状态
useQuery 返回的状态有两套,搞混了会写出很奇怪的加载逻辑。
第一套是 status,回答的是「数据有没有」。isPending 表示还没有数据,isError 表示查询失败可以从 error 拿错误,isSuccess 表示数据在 data 里。
第二套是 fetchStatus,回答的是「请求在不在跑」。fetching 表示正在发请求,paused 表示因为断网之类的原因暂停了,idle 表示当前没有请求。
两套是正交的,所以会出现 status 为 success 同时 fetchStatus 为 fetching 的组合,含义是「有缓存数据可以先渲染,同时后台正在拉新的」。这正是 stale-while-revalidate 的样子,也是 TanStack Query 体验好的核心原因。
所以判断「首次加载中」用 isPending,判断「有没有请求在跑」用 isFetching。用 isFetching 控制全屏 loading,页面每次后台刷新都会闪一下。这个我踩过。
# 三、上手
# 安装和 Provider
npm install @tanstack/react-query
# 或
pnpm add @tanstack/react-query
# 或
yarn add @tanstack/react-query
根组件包一层 QueryClientProvider。
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
QueryClient 实例在纯客户端应用里可以放模块作用域,SSR 场景不行,第七节会说原因。
# 第一个查询
import { useQuery } from '@tanstack/react-query'
function RepoInfo() {
const { isPending, isError, data, error } = useQuery({
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/TanStack/query')
.then((res) => res.json())
})
if (isPending) return <div>加载中...</div>
if (isError) {
return <div>错误 {error.message}</div>
}
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<div>{data.stargazers_count} Stars</div>
<div>{data.forks_count} Forks</div>
</div>
)
}
queryKey 指定身份,queryFn 负责取数,剩下的加载态、错误态、缓存全是库在管。
用 fetch 时有个细节,fetch 对 4xx 和 5xx 不会 reject,所以上面这段代码在接口返回 404 时会把错误 JSON 当成正常数据渲染。正确做法是在 queryFn 里检查 res.ok 并手动 throw,或者干脆用 axios,它默认会对非 2xx 抛错。
# 四、默认配置里那几个关键值
TanStack Query 的默认值挺激进,不理解它们就会觉得「怎么老在发请求」。
staleTime 默认是 0。数据一拿到就被标记为过时,之后只要组件重新挂载、窗口重新获得焦点、网络重连,就会触发一次后台刷新。想少发请求就把它调大。
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000 // 5 分钟内不主动刷新
})
gcTime 默认 5 分钟,管的是「没有组件在用这份数据之后,缓存还留多久」。超时之后被垃圾回收。这个值和 staleTime 经常被搞混。
先说结论,staleTime 决定「要不要重新请求」,gcTime 决定「缓存什么时候删掉」。数据 stale 了缓存还在,所以重新进页面时会先渲染旧数据再后台刷新,界面不会空白。数据被 gc 掉了缓存就没了,重新进页面会走完整的 pending 状态。
我一开始也是这么想的,以为把 staleTime 设长就够了。结果用户切走五分钟再回来,页面还是白了一下。原因就是 gcTime 到期把缓存清了。两个值要一起调,一般让 gcTime 大于等于 staleTime。
retry 默认 3 次,指数退避。这对偶发的网络抖动很有用,但对 404 这种确定性错误是浪费。建议按状态码定制。
useQuery({
queryKey: ['user', id],
queryFn: fetchUser,
retry: (failureCount, error) => {
if (error.status === 404) return false
return failureCount < 3
}
})
refetchOnWindowFocus 默认 true,用户切回标签页时自动刷新。对看板、消息这类数据是加分项,对一个正在填的长表单页面就是干扰。
# 五、useMutation 与乐观更新
# 基础用法
增删改走 useMutation,它和 useQuery 最大的区别是不会自动执行,得你手动 mutate。
function CreateTodo() {
const mutation = useMutation({
mutationFn: (newTodo) =>
axios.post('/api/todos', newTodo)
})
return (
<button
onClick={() => {
mutation.mutate({ title: '学习 TanStack Query', completed: false })
}}
disabled={mutation.isPending}
>
{mutation.isPending ? '创建中...' : '创建待办'}
</button>
)
}
状态有四个,isIdle 还没触发过、isPending 执行中、isSuccess 成功、isError 失败。另外 mutate 不返回 Promise,需要 await 结果的话用 mutateAsync,但记得自己 try catch,不然会有未处理的 rejection。
# 乐观更新的三段式
乐观更新就是在服务端响应之前先把界面改掉。onMutate、onError、onSettled 这三个钩子刚好对应「先改、失败回滚、最后对齐」。