第十二章:TanStack Query 从入门到精通:吃透缓存策略、状态同步与服务端交互,构建高效数据管理系统
第十二章:TanStack Query 从入门到精通:吃透缓存策略、状态同步与服务端交互,构建高效数据管理系统
Prorise序章: 战略定位与工程实践:搭建实战环境
摘要: 在本章中,我们将首先从 战略层面 出发,剖析 React 生态中一个至关重要的概念:服务器状态 与 客户端状态 的区别。接着,我们将鸟瞰整个 TanStack 生态,并阐明为何 TanStack Query 是我们解决服务器状态管理问题的“天选之子”。最后,我们将进入 工程实践 环节,手把手地搭建一个集成了所有现代化工具的、生产级的 React 开发环境,为您后续的学习扫清一切障碍。
0.1. 战略定位:为什么是 TanStack Query?
痛点背景: 作为一名经验丰富的 Vue 开发者,您已经习惯了使用 Pinia (或 Vuex) 来管理应用状态。但您可能也隐约感觉到,无论是 Pinia 还是它在 React 中的对等物 Zustand,它们在处理从后端 API 获取的数据时,都显得有些“笨拙”。我们通常需要自己编写这样的代码:
1 | // 在 Zustand/Pinia store 中的典型异步 action |
这段代码背后,隐藏着一系列棘手的问题:
- 我们何时应该重新获取数据?(例如用户切换回浏览器标签页时)
- 如何处理数据缓存,避免不必要的网络请求?
- 如何实现分页或无限滚动?
- 如何在数据变更后(例如添加了一个新用户),智能地更新用户列表?
手动处理这些问题,会导致状态管理逻辑急剧膨胀且难以维护。这正是因为我们混淆了两种截然不同的状态。
核心概念:服务器状态 vs 客户端状态
定义: 存储在远程服务器上,我们通过网络请求来读取和修改的状态。我们并不直接拥有它,只能拥有它的“快照”。
- 特点:
- 异步性: 必须通过异步 API 获取和更新。
- 所有权: 数据所有权在后端,前端数据随时可能“过时”。
- 共享性: 可能被其他用户、其他设备修改。
- 示例:
- 用户列表
- 文章详情
- 商品库存
定义: 完全存在于前端应用内部,由用户交互或应用逻辑直接控制的状态。我们对其拥有完全的控制权。
- 特点:
- 同步性: 可以被同步地、直接地读取和修改。
- 所有权: 数据所有权在前端。
- 私有性: 通常只与当前用户会话相关。
- 示例:
- 暗黑模式的开关状态
- 表单的输入内容
- 一个 Modal 是否打开
所以,Zustand (Pinia) 的问题在于,它被设计用来管理“客户端状态”,但我们却用它来硬抗“服务器状态”,导致了代码的复杂化。
完全正确。用专业的工具做专业的事。Zustand 依然是我们管理客户端状态的最佳选择,但我们需要一个“服务器状态专家”。
这位专家就是 TanStack Query?
正是。TanStack Query 不会取代 Zustand,它们是天生的伙伴。Zustand 管理你的 UI 状态,TanStack Query 则帮你打理好所有与后端数据交互的脏活累活。
生态审视:我们的“武断”但正确的选择
TanStack 生态提供了一个庞大的工具箱,但作为追求效率的“转译者”,我们只选择其中最核心、最无可替代的组件。
| 生态库 | 核心描述 | 我们的选择 (2025 年最佳实践) |
|---|---|---|
| TanStack Query | (核心) 强大的异步状态管理,专用于处理服务器状态。 | 💎 必学。它是解决上述所有服务器状态痛点的终极方案,是 React 生态的事实标准。 |
| TanStack Router | 类型安全的路由库。 | 🟡 暂不学习。React Router 仍是社区共识最广、生态最成熟的方案,我们优先学习它作为 Vue Router 的对等物。 |
| TanStack Table | “Headless”的复杂表格构建库。 | 🟡 按需学习。这是一个“攻坚武器”,初期,Ant Design 自带的表格组件已足够强大。 |
| TanStack Form | 类型安全的表单状态管理库。 | 🔴 直接跳过。React 社区已有公认的黄金组合:React Hook Form + Zod,我们直接采用这套最佳实践。 |
| TanStack Store | 轻量级状态管理器。 | 🔴 直接跳过。您已经掌握了 Zustand,它就是 Pinia 在 React 中最简洁、最高效的对等物。 |
0.2. 现代 React 数据流:三大核心的职责分离
在 Vue 中,我们习惯于将大部分状态逻辑集中在 Pinia 中。但在现代 React 生态中,最佳实践是进行更精细的职责划分,将状态管理视为一个由多个专业工具协同工作的系统。
| 工具 | 核心职责 | 知识转译 (类比) |
|---|---|---|
| React Router | 数据流的“编排与触发器” | 它如同项目的 “交通指挥官”。负责在路由导航时,命令“谁”去获取数据,以及在表单提交时,命令“谁”去执行变更。它关心 “何时何地” 发生数据交互。 |
| TanStack Query | 服务端状态的“引擎与缓存中心” | 它是应用的 “智能数据引擎”。负责实际执行数据获取、管理复杂的缓存、后台同步、重试、乐观更新等所有脏活累活。它关心 “如何更好地管理” 服务器数据。 |
| Zustand (切片模式) | 客户端状态的“全局内存” | 它是应用的 “前端内存数据库”。负责存储与 UI 强相关的、跨组件共享的、非持久化的客户端状态。它关心 “如何高效管理” 与服务器无关的全局状态。 |
0.3. 黄金工作流:我们将遵循的开发范式
忘掉在 useEffect 中 fetch 数据,也忘掉在组件中直接调用 Store 的异步 Action。我们将遵循一套更强大、更解耦的范式,这套范式将贯穿我们所有的后续章节,让代码保持极度的简洁和优雅。
数据读取 (Read) 的工作流
伪代码范式:
1 | // 1. 在路由层 (router.tsx): 定义“何时”获取 |
数据变更 (Write) 的工作流
伪代码范式:
1 | // 1. 在组件层 (AddUserForm.tsx): 定义一个变更操作 |
Zustand 的角色:全局事件总线与 UI 状态中心
当一个组件(如 AddUserForm)的变更,需要通知另一个完全不相关的组件(如全局头部的 <Profile />)时,Zustand 就登场了。
1 | // 在 useMutation 的 onSuccess 中 |
loader触发:路由loader是数据获取的唯一入口,它调用 TanStack Query。useQuery消费:组件通过useQuery从 TanStack Query 缓存中读取数据。useMutation变更:组件通过useMutation执行数据变更。invalidateQueries同步:变更成功后,通过invalidateQueries实现服务器状态的自动同步。Zustand广播:对于需要即时响应的跨组件 UI 更新,使用 Zustand 进行状态广播。
0.4. 工程实践:将架构落地为代码
现在,我们将上述架构思想,完整地落地到一个可运行的、结构优雅的启动器项目中。
第一步:初始化项目与安装核心依赖
1 | # 1. 创建 Vite + React + TS 项目 |
说明: 我们将项目所需的所有“砖块”一次性准备好,后续步骤将专注于如何将它们“砌”成坚固的结构。
第二步:配置样式、路径别名与 TS
文件路径: vite.config.ts
1 | import { defineConfig } from 'vite' |
文件路径: src/index.css (清空后)
1 | @import "tailwindcss"; |
文件路径: tsconfig.json (复制粘贴)
1 | { |
关键一步: 配置完成后,请在 VSCode 中使用 Ctrl+Shift+P (或 Cmd+Shift+P),然后选择 TypeScript: Restart TS Server 以使路径别名生效。
第三步:搭建数据层 (api, types, utils) 与模拟 API
搭建模拟 API
文件路径:db.json(项目根目录)1
{ "users": [ { "id": 1, "name": "张三 (from db.json)", "email": "zhangsan@example.com" }, { "id": 2, "name": "李四 (from db.json)", "email": "lisi@example.com" } ] }
文件路径:
package.json(添加脚本)注意: 请保持
pnpm run api在一个独立的终端中持续运行,我们故意加上了后端 api 的三秒延迟用于测试缓存机制以及加载优化1
2
3
4"scripts": {
// ....其他脚本
"api": "json-server --watch db.json --port 3001 --delay=3000"
},创建类型定义
文件路径:src/types/UserType.ts1
2
3
4
5export interface User {
id: number;
name: string;
email: string;
}创建 Axios 实例 (Utils 层)
文件路径:src/utils/server.ts1
2
3
4
5import axios from "axios";
const axiosInstance = axios.create({
baseURL: "http://localhost:3001",
});
export default axiosInstance;创建 API 请求函数 (API 层)
文件路径:src/api/user.ts1
2
3
4
5
6
7import axiosInstance from "@/utils/server";
import type { User } from "@/types/UserType";
export const getUsers = async (): Promise<User[]> => {
const response = await axiosInstance.get("/users");
return response.data;
};
第四步:解决循环依赖 - 独立 queryClient.ts
痛点: main.tsx 需要 router,而 router 的 loader 又需要 main.tsx 导出的 queryClient,这会造成循环依赖。
解决方案: 将 queryClient 实例创建在一个独立的、无依赖的模块中。
文件路径: src/lib/queryClient.ts (新建文件夹和文件)
1 | import { QueryClient } from "@tanstack/react-query"; |
第五步:配置应用入口 (main.tsx)
文件路径: src/main.tsx (完整重写)
1 | import { StrictMode } from 'react' |
第六步:创建空的页面、布局与路由
我们只搭建“空壳”,不填充任何业务逻辑。
文件路径: src/App.tsx (作为布局)
1 | import { Layout } from 'antd'; |
文件路径: src/pages/Home.tsx 和 src/pages/Users.tsx (创建空壳)
1 | // Home.tsx |
1 | // Users.tsx |
文件路径: src/router/index.tsx
1 | import { createBrowserRouter } from "react-router-dom"; |
第七步:验证与架构图
运行 pnpm run dev 和 pnpm run api,验证应用骨架可正常运行。至此,我们已经搭建了如下清晰的分层架构:
1 | ┌─────────────────────────────────────┐ |
朋友,我们已经成功搭建好了环境,现在是时候迎接第一个“啊哈!”时刻了。我们将要学习的 useQuery 不仅仅是 fetch 的替代品,它是一种全新的、关于如何“看待”和“描述”异步数据的思维方式。
为何一个 isLoading 远远不够?
在我们过去的岁月里,一个简单的 isLoading 标志位似乎已经能解决所有问题。但让我们深入思考一下现代 Web 应用的真实场景,你会发现它的局限性:
首次加载 vs. 后台刷新:当用户第一次打开一个页面时,数据是完全没有的。此时,我们可能需要展示一个大面积的“骨架屏”(Skeleton)来优化体验,防止页面空白和抖动。但是,当用户已经看到了数据,我们只是在后台静默地刷新它(比如用户重新聚焦了浏览器窗口),你还想用那个粗暴的骨架屏去打扰用户吗?当然不。这时,一个微小、不引人注目的加载指示器(比如一个小 spinner)才是更优雅的选择。
职责混淆:用一个
isLoading来同时表示“页面正在初始化”和“数据正在后台更新”,这在逻辑上是含糊不清的。作为追求卓越架构的“开拓者”,我们需要更精确的工具来描述这两种截然不同的 UI 状态。
手动维护 isloading 的模式让我们陷入了两难。我们当然可以手动创建 isInitialLoading 和 isRefetching 两个状态,但这会让我们的 Store 变得更加臃肿,模板代码也越来越多。我们需要的,是一种原生就理解并区分这些状态的机制。
第八步:深入理解 status 与 fetchStatus 的双核系统
TanStack Query 的优雅之处,在于它为我们提供了一套精确而强大的双状态系统来描述查询的生命周期。请记住这两个核心概念,它们是你理解后面一切高级功能的基石:
| 状态类型 | 核心职责 | 可能的值 | 描述 |
|---|---|---|---|
status | 描述 “数据” 的状态 | pending | 无数据 并且正在进行首次请求。这是真正的“从零到一”阶段。 |
success | 请求成功,我们手中 有可用的数据 可以渲染。 | ||
error | 请求失败,我们手中没有数据,但 有错误信息。 | ||
fetchStatus | 描述 “网络请求” 的状态 | fetching | queryFn 正在执行中。无论是首次请求还是后台刷新,只要在请求,它就是 fetching。 |
paused | 请求因网络断开等原因被暂停,它会在网络恢复后自动重试。 | ||
idle | 当前没有任何网络请求在进行。 |
这看起来可能有点复杂,但别担心。TanStack Query 已经为我们封装好了更易于使用的衍生布尔值。你日常打交道最多的将是它们:
| 衍生状态 | 核心含义 | 最佳 UI 场景 |
|---|---|---|
isLoading | 首次加载中(因为还没有数据)。 | 骨架屏、整页加载动画。 |
isFetching | 任何 网络请求正在进行中。 | 细微的后台加载指示器、刷新按钮的 loading 状态。 |
isError | 请求失败。 | 错误提示信息。 |
isSuccess | 请求成功且有数据。 | 渲染主要内容。 |
isRefetching | 后台刷新中(因为已有旧数据)。 | 等同于 isFetching 的场景。 |
看到了吗?isLoading 只是 isFetching 在一种特殊情况(status 为 pending)下的表现。通过同时使用 isLoading 和 isFetching,我们就能完美地解决之前提出的 UI 状态区分难题。
第一章: 快速启动:非阻塞式数据预取与 useQuery
摘要: 在本章,我们将基于序章搭建的坚实架构,实现第一个完整的数据查询流程。我们将严格遵循 非阻塞式数据预取 (prefetchQuery) 的最佳实践,确保极致的用户体验。您将学习到如何优雅地串联起 Query 层、Router 层和 Component 层,并利用 Devtools 观察 isLoading 和 isFetching 的微妙差别。
1.1. 遵循范式:实现用户列表查询
第一步:创建 Query Options (Query 层)
这是连接 api 层与 component 层的桥梁,是代码复用的核心。
文件路径: src/queries/userQueries.ts (新建文件夹和文件)
1 | import { getUsers } from "@/api/user"; |
第二步:实现非阻塞式 Loader (Router 层)
在我们之前的路由章节中,我们知道 Loader 是通过 Promise.all 并行触发 所有这些数据,但这就会导致一个问题,当后端接口慢的时候,整个页面就连 loading 效果都加载不出来,因为页面的 dom 完全被 loader 接管,所以我们采用 prefetchQuery 来预取数据,它只触发请求,不阻塞 路由渲染
文件路径: src/router/index.tsx (修改)
1 | import { createBrowserRouter } from 'react-router-dom'; |
心智模型转变: loader 的作用从“必须准备好数据 才能进页面”转变为“通知 TanStack Query 开始准备数据,然后立即让路”。
第三步:消费数据并处理加载状态 (Component 层)
由于 loader 不再阻塞,组件自己需要负责处理加载状态,这提供了更好的用户体验。
文件路径: src/pages/Users.tsx (修改)
1 | import { useQuery } from "@tanstack/react-query"; |
第四步:验证黄金工作流
- 从首页点击“前往用户列表”。
- 您会 立即 跳转到
/users页面,并看到“正在加载用户列表…”的 Spin 组件。 - 短暂延迟后,用户列表显示出来。
- 切换到其他浏览器标签页再切回来,您会看到右上角出现“刷新中…”的小 Spin,列表数据随后更新,且回退到上一页再重新进入 Users 页面,你会发现数据完全被缓存了,除非有新的后台数据刷新,否则不会有接口再次请求

这套流程完美地展示了我们的架构:路由立即响应,组件负责展示加载状态,数据在后台被智能地预取和刷新。
1.2. 黄金工作流总结
数据流向图
1 | 1. 用户点击链接 → 触发路由切换 |
1.3. 高频面试题与陷阱
你选择了 prefetchQuery 而不是 ensureQueryData,这两种方案在用户体验上有什么本质区别?
ensureQueryData 是 阻塞式 的,它会暂停路由导航,直到数据获取成功。这会导致用户点击链接后,页面“卡住”一小段时间才跳转,是一种同步体验。
而 prefetchQuery 是 非阻塞式 的,它允许路由立即导航,页面组件先渲染出来(通常展示一个加载状态),同时数据在后台获取。这是一种异步体验,页面的响应性更高,用户能更快地得到视觉反馈,整体体验更流畅。
那么 prefetchQuery 有没有什么潜在的“陷阱”?
唯一的“陷阱”可能是开发者忘记在组件中处理 isLoading 状态。因为页面会立即渲染,如果 useQuery 返回的 data 是 undefined 且没有处理 isLoading,页面可能会因为访问 data 的属性而报错。但这恰恰是这套架构的优点,它强制我们构建对加载状态更鲁棒的 UI。
1.4. 本章核心速查总结
| 分类 | 关键项 | 核心描述 |
|---|---|---|
| 架构分层 | api -> queries -> router -> pages | (黄金范式) 清晰的职责分离:HTTP 请求 -> Query 配置 -> 路由预取 -> UI 消费。 |
| 路由预取策略 | queryClient.prefetchQuery(options) | (最佳实践) 在 loader 中非阻塞地预取数据,路由立即渲染,组件负责处理加载状态。 |
| 组件数据消费 | useQuery(options) | (单一来源) 组件通过此 Hook 从 TanStack Query 全局缓存中订阅数据。 |
| 核心状态 | isLoading | (首次加载) true 仅在 没有缓存数据 且正在请求时出现,通常用于展示骨架屏或全屏加载。 |
| 核心状态 | isFetching | (后台刷新) true 在任何请求进行中时都会出现,通常用于展示不打扰用户的刷新提示。 |
| 配置复用 | queryOptions 对象 | (代码复用) 将 queryKey 和 queryFn 封装在独立对象中,供 loader 和 useQuery 复用。 |
| 调试工具 | <ReactQueryDevtools /> | (开发必备) 可视化所有查询状态、缓存和生命周期的强大工具。 |
核心工作流总结:
- 定义 (Define): 在
queries目录下为数据源创建queryOptions。 - 预取 (Prefetch): 在
router的loader中调用queryClient.prefetchQuery()来触发后台加载。 - 消费 (Consume): 在页面组件中调用
useQuery()来获取数据并处理isLoading/isFetching状态。
第二章: 核心基石 useQuery:精通查询键与类型安全
摘要: 在上一章,我们成功实现了第一个数据查询流程。本章,我们将深入 useQuery 的心脏地带,解锁其全部潜力。您将掌握如何使用 动态查询键 来获取单个资源,并学习如何利用官方推荐的 queryOptions 辅助函数来构建 完全类型安全 的查询。最后,我们将探索强大的 select 选项,学习如何在不修改 API 的情况下,对数据进行高效的转换和派生。
2.1. 动态查询键:获取单个资源 (User Detail)
我们已经能获取用户列表,但如何获取单个用户的详情呢?例如 /users/1。这就需要用到动态查询键。
第一步:扩展 API 层
首先,在 api 层添加一个根据 ID 获取单个用户的函数。
文件路径: src/api/user.ts (添加新函数)
1 | import axiosInstance from "@/utils/server"; |
注意:在 queryFn 中,如果 queryKey 是 ['users', 1],TanStack Query v5 默认不再自动将 1 作为参数传给 queryFn。最现代和明确的做法是使用一个箭头函数来手动传递,我们将在下一步看到。
第二步:创建动态的 Query Options
为了让 Query Options 能够接收动态参数 (如 userId),我们必须将其从一个对象,重构为一个接收参数并返回配置的 函数。
文件路径: src/queries/userQueries.ts (修改)
1 | import { getUserById, getUsers } from "@/api/user"; |
第三步:添加详情页路由与 loader
文件路径: src/router/index.tsx (修改)
1 | import { createBrowserRouter } from 'react-router-dom'; |
第四步:创建用户详情页组件
文件路径: src/pages/UserDetail.tsx (新建)
1 | import { useQuery } from '@tanstack/react-query'; |
现在,在用户列表页添加 Link 组件,您就可以成功跳转并看到单个用户的详情了。
1 | import { useQuery } from "@tanstack/react-query"; |
2.2. queryOptions 助手:通往完全类型安全之路
痛点: 我们之前直接导出的 { queryKey: ..., queryFn: ... } 对象,虽然能工作,但 TanStack Query 无法从中完美推断出所有类型信息。例如,当你在其他地方使用 queryClient.getQueryData(['users']) 时,返回值的类型会是 unknown。
解决方案: 使用 queryOptions 辅助函数来创建我们的配置。它能将 queryKey、queryFn 的返回类型、error 类型等信息牢牢地绑定在一起。
重构 userQueries.ts
让我们用 queryOptions 来重构之前的代码。
文件路径: src/queries/userQueries.ts (最终版)
1 | import { queryOptions } from '@tanstack/react-query'; |
代码看起来几乎没变,只是多了个 queryOptions() 的包裹。这真的有那么大区别吗?
天壤之别。queryOptions 返回的不仅仅是一个普通对象,它是一个携带了“元数据”的、被类型系统深度理解的“智能”对象。
怎么理解这个“智能”?
现在,当你在任何地方使用 userQueryOptions(1) 时,TypeScript 不仅知道它的 queryKey 和 queryFn,更知道它的 queryFn 会返回 Promise<User>。这意味着,像 useQuery, prefetchQuery, getQueryData 这些函数,在接收这个对象后,都能 自动推断出 data 的类型是 User,error 的类型是 Error。你无需再手动添加泛型,实现了真正的端到端类型安全。
2.3. 数据转换:select 选项的妙用
场景: 假设我们有一个组件,只需要展示所有用户的姓名列表 (string[]),而不是完整的用户信息 (User[])。
错误的做法: 再创建一个新的 API 端点 /users/names。这会增加后端负担。
更好的做法: 复用已有的 /users 数据,在前端进行转换。
select 选项允许我们在数据从 queryFn 返回后,更新到缓存前,对其进行转换。
实战:创建一个只显示用户名的组件
文件路径: src/pages/Users.tsx (在文件底部添加新组件)

1 | // ... (保留 Users 组件的 import 和代码) |
关键洞察:
- 缓存优化: Devtools 会显示,我们自始至终只有一个
['users']查询实例。select不会 创建新的缓存条目。缓存中永远存储的是getUsers返回的原始、完整的数据 (User[])。 - 组件解耦:
select使得组件可以根据自己的需要,从同一个数据源派生出不同形态的数据,而无需关心原始数据的结构,实现了视图与数据的进一步解耦。
2.4. 本章核心速查总结
| 分类 | 关键项 | 核心描述 |
|---|---|---|
| 查询键 | 动态查询键 ['entity', id] | (核心) 用于标识和缓存单个资源。数组结构便于层级管理和部分失效。 |
| 类型安全 | queryOptions({...}) | (最佳实践) 官方推荐的辅助函数,用于创建携带完整类型信息的查询配置对象,实现端到端类型推断。 |
| 数据转换 | select: (data) => T | (性能优化) 在 useQuery 中使用,用于从原始数据派生出组件所需的数据形态,而不创建新的缓存。 |
| API 设计 | queryFn: () => myApi(param) | (推荐) queryFn 应该使用箭头函数来调用 API 层,明确地传递参数,而不是依赖隐式传参。 |
第三章: 数据变更:从 useMutation 到自定义 Hook
摘要: 掌握了数据查询后,我们进入硬币的另一面:数据变更。本章将遵循一个 从基础到架构 的渐进式学习路径。我们将首先深入 useMutation Hook 的核心 API,并完成一个基础版的“添加用户”功能。然后,我们会直面这种基础模式带来的 架构痛点,并最终学习如何通过 自定义 Hook 将其重构为企业级的、高内聚低耦合的最佳实践,为下一章的“乐观更新”打下坚实的基础。
3.1. 核心 API 解析:useMutation
useMutation 是 TanStack Query 提供的、专门用于执行数据创建 (Create)、更新 (Update) 和删除 (Delete) 等“写”操作的 Hook。它为我们封装了变更过程中的所有状态,如 isPending (是否进行中)、isSuccess (是否成功)等。
useMutation 的基本结构
useMutation 接收一个配置对象作为参数,并返回一个包含了触发函数和状态的对象。
1 | const { |
关键配置选项 (Options)
| 选项 | 类型 | 核心描述 |
|---|---|---|
mutationFn | (variables) => Promise<TData> | (必需) 一个执行异步任务并返回 Promise 的函数。它接收 mutate 函数传递的变量。这是实际执行 API 调用的地方。 |
onSuccess | (data, variables, context) => void | (成功回调) 当 mutationFn 成功解析 (resolve) 后触发。data 是 mutationFn 返回的结果。这是执行“缓存失效”等副作用的最佳时机。 |
onError | (error, variables, context) => void | (失败回调) 当 mutationFn 被拒绝 (reject) 后触发。error 是抛出的错误对象。用于处理错误提示、日志上报等。 |
3.2. 基础实战:在组件中直接使用 useMutation
为了理解 useMutation 的工作流程,我们先采用最直接的方式,在组件内部实现“创建新用户”功能。
第一步:扩展 API 层
文件路径: src/api/user.ts (添加新函数)
1 | import axiosInstance from "@/utils/server"; |
第二步:创建 AddUserForm 组件
文件路径: src/components/AddUserForm.tsx (新建文件夹和文件)
1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; |
第三步:集成到页面
文件路径: src/pages/Users.tsx (修改)
1 | import AddUserForm from '@/components/AddUserForm'; |
阶段性总结: 功能实现了,代码也“能跑”。但是,这种将所有逻辑都堆在组件里的方式,隐藏着巨大的架构隐患。
3.3. 痛点分析:为什么组件内的 useMutation 是“坏味道”?
我们刚才创建的 AddUserForm 组件,虽然能工作,但它已经违反了软件工程中最重要的 单一职责原则。
组件现在的职责:
- ✅ 渲染表单 UI (
<Form>,<Input>,<Button>) - ❌ 调用
useMutation,配置数据变更逻辑 - ❌ 处理 API 成功后的副作用 (
message.success,form.resetFields) - ❌ 管理缓存 (
queryClient.invalidateQueries) - ❌ 处理 API 失败后的副作用 (
message.error)
一个组件承担了 UI、状态管理、API 通信、缓存控制等多重职责,变得 臃肿 且 混乱。
场景: 假设现在产品经理要求,在应用的另一个“快速入口”弹窗中,也要能添加用户。
我们该怎么办?
- 选项 A: 复制粘贴。将
AddUserForm.tsx的useMutation逻辑几乎原封不动地复制到新的弹窗组件中。这会导致代码冗余,未来任何修改都需要同步改动两处。 - 选项 B: 逻辑提升。将
useMutation提升到父组件,再通过 props 传递mutate和isPending。这会让父组件变得臃肿,并且 props drilling (属性逐层传递) 问题会很快出现。
两种选择都不理想。
场景: 我们想为“添加用户”的逻辑编写单元测试。
挑战:
- 我们必须渲染整个
AddUserForm组件。 - 我们需要模拟 (
mock)useMutation,useQueryClient,AntdApp.useApp等多个 Hook。 - 测试代码会变得非常复杂,与 UI 强耦合。我们实际上只想测试“调用
createUser成功后,invalidateQueries是否被调用”这样的纯逻辑。
3.4. 解决方案:封装到自定义 Hook
核心思想: 将所有与特定业务动作相关的非 UI 逻辑,都从组件中抽离出来,封装到一个可复用的自定义 Hook 中。
第一步:创建 useUserMutations Hook
文件路径: src/hooks/useUserMutations.ts (新建文件夹和文件)
1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; |
这个 Hook 现在是一个 高内聚 的逻辑单元。它只关心“如何添加用户”,不关心 UI 长什么样。
第二步:重构 AddUserForm 组件
现在,AddUserForm 可以回归它的本职工作:渲染 UI 和处理用户交互。
文件路径: src/components/AddUserForm.tsx (修改)
1 | import { useAddUser } from "@/hooks/useUserMutations"; // 👈 导入我们创建的 Hook |
现在,我们的组件变得 轻量、纯粹、易于理解和测试。
3.5. 深入 invalidateQueries 与乐观更新的引出
invalidateQueries 是我们实现数据同步的核心武器。
核心哲学: 相信服务器是唯一的事实来源。我们不手动修改前端缓存来“假装”数据已更新,而是通知 TanStack Query:“嘿,这份数据可能旧了,你去服务器问问最新的情况吧!”
精确控制失效范围
invalidateQueries 非常智能,它可以进行模糊或精确匹配。
1 | const queryClient = useQueryClient(); |
invalidateQueries 的模式虽然健壮,但用户在提交后,列表仍然会有一个短暂的 fetching 刷新过程。有没有办法让这个过程也消失,达到“零延迟”的极致体验呢?这,就是 乐观更新 将在下一章解决的问题。
3.6. 本章核心速查总结
| 分类 | 关键项 | 核心描述 |
|---|---|---|
| 架构模式 | 自定义 Hook | (最佳实践) 将 useMutation 逻辑封装起来,实现业务逻辑与 UI 的彻底解耦和复用。 |
| 核心 Hook | useMutation(options) | (数据变更) 用于执行创建、更新、删除等异步操作,并管理其状态。 |
| 状态同步 | queryClient.invalidateQueries() | (黄金实践) 声明式地让缓存失效,触发自动后台刷新,保证数据最终一致性。 |
第四章: 终极用户体验:乐观更新
摘要: 在上一章,我们通过自定义 Hook 实现了优雅的数据变更。但 invalidateQueries 带来的“刷新感”仍有优化空间。本章,我们将挑战 TanStack Query 的终极用户体验模式——乐观更新。我们首先剖析其解决的痛点,然后学习其核心心智模型,最后通过分步实战,将我们的 useAddUser Hook 升级为具备“零延迟”交互能力的强大工具。
4.1. 我们为什么需要乐观更新?
痛点背景: 我们在第三章实现的 useAddUser Hook 已经非常健壮了。用户点击“添加”,数据被发送到后端,成功后通过 invalidateQueries 刷新列表。但在这个流程中,一个微小但真实的用户体验瑕疵依然存在:
- 用户点击【添加用户】按钮,
isPending变为true。 - 按钮进入
loading状态,用户等待… - 请求成功,
onSuccess触发invalidateQueries。 useQuery监听到缓存失效,isFetching变为true,用户列表右上角出现“刷新中…”的提示。- 列表数据更新,UI 最终稳定下来。
整个过程可能会有 500ms 到 1s 甚至更长的延迟和视觉上的“闪烁”。对于用户而言,这是一个 被动等待服务器确认 的过程。我们能否让用户感觉操作是 立即完成 的呢?
4.2. 乐观更新的技术实现与流程
解决方案: 乐观更新
乐观更新是一种客户端 UI 优化策略,旨在通过消除等待服务器响应的延迟来提升用户体验。其核心原理是:在发起数据变更请求后,不等待服务器确认,而是立即在前端修改 UI 和本地缓存,以模拟操作成功的状态。如果后续操作失败,系统将状态回滚至变更前的版本;无论成功与否,最终都会与服务器的权威数据进行同步。
乐观更新执行流程图
以下流程图展示了使用 useMutation 进行乐观更新时,各个回调函数的执行顺序与逻辑分支:
1 | [ 用户交互: 触发 useMutation ] |
乐观更新的生命周期回调
TanStack Query 通过 useMutation 钩子提供的三个关键回调函数,为实现上述流程提供了完整的支持。
onMutate
此函数在mutationFn(实际的异步请求函数) 执行之前 同步触发。它是乐观更新的起点,负责所有预处理工作,以确保 UI 的即时响应和后续操作的安全性。onError
当mutationFn执行失败(例如 Promise 被 reject,或抛出错误)时,此函数被调用。它的核心职责是处理异常,将 UI 状态恢复到操作之前的样子,确保用户看到的数据与实际状态一致。onSettled
此函数在mutationFn执行完成之后 触发,无论请求是成功还是失败。它标志着整个变更操作的结束,主要负责清理和最终的数据同步工作,确保客户端缓存与服务器的“事实来源”保持一致。
核心职责总结
下表详细说明了每个回调函数在乐观更新流程中的具体任务:
| 回调函数 | 执行时机 | 核心职责 |
|---|---|---|
onMutate | 在 mutationFn 执行前 | 1. 取消查询:防止旧数据覆盖乐观更新。 2. 创建数据快照:备份当前数据,用于失败时回滚。 3. 执行乐观更新:手动修改缓存,立即更新 UI。 |
onError | 在 mutationFn 失败时 | 1. 回滚数据:使用快照将缓存恢复到变更前的状态。 |
onSettled | 在 mutationFn 结束后(无论成败) | 1. 数据同步:使查询失效,触发后台重新获取,确保数据与服务器最终一致。 |
4.3. 分步升级 useAddUser Hook
现在,我们严格按照上述三步曲,一步步地为我们的 useAddUser Hook 增加乐观更新的能力。
第一步:在 onMutate 中执行预操作
这是最关键的一步。我们在这里执行取消、快照和手动更新。
文件路径: src/hooks/useUserMutations.ts (修改)
1 | // ... (保留 imports) |
第二步:在 onError 中实现回滚
如果 mutationFn 失败,我们需要使用 onMutate 返回的 context 来恢复数据。
文件路径: src/hooks/useUserMutations.ts (修改 onError)
1 | // ... (保留 imports 和 onMutate) |
第三步:在 onSettled 中确保最终一致性
无论乐观更新成功与否,我们最终都需要从服务器获取最准确的数据。
文件路径: src/hooks/useUserMutations.ts (添加 onSettled)
1 | // ... (保留 imports, onMutate, onError) |
第四步:调整 UI 组件以获得最佳体验
文件路径: src/components/AddUserForm.tsx (修改)
1 | import { useAddUser } from "@/hooks/useUserMutations"; |
现在,再次运行您的应用。当您点击添加按钮时,新用户会 瞬间 出现在列表中,表单也会被清空。您可以打开浏览器的网络限流工具,将网速调慢,更能体会到乐观更新带来的极致流畅感。
4.4. 本章核心速查总结
| 分类 | 关键项 | 核心描述 |
|---|---|---|
| 核心思想 | 乐观更新 | (用户体验) 假定操作成功,立即更新 UI,然后在后台与服务器同步,达到“零延迟”交互。 |
| 核心回调 | onMutate | (乐观更新入口) 在 mutationFn 执行前同步触发,是执行取消查询、缓存快照、手动更新 UI 的唯一场所。 |
| 关键 API | queryClient.cancelQueries() | (防止覆盖) 在 onMutate 中首先调用,用于取消正在进行的查询,避免其结果覆盖我们的乐观更新。 |
| 关键 API | queryClient.setQueryData() | (手动更新/回滚) 在 onMutate 中用于将新数据写入缓存;在 onError 中用于将旧数据写回缓存。 |
| 核心回调 | onError (带 context) | (回滚) 变更失败时,使用 onMutate 返回的 context (旧数据) 来回滚缓存。 |
| 核心回调 | onSettled | (最终同步) 无论成功或失败,都应调用 invalidateQueries 来确保数据与服务器最终一致。 |
第五章: 协同作战:TanStack Query 与 Zustand 的黄金搭档
摘要: 在前四章,我们专注于 服务器状态 的管理,并构建了一套优雅的查询与变更流程。本章,我们将激活架构图中的最后一块核心拼图:客户端状态 管理器 Zustand。我们将通过两个企业级的实战场景,深度剖析 TanStack Query 与 Zustand 如何协同工作:首先,学习如何用 客户端状态(Zustand) 来动态驱动 服务器状态查询(TanStack Query),以实现复杂的全局筛选功能;接着,我们将反向实践,学习如何在 服务器状态变更 成功后,广播更新 全局客户端状态,实现跨组件的即时 UI 同步。
5.1. 理念重申:专业工具,各司其职
在深入代码之前,让我们再次明确两大核心工具的职责边界。混淆它们的定位是导致状态管理混乱的根源。
| 状态管理器 | 核心职责 | 关键词 |
|---|---|---|
| TanStack Query | 服务器状态 (Server State) | 异步、缓存、后端同步、数据所有权在远端、可能过时 |
| Zustand | 客户端状态 (Client State) | 同步、全局共享、UI 状态、数据所有权在前端、私有 |
我们的目标,就是搭建一个系统,让这两个专家级的工具能在各自的领域内发挥最大效能,并通过清晰的接口进行通信。
5.2. 场景一:客户端状态驱动服务器查询 (全局筛选)
这是两者协同最常见、最重要的模式。
5.2.1 痛点分析:全局筛选的困境
业务需求: 我们需要在应用的主布局 Header 中,放置一个全局的用户状态筛选器(例如:所有用户、活跃用户)。当用户选择一个筛选条件后,下方的用户列表页需要根据这个条件重新获取并展示数据。

传统方案的挣扎:
- Props Drilling: 将筛选状态从顶层 App 组件,一层层地往下传递给
Users页面,如果层级很深,会造成“属性钻透地狱”。 - React Context: 相比 Props Drilling 稍好,但 Context 的性能问题(任何消费该 Context 的组件都会在状态变更时重新渲染)和创建 Provider、
useContext的模板代码,使其在管理简单状态时显得有些“重”。
这两种方案都远非理想。筛选器的状态,本质上是一个与具体页面无关的、全局共享的 客户端状态,这正是 Zustand 的用武之地。
5.2.2 架构设计:单向数据流
我们将构建一个清晰的单向数据流:
1 | 1. Filter 组件 (UI) |
5.2.3 第一步:扩展 API 层与类型定义
首先,我们根据新的需求更新 User 类型,并添加一个专门用于按状态获取用户的 API 函数。
文件路径: src/types/UserType.ts (修改)
1 | export interface User { |
文件路径: src/api/user.ts (添加新函数)
1 | // ... (保留 getUsers, getUserById, createUser 等) |
我们保留了 getUsers 函数,它将用于获取所有用户。
5.2.4 第二步:创建 filterSlice 并组合到 Root Store
这一步用于创建管理筛选器状态的 Zustand 切片,我们采用规范的切片模式来解耦
文件路径: src/stores/slices/filterSlice.ts (新建)
1 | import { type StateCreator } from "zustand"; |
文件路径: src/stores/useAppStore.ts (新建或修改)
1 | import { create } from 'zustand'; |
5.2.5 第三步:【核心】在 Query 层封装动态查询逻辑
queryFn 需要根据 userStatus 的值来决定调用哪个 API 函数 (getUsers 还是 getUsersByStatus)。将这个 if/else 逻辑放在组件中会破坏职责分离。
最佳实践 是将这个动态逻辑封装在 queries 层的 queryOptions 创建函数中。
文件路径: src/queries/userQueries.ts (修改)
1 | import { queryOptions } from '@tanstack/react-query'; |
通过这种方式,我们将 “如何根据状态获取数据” 的复杂逻辑从组件中彻底剥离,封装在了可复用、可测试的 queries 层。组件只需要关心 “我需要什么状态的数据”,而无需关心数据是如何获取的。
5.2.6 第四步:创建筛选器 UI 组件
我们可以在 Ui 层 UserFilter 组件消费我们所封装的逻辑
文件路径: src/components/UserFilter.tsx (新建)
1 | import { Radio, type RadioChangeEvent } from "antd"; |
5.2.7 第五步:最终改造 Users.tsx,实现优雅消费
现在,得益于 queries 层的封装,我们的页面组件变得极其简洁和清晰。
文件路径: src/pages/Users.tsx (最终版)
1 | import { useQuery } from "@tanstack/react-query"; |
我明白了!通过在 queries 层创建一个 usersByStatusQueryOptions 函数,我们把 if (status === 'all') 的判断逻辑彻底从组件中移除了。
正是如此。组件的职责回归纯粹:从 Zustand 获取 意图,将意图传递给 Query 层,然后渲染 Query 层返回的结果。组件本身不应该包含任何数据获取的实现细节。
这样做,如果未来筛选逻辑更复杂,比如增加一个按姓名搜索的输入框,我只需要修改 usersByStatusQueryOptions 函数,而 Users.tsx 组件可能一行代码都不用改。
这就是 关注点分离 带来的巨大威力。你的组件变得更加健壮、可预测,并且你的数据获取逻辑也拥有了独立的、可复用的单元。恭喜你,这正是企业级应用所推崇的架构模式。
5.3. 场景二:服务器状态变更后广播客户端状态
现在我们来看反向的通信:Mutation 成功后,如何通知全局 UI 进行更新。
5.3.1 业务场景与挑战
需求: 在 App.tsx 的 Header 中,有一个显示“当前用户总数”的徽标。当我们在 Users 页面成功添加一个新用户后,这个徽标的数字需要 立即、准确地 更新。
挑战: Header 组件和 AddUserForm 组件之间可能没有任何父子关系,它们是完全解耦的。我们如何实现这种跨组件通信?

5.3.2 架构思想:单一来源与衍生数据
要优雅地解决这个问题,我们首先要确立一个核心架构思想:
- 单一事实来源: 关于用户数据的唯一事实来源,是
GET /users这个 API 端点返回的用户列表。 - 衍生数据: “用户总数”这个信息,并非一个独立的、需要专门请求的数据。它本质上是从“用户列表”这个事实来源中 衍生 出来的。
- 声明式同步: 我们不应该手动去维护这个总数(比如
count++),而应该建立一个机制,当“用户列表”这个源头数据更新时,“用户总数”能够 自动、声明式地 更新。
TanStack Query 的 select 选项,正是实现这一点的完美工具。
5.3.3 实现步骤
第一步:在 Query 层创建衍生的“总数”查询
为了高效地获取用户总数而无需发起额外的网络请求,我们将基于已有的 usersQueryOptions,利用 select 创建一个只返回数量的衍生查询配置。
文件路径: src/queries/userQueries.ts
1 | import { queryOptions } from '@tanstack/react-query'; |
我们定义了一个新的查询配置 usersCountQueryOptions。当它被 useQuery 使用时,TanStack Query 会:
- 查找
queryKey: ['users']的缓存。 - 如果没有或已过时,则执行
queryFn: getUsers来获取完整的用户列表。 - 在返回数据给组件之前,执行
select函数,只将数组的长度 (data.length) 作为最终的data返回。这一切都发生在同一个查询实例上,没有额外的网络请求。
第二步:创建 statsSlice
我们的 Zustand 切片现在职责非常纯粹:仅负责存储从服务器同步过来的 userCount 这个客户端状态。
文件路径: src/stores/slices/statsSlice.ts
1 | import { type StateCreator } from "zustand"; |
第三步:组合 statsSlice 到 Root Store
文件路径: src/stores/useAppStore.ts
1 | import { create } from 'zustand'; |
第四步:定义 useAddUser 自定义 Hook
此 Hook 的职责是清晰地向 TanStack Query 声明“服务器状态已变更”,而不必关心具体的 UI 更新逻辑。
文件路径: src/hooks/useUserMutations.ts
1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; |
这个设计非常解耦!useAddUser Hook 完全不知道 Zustand 的存在,它的职责变得非常单一,就是和 TanStack Query 交互。
完全正确。高层逻辑(数据变更)不应该依赖于低层实现(具体的 UI 更新)。它只需要发出一个“数据已旧”的信号 (invalidateQueries),所有订阅了这个源头数据的部分,无论是用户列表还是用户总数,都会自动做出响应。
这样做,即便未来我们有更多衍生数据(比如“活跃用户数”),只要它们都源自 ['users'],这个 Hook 依然一行代码都不用改。
这就是声明式状态管理的魅力。你描述的是 “结果”(让数据失效),而不是 “过程”(手动去加一),系统会自动完成剩下的所有工作。
第五步:在 Header 中消费衍生查询与客户端状态
最后,我们在 App.tsx 组件中将所有部分连接起来。这里的 useEffect 扮演了关键的桥梁角色,负责在应用初始化时,将服务器状态同步到客户端状态存储中。
文件路径: src/App.tsx
1 | import { Layout, Badge } from "antd"; |
至此,我们构建了一个高效且健壮的系统。当新用户被添加时,useAddUser 只需让 ['users'] 缓存失效,App.tsx 中订阅总数的 useQuery 和 Users.tsx 中订阅列表的 useQuery 都会自动收到通知并以最高效的方式更新,最终通过 useEffect 将最准确的数据同步到 Zustand Store 中。
5.4. 本章核心速查总结
| 模式 | 核心思想 | 数据流向 | 关键代码 |
|---|---|---|---|
| 客户端驱动服务端 | 使用全局 UI 状态(如筛选器)来动态构建 queryKey,触发 TanStack Query 自动查询。 | UI -> Zustand -> queryKey -> TanStack Query | useQuery({ queryKey: ['entity', clientState] }) |
| 服务端驱动客户端 | 在 useMutation 的回调函数(onSuccess / onMutate)中,调用 Zustand 的 action 来更新全局 UI 状态。 | useMutation -> onSuccess -> Zustand Action -> UI | onSuccess: () => { invalidateQueries(); zustandAction(); } |
| 架构原则 | 职责分离 | TanStack Query 永远是与服务器交互的 唯一 接口。Zustand 负责管理所有与服务器无关的、纯粹的 UI 状态。 | 切片模式 (slices),自定义 Hooks (useAddUser) |
第六章: 处理海量数据:分页查询与无限加载
摘要: 在本章中,我们将从基础的 CRUD 迈向真实世界中最常见的数据交互场景:处理长列表。我们将首先攻克 分页查询,学习如何使用 TanStack Query 提供的 placeholderData 功能,解决页面切换时的“跳动”问题,实现如丝般顺滑的翻页体验。随后,我们将深入更高级的 无限加载 模式,精通 useInfiniteQuery Hook 的使用,构建出“加载更多”或“无限滚动”等现代化的 UI 交互。
6.1. 分页查询
渲染可分页的数据是一种极其常见的 UI 模式。在 TanStack Query 中,最直观的实现方式就是在查询键中包含页码信息。
6.1.1 痛点分析:恼人的 UI “跳动”
一个朴素的实现可能如下:
1 | const { data } = useQuery({ |
如果您运行这个例子,会发现当 page 改变时,queryKey 随之改变,TanStack Query 会将它视为一个全新的查询。这导致 UI 会在 success (成功) 和 pending (加载中) 状态之间来回跳动,页面内容会先消失,显示一个加载指示器,然后新内容再出现。这种体验并不理想。
6.1.2 解决方案:使用 placeholderData 保持数据暂留
TanStack Query 提供了一个绝佳的功能来解决这个问题:placeholderData。通过一个特殊的辅助函数 keepPreviousData,我们可以告诉 TanStack Query:
“当
queryKey改变时,请继续显示上一份成功获取的数据,直到新的数据成功返回。在这期间,请在后台静默地获取新数据。”
这会带来几个好处:
- 即使用户翻页(
queryKey改变),UI 也不会闪烁,旧数据会一直保留。 - 当新数据到达时,旧数据会被无缝地替换掉。
- 我们可以通过一个
isPlaceholderData的布尔值,来精确地知道当前显示的是否是“暂留”的旧数据。
6.1.3 第一步:准备分页 API (json-server)
幸运的是,json-server 内置了对 RESTful 分页的完美支持。我们只需要在请求时添加 _page 和 _limit 参数即可。
首先,为了让分页效果更明显,我们在 db.json 中添加更多数据。
文件路径: db.json (添加 projects 数组)
验证 API:
确保您的 pnpm run api 正在运行。然后可以在浏览器中访问 http://localhost:3001/projects?_page=1&_limit=5,您应该能看到前 5 个项目。json-server 甚至会在响应头中返回 X-Total-Count 来告知总条目数,非常方便。
6.1.4 第二步:扩展 API 与 Query 层
文件路径: src/api/project.ts (新建文件)
1 | import axiosInstance from "@/utils/server"; |
文件路径: src/queries/projectQueries.ts (新建文件)
1 | import { queryOptions, keepPreviousData } from '@tanstack/react-query'; |
6.1.5 第三步:创建分页 UI 组件
文件路径: src/pages/Projects.tsx (新建文件)
1 | import { useState } from "react"; |
6.1.6 第四步:添加路由
最后,在路由中添加新页面。
文件路径: src/router/index.tsx (修改)
1 | // ... 其他 imports |
现在,访问 /projects 页面并点击翻页按钮。您会发现列表内容无缝切换,没有任何加载状态的“跳动”,同时右上角的“刷新中…”指示器会准确地反映后台获取状态。这正是我们想要的流畅体验!

6.2. 无限加载
无限加载(或称“加载更多”、“无限滚动”)是另一种处理长列表的常见模式。useInfiniteQuery 是 TanStack Query 专为此场景设计的增强版 useQuery。
6.2.1 useInfiniteQuery 的核心区别
data对象结构不同:现在包含data.pages(一个包含所有已加载页面的数组) 和data.pageParams(获取这些页面所用的参数数组)。- 需要提供
initialPageParam:作为第一页的请求参数。 - 需要提供
getNextPageParam函数:这是无限加载的“引擎”,它根据最后一页的数据,来决定下一页的请求参数是什么。如果返回undefined,则表示没有更多数据了。 - 提供了
fetchNextPage函数:用于手动触发加载下一页。 - 提供了
hasNextPage布尔值:如果getNextPageParam能返回有效值,它就为true。 - 提供了
isFetchingNextPage布尔值:用于专门表示“正在加载下一页”的状态。
6.2.2 第一步:API 准备
我们的 getProjects API 函数已经能够接收 page 参数,完全可以复用于无限加载场景。我们将把 page 当作 cursor 来使用。
6.2.3 第二步:在 Query 层定义 infiniteQueryOptions
文件路径: src/queries/projectQueries.ts (添加新选项)
1 | import { |
这个 getNextPageParam 是关键!它接收三个参数:lastPage (最后一页的数据), allPages (所有已加载页面的数据数组), 和 lastPageParam (获取最后一页时用的参数)。
正是。你的核心逻辑就在这里:根据最后一页的数据(我们从中能知道 totalCount)和最后一个页面参数(lastPageParam,也就是当前页码),来计算出 下一页的参数。如果计算不出来(比如已经到了最后一页),就返回 undefined。
TanStack Query 会将这个函数返回的值,在下一次调用 fetchNextPage 时,作为 pageParam 传给我们的 queryFn。
完全正确。你已经掌握了无限查询的数据流转核心。
6.2.4 第三步:创建无限加载 UI 组件
文件路径: src/pages/InfiniteProjects.tsx (新建文件)
1 | import React from "react"; |
6.2.5 第四步:添加路由
文件路径: src/router/index.tsx (修改)
1 | // ... |
现在,访问 /infinite-projects 页面,您就可以通过点击“加载更多”按钮,不断地在列表下方追加新的数据,直到所有数据加载完毕。

6.3. 本章核心速查总结
| 分类 | 关键项 | 核心描述 |
|---|---|---|
| 分页查询 | placeholderData: keepPreviousData | (体验核心) 在 queryOptions 中设置,用于在翻页时保持旧数据暂留,防止 UI 跳动。 |
| 分页查询 | isPlaceholderData | (UI 状态) 从 useQuery 返回的布尔值,用于判断当前显示的是否为暂留数据,常用于禁用“下一页”按钮。 |
| 无限加载 | useInfiniteQuery | (核心 Hook) 专用于“加载更多”或“无限滚动”场景的 Hook。 |
| 无限加载 | initialPageParam | (必需配置) 定义第一页数据的请求参数。 |
| 无限加载 | getNextPageParam | (核心逻辑) 根据最后一页数据计算下一页的请求参数。返回 undefined 表示没有更多数据。 |
| 无限加载 | fetchNextPage() | (触发函数) 调用此函数来获取并追加下一页的数据。 |
| 无限加载 | data.pages | (数据结构) 包含所有已加载页面的数组,渲染时需要嵌套遍历。 |
第七章: 揭开 Query 的“魔法”面纱:精通缓存机制
摘要: 到目前为止,您已经领略了 TanStack Query 强大的自动化能力——它会在您切换窗口或重新联网时智能地刷新数据。本章,我们将深入这座“智能工厂”的“引擎室”,彻底揭开其自动化行为背后的秘密。您将精通控制缓存的两个核心概念:staleTime (数据保鲜期) 和 gcTime (垃圾回收时间),学会如何从全局和局部两个层面精准地调优应用的网络行为,将不必要的 API 请求降至最低。
7.1. 核心心智模型:把缓存想象成你的厨房储藏室
要理解缓存,我们先建立一个生动的类比:
- API 服务器: 城市里的大型超市。货品最全、最新鲜,是所有商品的“事实来源”。
- TanStack Query 缓存: 您家里的厨房储藏室。您从超市买回来的食材会先放在这里。
fetch数据: 开车去超市采购食材,并放入自家储藏室的过程。- 组件
useQuery: 您想做饭时,先去储藏室看看有没有现成的食材。
现在,两个最重要的概念登场了:
staleTime (数据“保鲜期”)
这就像食材包装袋上的 “最佳赏味期”。
staleTime未到期 (数据是fresh新鲜的):- 您去看储藏室,发现牛奶还在最佳赏味期内。您会非常自信地直接拿来用,根本不会考虑再去一趟超市。
- 对应 TanStack Query:只要数据是
fresh的,即便是组件重装、窗口聚焦,都不会触发新的网络请求。
staleTime已到期 (数据是stale陈旧的):- 您发现牛奶过了最佳赏味期。它 还能喝 (可以立即从储藏室取用),但您心里会嘀咕:“下次出门得去超市买瓶新的了。”
- 对应 TanStack Query:数据变为
stale后,它 依然会立刻从缓存中提供给 UI,保证页面不白屏。但与此同时,它会标记这个数据“该更新了”,在下一次窗口聚焦或组件挂载时,会在后台静默地发起一次网络请求 去获取最新数据。
staleTime 的默认值是 0,意味着数据一获取回来就立刻“过保”,所以 TanStack Query 默认会非常积极地进行后台刷新。
gcTime (缓存“垃圾回收”时间)
这就像您决定 “多久清理一次储藏室里用不上的东西”。
- 查询变得
inactive(不活跃):- 您做完了一道菜,暂时用不到番茄酱了。这瓶番茄酱就成了“不活跃”的食材。
- 对应 TanStack Query:当最后一个使用
['projects', 1]这个查询的组件卸载后,这个查询就进入了inactive状态。
gcTime倒计时开始:- 您心想:“这瓶番茄酱我先放着,如果 5 分钟内我还要做别的菜,还能用上。如果 5 分钟都没再用它,说明今天用不上了,就扔掉给储藏室腾地方。”
- 对应 TanStack Query:查询进入
inactive后,gcTime(默认 5 分钟) 的倒计时启动。如果在倒计时结束前,您再次访问需要这个查询的页面,它会立刻从缓存中恢复。如果倒计时结束,该查询及其数据将从缓存中被彻底删除。
| 配置项 | 核心职责 | 作用于… | 默认值 |
|---|---|---|---|
staleTime | 数据的新鲜度。决定了数据在多长时间内 无需 后台刷新。 | 活跃的 (active) 查询 | 0 ms |
gcTime | 缓存的生命周期。决定了不活跃的数据在多长时间后被 清除。 | 不活跃的 (inactive) 查询 | 5 分钟 |
所以,staleTime 和 gcTime 是两个完全独立的计时器?
完全正确,这是最关键的概念!staleTime 关心的是 “数据是否需要更新”,它作用于活跃的查询。而 gcTime 关心的是 “数据是否需要被遗忘”,它只作用于不活跃的查询。
也就是说,一个查询的数据可以同时是“陈旧的”(stale),但距离被“垃圾回收”(gc) 还很久?
绝佳的总结!这正是常态。比如 staleTime: 0 (默认值) 和 gcTime: 5 * 60 * 1000 (默认值)。数据一获取就“陈旧”了,所以下次聚焦会刷新,但你离开页面后,它还会在缓存里躺 5 分钟等你回来。
7.2. 实战一:配置全局默认值
对于一个应用来说,最佳实践是首先设定一个合理的全局缓存策略。
文件路径: src/lib/queryClient.ts (修改)
1 | import { QueryClient } from "@tanstack/react-query"; |
只需这几行代码,您的整个应用就变得不那么“激进”了。在用户访问一个页面后的 1 分钟内,即便是来回切换浏览器标签页,也不会再频繁地请求 API,显著降低了服务器压力。
7.3. 实战二:为特定查询覆盖配置
全局配置虽好,但不同数据的更新频率显然不同。比如,“项目列表”可能变化不频繁,而“实时通知”则需要更短的保鲜期。我们可以在 queryOptions 中轻松覆盖全局配置。
文件路径: src/queries/projectQueries.ts (修改)
1 | import { queryOptions, keepPreviousData } from '@tanstack/react-query'; |
如何验证?
- 启动应用并打开 React Query Devtools。
- 访问
/projects页面。您会看到['projects', { page: 1 }]这个查询实例。它的状态指示器会是 绿色 的,表示fresh。 - 等待,即使过了 1 分钟(我们设置的全局
staleTime),这个查询依然是绿色的。 - 直到 5 分钟后,它才会变为 蓝色,表示
stale。 - 在这 5 分钟内,反复切换浏览器标签页,您会发现 Devtools 中不会出现
isFetching状态,因为数据是新鲜的,无需刷新。
7.4. 实战三:控制自动刷新行为
除了基于时间的缓存策略,我们还可以直接控制 TanStack Query 的自动化行为。
refetchOnWindowFocus: 窗口重新获得焦点时是否自动刷新。refetchOnMount: 组件挂载时,如果数据是stale状态,是否自动刷新。refetchOnReconnect: 网络重新连接时是否自动刷新。
这些选项默认都为 true。但在某些场景下,比如一个包含复杂表单的页面,用户可能不希望在填写过程中因为切换窗口而导致数据意外刷新。此时我们可以禁用它。
全局禁用示例: src/lib/queryClient.ts
1 | export const queryClient = new QueryClient({ |
局部禁用示例: src/queries/projectQueries.ts
1 | export const projectsQueryOptions = (page: number) => |
7.5. 本章核心速查总结
| 配置项 | 核心职责 | 最佳实践与思考 |
|---|---|---|
staleTime | 数据保鲜期。决定数据在多长时间内无需后台刷新。 | - 全局设置一个合理的短时长(如 1-2 分钟)。 - 对几乎不变的数据(如配置、分类)设置一个很长的时长(如 1 小时)。 |
gcTime | 缓存生命周期。决定不活跃数据多久后被从内存中清除。 | - 通常保持默认的 5 分钟即可,它提供了很好的导航体验。 - 如果应用内存敏感,可以适当缩短。 |
refetchOn... | 行为开关。控制是否在特定事件(聚焦、挂载等)时自动刷新 stale 数据。 | - 大部分情况保持默认 true。- 在不希望用户操作被打断的页面(如复杂表单)可以局部设置为 false。 |
恭喜您!您已经完全掌握了 TanStack Query 的缓存核心。现在,您不仅能构建功能,更能随心所欲地优化应用的性能和用户体验。













