前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

TanStack Query 实战指南 缓存机制与 Next.js 集成

首页2025-10-06 16:20:00Front-End
React QueryTanStack Query数据获取状态管理前端工程化

一个列表页写到第三遍,你会发现自己一直在重复同样的四段代码,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 这三个钩子刚好对应「先改、失败回滚、最后对齐」。

fe
  • 一、服务端状态凭什么要单独管
  • 二、QueryKey 和两套状态
    • QueryKey 是缓存的身份证
    • 两个维度的状态
  • 三、上手
    • 安装和 Provider
    • 第一个查询
  • 四、默认配置里那几个关键值
  • 五、useMutation 与乐观更新
    • 基础用法
    • 乐观更新的三段式
    • 只做失效不做乐观更新
  • 六、依赖查询、并行、分页
    • 依赖查询
    • 并行查询
    • 分页
    • 无限滚动
  • 七、Next.js App Router 集成
    • QueryClient 实例的处理
    • 服务端预取加客户端水合
    • 配合 Server Actions
  • 八、几个性能相关的点
    • 结构化共享
    • 让查询保持活跃
    • 窗口焦点刷新的取舍
  • 九、常见场景速查
  • 总结
  • 参考

← Next.js 16 升级指南 从Turbopack默认启用到异步API全面化React 状态管理库选型指南 四个主流方案横向对比 →