第十二章:TanStack Query 从入门到精通:吃透缓存策略、状态同步与服务端交互,构建高效数据管理系统


序章: 战略定位与工程实践:搭建实战环境

摘要: 在本章中,我们将首先从 战略层面 出发,剖析 React 生态中一个至关重要的概念:服务器状态客户端状态 的区别。接着,我们将鸟瞰整个 TanStack 生态,并阐明为何 TanStack Query 是我们解决服务器状态管理问题的“天选之子”。最后,我们将进入 工程实践 环节,手把手地搭建一个集成了所有现代化工具的、生产级的 React 开发环境,为您后续的学习扫清一切障碍。


0.1. 战略定位:为什么是 TanStack Query?

痛点背景: 作为一名经验丰富的 Vue 开发者,您已经习惯了使用 Pinia (或 Vuex) 来管理应用状态。但您可能也隐约感觉到,无论是 Pinia 还是它在 React 中的对等物 Zustand,它们在处理从后端 API 获取的数据时,都显得有些“笨拙”。我们通常需要自己编写这样的代码:

1
2
3
4
5
6
7
8
9
10
// 在 Zustand/Pinia store 中的典型异步 action
const fetchUsers = async () => {
set({ isLoading: true, error: null });
try {
const data = await api.get('/users');
set({ users: data, isLoading: false });
} catch (e) {
set({ error: e, isLoading: false });
}
};

这段代码背后,隐藏着一系列棘手的问题:

  • 我们何时应该重新获取数据?(例如用户切换回浏览器标签页时)
  • 如何处理数据缓存,避免不必要的网络请求?
  • 如何实现分页或无限滚动?
  • 如何在数据变更后(例如添加了一个新用户),智能地更新用户列表?

手动处理这些问题,会导致状态管理逻辑急剧膨胀且难以维护。这正是因为我们混淆了两种截然不同的状态。

核心概念:服务器状态 vs 客户端状态

定义: 存储在远程服务器上,我们通过网络请求来读取和修改的状态。我们并不直接拥有它,只能拥有它的“快照”。

  • 特点:
    • 异步性: 必须通过异步 API 获取和更新。
    • 所有权: 数据所有权在后端,前端数据随时可能“过时”。
    • 共享性: 可能被其他用户、其他设备修改。
  • 示例:
    • 用户列表
    • 文章详情
    • 商品库存

定义: 完全存在于前端应用内部,由用户交互或应用逻辑直接控制的状态。我们对其拥有完全的控制权。

  • 特点:
    • 同步性: 可以被同步地、直接地读取和修改。
    • 所有权: 数据所有权在前端。
    • 私有性: 通常只与当前用户会话相关。
  • 示例:
    • 暗黑模式的开关状态
    • 表单的输入内容
    • 一个 Modal 是否打开
技术选型深度对话
2025-10-07

所以,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. 黄金工作流:我们将遵循的开发范式

忘掉在 useEffectfetch 数据,也忘掉在组件中直接调用 Store 的异步 Action。我们将遵循一套更强大、更解耦的范式,这套范式将贯穿我们所有的后续章节,让代码保持极度的简洁和优雅。

数据读取 (Read) 的工作流

伪代码范式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 在路由层 (router.tsx): 定义“何时”获取
{
path: "/users",
// loader 的唯一职责,就是告诉 TanStack Query:“嘿,确保这个数据准备好!”
loader: () => queryClient.ensureQueryData(userListQueryOptions),
element: <UsersPage />,
}

// 2. 在组件层 (UsersPage.tsx): 消费数据
function UsersPage() {
// 组件不关心数据是怎么来的,它只管从 TanStack Query 的“缓存中心”取数据
const { data, isLoading } = useQuery(userListQueryOptions);
// ... render UI
}

数据变更 (Write) 的工作流

伪代码范式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 在组件层 (AddUserForm.tsx): 定义一个变更操作
function AddUserForm() {
const mutation = useMutation({
mutationFn: (newUser) => api.post('/users', newUser),
// 变更成功后,命令 TanStack Query:“与'users'相关的数据都过时了,去更新一下!”
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});

const handleSubmit = (data) => {
mutation.mutate(data);
};
// ... render form
}

Zustand 的角色:全局事件总线与 UI 状态中心

当一个组件(如 AddUserForm)的变更,需要通知另一个完全不相关的组件(如全局头部的 <Profile />)时,Zustand 就登场了。

1
2
3
4
5
6
7
// 在 useMutation 的 onSuccess 中
onSuccess: (newUser) => {
// 1. 命令 TanStack Query 更新服务器状态
queryClient.invalidateQueries({ queryKey: ['users'] });
// 2. 使用 Zustand 更新客户端的全局状态 (例如,更新用户名)
useUserStore.getState().setUser(newUser);
}
  1. loader 触发:路由 loader 是数据获取的唯一入口,它调用 TanStack Query。
  2. useQuery 消费:组件通过 useQuery 从 TanStack Query 缓存中读取数据。
  3. useMutation 变更:组件通过 useMutation 执行数据变更。
  4. invalidateQueries 同步:变更成功后,通过 invalidateQueries 实现服务器状态的自动同步。
  5. Zustand 广播:对于需要即时响应的跨组件 UI 更新,使用 Zustand 进行状态广播。

0.4. 工程实践:将架构落地为代码

现在,我们将上述架构思想,完整地落地到一个可运行的、结构优雅的启动器项目中。

第一步:初始化项目与安装核心依赖

1
2
3
4
5
6
7
8
9
# 1. 创建 Vite + React + TS 项目
pnpm create vite tanstack-query-practice --template react-ts
cd tanstack-query-practice

# 2. 一次性安装所有核心依赖
pnpm add antd react-router-dom @tanstack/react-query @tanstack/react-query-devtools zustand axios

# 3. 安装开发依赖 (锁定 json-server 版本以保证稳定性)
pnpm add -D tailwindcss@next @tailwindcss/vite json-server@0.17.4

说明: 我们将项目所需的所有“砖块”一次性准备好,后续步骤将专注于如何将它们“砌”成坚固的结构。

第二步:配置样式、路径别名与 TS

文件路径: vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'

export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

文件路径: src/index.css (清空后)

1
@import "tailwindcss";

文件路径: tsconfig.json (复制粘贴)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

关键一步: 配置完成后,请在 VSCode 中使用 Ctrl+Shift+P (或 Cmd+Shift+P),然后选择 TypeScript: Restart TS Server 以使路径别名生效。

第三步:搭建数据层 (api, types, utils) 与模拟 API

  1. 搭建模拟 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"
    },
  2. 创建类型定义
    文件路径: src/types/UserType.ts

    1
    2
    3
    4
    5
    export interface User {
    id: number;
    name: string;
    email: string;
    }
  3. 创建 Axios 实例 (Utils 层)
    文件路径: src/utils/server.ts

    1
    2
    3
    4
    5
    import axios from "axios";
    const axiosInstance = axios.create({
    baseURL: "http://localhost:3001",
    });
    export default axiosInstance;
  4. 创建 API 请求函数 (API 层)
    文件路径: src/api/user.ts

    1
    2
    3
    4
    5
    6
    7
    import 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,而 routerloader 又需要 main.tsx 导出的 queryClient,这会造成循环依赖。
解决方案: 将 queryClient 实例创建在一个独立的、无依赖的模块中。

文件路径: src/lib/queryClient.ts (新建文件夹和文件)

1
2
3
4
import { QueryClient } from "@tanstack/react-query";

// 创建 QueryClient 实例并导出,供应用中任何需要它的地方导入。
export const queryClient = new QueryClient();

第五步:配置应用入口 (main.tsx)

文件路径: src/main.tsx (完整重写)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { RouterProvider } from 'react-router-dom'
import router from '@/router'
import { ConfigProvider, App as AntdApp } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from '@/lib/queryClient' // 👈 从独立模块导入

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN}>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
)

第六步:创建空的页面、布局与路由

我们只搭建“空壳”,不填充任何业务逻辑。

文件路径: src/App.tsx (作为布局)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Layout } from 'antd';
import { Outlet, NavLink } from 'react-router-dom';

const { Header, Content } = Layout;

export default function App() {
return (
<Layout className="min-h-screen">
<Header>
<NavLink to="/users" className="text-white">用户列表</NavLink>
</Header>
<Layout>
<Content className="m-6 p-6 bg-white">
<Outlet />
</Content>
</Layout>
</Layout>
);
}

文件路径: src/pages/Home.tsxsrc/pages/Users.tsx (创建空壳)

1
2
3
4
5
6
// Home.tsx
import { Typography } from 'antd';
import { Link } from 'react-router-dom';
export default function Home() {
return <div><Typography.Title>首页</Typography.Title><Link to="/users">前往用户列表</Link></div>;
}
1
2
3
4
5
// Users.tsx
import { Typography } from 'antd';
export default function Users() {
return <Typography.Title> 用户列表页 </Typography.Title>;
}

文件路径: src/router/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createBrowserRouter } from "react-router-dom";
import App from "@/App";
import Home from "@/pages/Home";
import Users from "@/pages/User";

const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: "users", element: <Users /> },
],
},
]);

export default router;

第七步:验证与架构图

运行 pnpm run devpnpm run api,验证应用骨架可正常运行。至此,我们已经搭建了如下清晰的分层架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────┐
│ Pages (UI 层) │ ← (下一章) 使用 useQuery,展示数据
│ - Home.tsx, Users.tsx │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│ Queries 层 │ ← (下一章) 定义 queryKey 和 queryFn
│ - userQueries.ts │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│ API 层 │ ← (已完成) 封装 HTTP 请求
│ - user.ts │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│ Utils 层 │ ← (已完成) Axios 实例配置
│ - server.ts │
└─────────────────────────────────────┘

朋友,我们已经成功搭建好了环境,现在是时候迎接第一个“啊哈!”时刻了。我们将要学习的 useQuery 不仅仅是 fetch 的替代品,它是一种全新的、关于如何“看待”和“描述”异步数据的思维方式。

为何一个 isLoading 远远不够?

在我们过去的岁月里,一个简单的 isLoading 标志位似乎已经能解决所有问题。但让我们深入思考一下现代 Web 应用的真实场景,你会发现它的局限性:

  1. 首次加载 vs. 后台刷新:当用户第一次打开一个页面时,数据是完全没有的。此时,我们可能需要展示一个大面积的“骨架屏”(Skeleton)来优化体验,防止页面空白和抖动。但是,当用户已经看到了数据,我们只是在后台静默地刷新它(比如用户重新聚焦了浏览器窗口),你还想用那个粗暴的骨架屏去打扰用户吗?当然不。这时,一个微小、不引人注目的加载指示器(比如一个小 spinner)才是更优雅的选择。

  2. 职责混淆:用一个 isLoading 来同时表示“页面正在初始化”和“数据正在后台更新”,这在逻辑上是含糊不清的。作为追求卓越架构的“开拓者”,我们需要更精确的工具来描述这两种截然不同的 UI 状态。

手动维护 isloading 的模式让我们陷入了两难。我们当然可以手动创建 isInitialLoadingisRefetching 两个状态,但这会让我们的 Store 变得更加臃肿,模板代码也越来越多。我们需要的,是一种原生就理解并区分这些状态的机制。

第八步:深入理解 statusfetchStatus 的双核系统

TanStack Query 的优雅之处,在于它为我们提供了一套精确而强大的双状态系统来描述查询的生命周期。请记住这两个核心概念,它们是你理解后面一切高级功能的基石:

状态类型核心职责可能的值描述
status描述 “数据” 的状态pending无数据 并且正在进行首次请求。这是真正的“从零到一”阶段。
success请求成功,我们手中 有可用的数据 可以渲染。
error请求失败,我们手中没有数据,但 有错误信息
fetchStatus描述 “网络请求” 的状态fetchingqueryFn 正在执行中。无论是首次请求还是后台刷新,只要在请求,它就是 fetching
paused请求因网络断开等原因被暂停,它会在网络恢复后自动重试。
idle当前没有任何网络请求在进行。

这看起来可能有点复杂,但别担心。TanStack Query 已经为我们封装好了更易于使用的衍生布尔值。你日常打交道最多的将是它们:

衍生状态核心含义最佳 UI 场景
isLoading首次加载中(因为还没有数据)。骨架屏、整页加载动画
isFetching任何 网络请求正在进行中。细微的后台加载指示器、刷新按钮的 loading 状态
isError请求失败。错误提示信息。
isSuccess请求成功且有数据。渲染主要内容。
isRefetching后台刷新中(因为已有旧数据)。等同于 isFetching 的场景。

看到了吗?isLoading 只是 isFetching 在一种特殊情况(statuspending)下的表现。通过同时使用 isLoadingisFetching,我们就能完美地解决之前提出的 UI 状态区分难题。


第一章: 快速启动:非阻塞式数据预取与 useQuery

摘要: 在本章,我们将基于序章搭建的坚实架构,实现第一个完整的数据查询流程。我们将严格遵循 非阻塞式数据预取 (prefetchQuery) 的最佳实践,确保极致的用户体验。您将学习到如何优雅地串联起 Query 层、Router 层和 Component 层,并利用 Devtools 观察 isLoadingisFetching 的微妙差别。

1.1. 遵循范式:实现用户列表查询

第一步:创建 Query Options (Query 层)

这是连接 api 层与 component 层的桥梁,是代码复用的核心。

文件路径: src/queries/userQueries.ts (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
import { getUsers } from "@/api/user";

// 定义与用户列表查询相关的所有 TanStack Query 选项
export const usersQueryOptions = {
// queryKey 是此查询的唯一标识符,用于全局缓存。
queryKey: ['users'],

// queryFn 调用我们已封装好的 API 层函数。职责清晰。
queryFn: getUsers,
};

第二步:实现非阻塞式 Loader (Router 层)

在我们之前的路由章节中,我们知道 Loader 是通过 Promise.all 并行触发 所有这些数据,但这就会导致一个问题,当后端接口慢的时候,整个页面就连 loading 效果都加载不出来,因为页面的 dom 完全被 loader 接管,所以我们采用 prefetchQuery 来预取数据,它只触发请求,不阻塞 路由渲染

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { createBrowserRouter } from 'react-router-dom';
import App from '@/App';
import Home from '@/pages/Home';
import Users from '@/pages/Users';
import { queryClient } from '@/lib/queryClient';
import { usersQueryOptions } from '@/queries/userQueries';

const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{
path: 'users',
element: <Users />,
// ✅ 最佳实践:非阻塞式预加载
loader: () => {
// prefetchQuery 会在后台触发数据获取,但会立即返回一个 Promise
// 我们不 await 它,直接返回 null,让路由立即渲染组件。
queryClient.prefetchQuery(usersQueryOptions);
return null;
},
},
],
},
]);

export default router;

心智模型转变: loader 的作用从“必须准备好数据 才能进页面”转变为“通知 TanStack Query 开始准备数据,然后立即让路”。


第三步:消费数据并处理加载状态 (Component 层)

由于 loader 不再阻塞,组件自己需要负责处理加载状态,这提供了更好的用户体验。

文件路径: src/pages/Users.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { useQuery } from "@tanstack/react-query";
import { usersQueryOptions } from "@/queries/userQueries";
import { List, Typography, Alert, Spin } from "antd";

export default function Users() {
// 调用 useQuery hook,传入与 loader 中完全相同的 query options
// 由于使用了 defer + prefetchQuery,路由会立即渲染组件
// 此时数据可能还在加载中,isLoading 会正确反映这个状态
const {
data: users,
isLoading,
isError,
error,
isFetching,
} = useQuery(usersQueryOptions);

// 处理错误状态
if (isError) {
return (
<Alert
message="数据加载失败"
description={error.message}
type="error"
showIcon
/>
);
}

return (
<div>
<div className="flex items-center justify-between mb-4">
<Typography.Title level={2} className="!mb-0">
用户列表
</Typography.Title>
{/* isFetching 且不是 isLoading:表示后台刷新
触发场景:
1. 窗口重新获得焦点时(refetchOnWindowFocus)
2. 网络重新连接时(refetchOnReconnect)
3. 手动调用 refetch() 时
4. staleTime 过期后自动重新获取
5. 使用 invalidateQueries 使缓存失效后
*/}
{isFetching && !isLoading && <Spin size="small" tip="刷新中..." />}
</div>

<List
loading={isLoading}
bordered
dataSource={users}
renderItem={(user) => (
<List.Item>
<List.Item.Meta title={user.name} description={user.email} />
</List.Item>
)}
/>
</div>
);
}

第四步:验证黄金工作流

  1. 从首页点击“前往用户列表”。
  2. 您会 立即 跳转到 /users 页面,并看到“正在加载用户列表…”的 Spin 组件。
  3. 短暂延迟后,用户列表显示出来。
  4. 切换到其他浏览器标签页再切回来,您会看到右上角出现“刷新中…”的小 Spin,列表数据随后更新,且回退到上一页再重新进入 Users 页面,你会发现数据完全被缓存了,除非有新的后台数据刷新,否则不会有接口再次请求

img

这套流程完美地展示了我们的架构:路由立即响应,组件负责展示加载状态,数据在后台被智能地预取和刷新。

1.2. 黄金工作流总结

数据流向图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 用户点击链接 → 触发路由切换


2. Router Loader → 调用 prefetchQuery (不等待) → 立即渲染组件
│ │
│ (后台) │
│ ▼
├─> TanStack Query 开始获取数据 3. 页面组件挂载 → 显示 loading 状态 (isLoading: true)
│ │ │
│ ▼ │
└─> 数据到达缓存 <───────────────────────────────┘ 4. useQuery 自动从缓存获取数据,如果没有数据则调用queryfn获取


5. 组件更新 → 展示数据 (isLoading: false)

1.3. 高频面试题与陷阱

面试官深度追问
2025-08-23

你选择了 prefetchQuery 而不是 ensureQueryData,这两种方案在用户体验上有什么本质区别?

ensureQueryData阻塞式 的,它会暂停路由导航,直到数据获取成功。这会导致用户点击链接后,页面“卡住”一小段时间才跳转,是一种同步体验。

prefetchQuery非阻塞式 的,它允许路由立即导航,页面组件先渲染出来(通常展示一个加载状态),同时数据在后台获取。这是一种异步体验,页面的响应性更高,用户能更快地得到视觉反馈,整体体验更流畅。

那么 prefetchQuery 有没有什么潜在的“陷阱”?

唯一的“陷阱”可能是开发者忘记在组件中处理 isLoading 状态。因为页面会立即渲染,如果 useQuery 返回的 dataundefined 且没有处理 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 对象(代码复用)queryKeyqueryFn 封装在独立对象中,供 loaderuseQuery 复用。
调试工具<ReactQueryDevtools />(开发必备) 可视化所有查询状态、缓存和生命周期的强大工具。

核心工作流总结:

  1. 定义 (Define): 在 queries 目录下为数据源创建 queryOptions
  2. 预取 (Prefetch): 在 routerloader 中调用 queryClient.prefetchQuery() 来触发后台加载。
  3. 消费 (Consume): 在页面组件中调用 useQuery() 来获取数据并处理 isLoading / isFetching 状态。

第二章: 核心基石 useQuery:精通查询键与类型安全

摘要: 在上一章,我们成功实现了第一个数据查询流程。本章,我们将深入 useQuery 的心脏地带,解锁其全部潜力。您将掌握如何使用 动态查询键 来获取单个资源,并学习如何利用官方推荐的 queryOptions 辅助函数来构建 完全类型安全 的查询。最后,我们将探索强大的 select 选项,学习如何在不修改 API 的情况下,对数据进行高效的转换和派生。


2.1. 动态查询键:获取单个资源 (User Detail)

我们已经能获取用户列表,但如何获取单个用户的详情呢?例如 /users/1。这就需要用到动态查询键。

第一步:扩展 API 层

首先,在 api 层添加一个根据 ID 获取单个用户的函数。

文件路径: src/api/user.ts (添加新函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import 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;
};

// 👇 新增函数
// TanStack Query 会自动将 queryKey 数组的第二个元素作为参数传递给 queryFn
export const getUserById = async (id: number): Promise<User> => {
const response = await axiosInstance.get(`/users/${id}`);
return response.data;
}

注意:在 queryFn 中,如果 queryKey['users', 1],TanStack Query v5 默认不再自动将 1 作为参数传给 queryFn。最现代和明确的做法是使用一个箭头函数来手动传递,我们将在下一步看到。

第二步:创建动态的 Query Options

为了让 Query Options 能够接收动态参数 (如 userId),我们必须将其从一个对象,重构为一个接收参数并返回配置的 函数

文件路径: src/queries/userQueries.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { getUserById, getUsers } from "@/api/user";

export const usersQueryOptions = {
queryKey: ['users'],
queryFn: getUsers,
};

// 👇 新增一个接收 userId 的函数
export const userQueryOptions = (userId: number) => ({
// 查询键现在包含动态部分,以区分不同的用户
queryKey: ['users', userId],
// 使用箭头函数,将从 queryKey 中解构出的 id 传递给 API 函数
queryFn: () => getUserById(userId),
});

第三步:添加详情页路由与 loader

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { createBrowserRouter } from 'react-router-dom';
import App from '@/App';
import Home from '@/pages/Home';
import Users from '@/pages/Users';
import UserDetail from '@/pages/UserDetail'; // 👈 1. 导入新页面
import { queryClient } from '@/lib/queryClient';
import { usersQueryOptions, userQueryOptions } from '@/queries/userQueries'; // 👈 2. 导入新选项

const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{
path: 'users',
element: <Users />,
loader: () => {
queryClient.prefetchQuery(usersQueryOptions);
return null;
},
},
// 👇 3. 新增用户详情页路由
{
path: 'users/:userId',
element: <UserDetail />,
loader: ({ params }) => {
// 从路由参数中获取 userId
const userId = Number(params.userId);
// 使用动态的 query options 进行预取
queryClient.prefetchQuery(userQueryOptions(userId));
return { userId }; // 将 userId 传递给组件
}
}
],
},
]);

export default router;

第四步:创建用户详情页组件

文件路径: src/pages/UserDetail.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { userQueryOptions } from '@/queries/userQueries';
import { Card, Spin, Alert, Descriptions } from 'antd';

export default function UserDetail() {
const { userId } = useParams<{ userId: string }>();
const id = Number(userId);

const { data: user, isLoading, isError, error } = useQuery(userQueryOptions(id));

if (isLoading) {
return <Spin tip="加载用户详情中..." size="large" />;
}

if (isError) {
return <Alert message="加载失败" description={error.message} type="error" showIcon />;
}

return (
<Card title="用户详情">
<Descriptions bordered>
<Descriptions.Item label="ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="姓名">{user.name}</Descriptions.Item>
<Descriptions.Item label="邮箱">{user.email}</Descriptions.Item>
</Descriptions>
</Card>
);
}

现在,在用户列表页添加 Link 组件,您就可以成功跳转并看到单个用户的详情了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useQuery } from "@tanstack/react-query";
import { usersQueryOptions } from "@/queries/userQueries";
import { List, Typography, Alert, Spin } from "antd";
import { useNavigate } from "react-router-dom";
export default function Users() {
const navigate = useNavigate();
<List
loading={isLoading}
bordered
dataSource={users}
renderItem={(user) => (
<List.Item className="cursor-pointer hover:bg-gray-100" onClick={() => navigate(`/users/${user.id}`)}>
<List.Item.Meta title={user.name} description={user.email} />
</List.Item>
)}
/>
</div>
);
}


2.2. queryOptions 助手:通往完全类型安全之路

痛点: 我们之前直接导出的 { queryKey: ..., queryFn: ... } 对象,虽然能工作,但 TanStack Query 无法从中完美推断出所有类型信息。例如,当你在其他地方使用 queryClient.getQueryData(['users']) 时,返回值的类型会是 unknown

解决方案: 使用 queryOptions 辅助函数来创建我们的配置。它能将 queryKeyqueryFn 的返回类型、error 类型等信息牢牢地绑定在一起。

重构 userQueries.ts

让我们用 queryOptions 来重构之前的代码。

文件路径: src/queries/userQueries.ts (最终版)

1
2
3
4
5
6
7
8
9
10
11
12
13
import { queryOptions } from '@tanstack/react-query';
import { getUserById, getUsers } from "@/api/user";

// 使用 queryOptions 包裹,返回一个携带完整类型信息的对象
export const usersQueryOptions = queryOptions({
queryKey: ['users'],
queryFn: getUsers,
});

export const userQueryOptions = (userId: number) => queryOptions({
queryKey: ['users', userId],
queryFn: () => getUserById(userId),
});
技术深度对话
此刻

代码看起来几乎没变,只是多了个 queryOptions() 的包裹。这真的有那么大区别吗?

架构师

天壤之别。queryOptions 返回的不仅仅是一个普通对象,它是一个携带了“元数据”的、被类型系统深度理解的“智能”对象。

怎么理解这个“智能”?

架构师

现在,当你在任何地方使用 userQueryOptions(1) 时,TypeScript 不仅知道它的 queryKeyqueryFn,更知道它的 queryFn 会返回 Promise<User>。这意味着,像 useQuery, prefetchQuery, getQueryData 这些函数,在接收这个对象后,都能 自动推断出 data 的类型是 Usererror 的类型是 Error。你无需再手动添加泛型,实现了真正的端到端类型安全。


2.3. 数据转换:select 选项的妙用

场景: 假设我们有一个组件,只需要展示所有用户的姓名列表 (string[]),而不是完整的用户信息 (User[])。

错误的做法: 再创建一个新的 API 端点 /users/names。这会增加后端负担。
更好的做法: 复用已有的 /users 数据,在前端进行转换。

select 选项允许我们在数据从 queryFn 返回后,更新到缓存前,对其进行转换。

实战:创建一个只显示用户名的组件

文件路径: src/pages/Users.tsx (在文件底部添加新组件)

image-20251007114252117

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ... (保留 Users 组件的 import 和代码)

// 👇 新增一个组件
function UserNames() {
// 我们复用 usersQueryOptions,但添加了 select 选项
const { data: userNames } = useQuery({
...usersQueryOptions,
// `data` 参数是 `getUsers` 函数返回的完整的 `User[]` 数组
select: (data) => data.map(user => user.name),
});

// `userNames` 的类型被自动推断为 string[] | undefined

return (
<Card title="用户名列表 (select 转换)" className="mt-8">
<List
dataSource={userNames}
renderItem={(name) => <List.Item>{name}</List.Item>}
/>
</Card>
)
}

// 在 Users 组件的 return 中添加 <UserNames />
export default function Users() {
// ... (保留 Users 组件的现有逻辑)
return (
<div>
{/* ... (保留 Users 组件的 JSX) ... */}
<UserNames /> {/* 👈 在这里渲染新组件 */}
</div>
)
}

关键洞察:

  1. 缓存优化: Devtools 会显示,我们自始至终只有一个 ['users'] 查询实例。select 不会 创建新的缓存条目。缓存中永远存储的是 getUsers 返回的原始、完整的数据 (User[])。
  2. 组件解耦: 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
2
3
4
5
6
7
const { 
mutate, // 👈 触发变更的函数
isPending, // 👈 变更是否正在进行中
// ... 其他状态
} = useMutation({
// ... 配置选项
});

关键配置选项 (Options)

选项类型核心描述
mutationFn(variables) => Promise<TData>(必需) 一个执行异步任务并返回 Promise 的函数。它接收 mutate 函数传递的变量。这是实际执行 API 调用的地方。
onSuccess(data, variables, context) => void(成功回调)mutationFn 成功解析 (resolve) 后触发。datamutationFn 返回的结果。这是执行“缓存失效”等副作用的最佳时机。
onError(error, variables, context) => void(失败回调)mutationFn 被拒绝 (reject) 后触发。error 是抛出的错误对象。用于处理错误提示、日志上报等。

3.2. 基础实战:在组件中直接使用 useMutation

为了理解 useMutation 的工作流程,我们先采用最直接的方式,在组件内部实现“创建新用户”功能。

第一步:扩展 API 层

文件路径: src/api/user.ts (添加新函数)

1
2
3
4
5
6
7
import axiosInstance from "@/utils/server";
import type { User } from "@/types/UserType";
// ... (保留 getUsers)
export const createUser = async (newUser: Omit<User, 'id'>): Promise<User> => {
const response = await axiosInstance.post("/users", newUser);
return response.data;
};

第二步:创建 AddUserForm 组件

文件路径: src/components/AddUserForm.tsx (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUser } from "@/api/user";
import { usersQueryOptions } from "@/queries/userQueries";
import { Button, Form, Input, App as AntdApp } from "antd";

export default function AddUserForm() {
const { message } = AntdApp.useApp();
const queryClient = useQueryClient();
const [form] = Form.useForm();

const { mutate, isPending } = useMutation({
mutationFn: createUser,
onSuccess: () => {
message.success("新用户添加成功!");
form.resetFields();
queryClient.invalidateQueries(usersQueryOptions);
},
onError: (error) => { message.error(`添加失败: ${error.message}`); }
});

const onFinish = (values: { name: string; email: string }) => {
mutate(values);
};

return (
<Form form={form} onFinish={onFinish} layout="vertical" className="mb-8">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input placeholder="请输入用户名"/>
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="请输入邮箱"/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={isPending}>
{isPending ? "添加中..." : "添加用户"}
</Button>
</Form.Item>
</Form>
);
}

第三步:集成到页面

文件路径: src/pages/Users.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
import AddUserForm from '@/components/AddUserForm';
// ...
export default function Users() {
// ...
return (
<div>
<Typography.Title level={2} className="!mb-4"> 添加新用户 </Typography.Title>
<AddUserForm />
{/* ... 列表部分 */}
</div>
);
}

阶段性总结: 功能实现了,代码也“能跑”。但是,这种将所有逻辑都堆在组件里的方式,隐藏着巨大的架构隐患。


3.3. 痛点分析:为什么组件内的 useMutation 是“坏味道”?

我们刚才创建的 AddUserForm 组件,虽然能工作,但它已经违反了软件工程中最重要的 单一职责原则

组件现在的职责:

  • ✅ 渲染表单 UI (<Form>, <Input>, <Button>)
  • ❌ 调用 useMutation,配置数据变更逻辑
  • ❌ 处理 API 成功后的副作用 (message.success, form.resetFields)
  • ❌ 管理缓存 (queryClient.invalidateQueries)
  • ❌ 处理 API 失败后的副作用 (message.error)

一个组件承担了 UI、状态管理、API 通信、缓存控制等多重职责,变得 臃肿混乱

场景: 假设现在产品经理要求,在应用的另一个“快速入口”弹窗中,也要能添加用户。

我们该怎么办?

  • 选项 A: 复制粘贴。将 AddUserForm.tsxuseMutation 逻辑几乎原封不动地复制到新的弹窗组件中。这会导致代码冗余,未来任何修改都需要同步改动两处。
  • 选项 B: 逻辑提升。将 useMutation 提升到父组件,再通过 props 传递 mutateisPending。这会让父组件变得臃肿,并且 props drilling (属性逐层传递) 问题会很快出现。

两种选择都不理想。

场景: 我们想为“添加用户”的逻辑编写单元测试。

挑战:

  • 我们必须渲染整个 AddUserForm 组件。
  • 我们需要模拟 (mock) useMutation, useQueryClient, AntdApp.useApp 等多个 Hook。
  • 测试代码会变得非常复杂,与 UI 强耦合。我们实际上只想测试“调用 createUser 成功后,invalidateQueries 是否被调用”这样的纯逻辑。

3.4. 解决方案:封装到自定义 Hook

核心思想: 将所有与特定业务动作相关的非 UI 逻辑,都从组件中抽离出来,封装到一个可复用的自定义 Hook 中。

第一步:创建 useUserMutations Hook

文件路径: src/hooks/useUserMutations.ts (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUser } from "@/api/user";
import { usersQueryOptions } from "@/queries/userQueries";
import { App as AntdApp } from "antd";

// 这个 Hook 封装了所有与“添加用户”相关的逻辑
export const useAddUser = () => {
const { message } = AntdApp.useApp();
const queryClient = useQueryClient();

return useMutation({
mutationFn: createUser,
onSuccess: () => {
message.success("新用户添加成功!");
return queryClient.invalidateQueries(usersQueryOptions);
},
onError: (error) => {
message.error(`添加失败: ${error.message}`);
},
});
};

这个 Hook 现在是一个 高内聚 的逻辑单元。它只关心“如何添加用户”,不关心 UI 长什么样。

第二步:重构 AddUserForm 组件

现在,AddUserForm 可以回归它的本职工作:渲染 UI 和处理用户交互。

文件路径: src/components/AddUserForm.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useAddUser } from "@/hooks/useUserMutations"; // 👈 导入我们创建的 Hook
import { Button, Form, Input } from "antd";
import { useEffect } from "react";

export default function AddUserForm() {
const [form] = Form.useForm();

// ✨ 一行代码,就获得了所有数据变更的逻辑和状态
const { mutate, isPending, isSuccess } = useAddUser();

const onFinish = (values: { name: string; email: string }) => {
mutate(values);
};

// 监听 isSuccess 状态,用于清空表单
useEffect(() => {
if (isSuccess) {
form.resetFields();
}
}, [isSuccess, form]);

return (
// ... 表单 JSX 保持不变 ...
<Form form={form} onFinish={onFinish} layout="vertical" className="mb-8">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, type: "email" }]}
>
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={isPending}>
{isPending ? "添加中..." : "添加用户"}
</Button>
</Form.Item>
</Form>
);
}

现在,我们的组件变得 轻量、纯粹、易于理解和测试


3.5. 深入 invalidateQueries 与乐观更新的引出

invalidateQueries 是我们实现数据同步的核心武器。

核心哲学: 相信服务器是唯一的事实来源。我们不手动修改前端缓存来“假装”数据已更新,而是通知 TanStack Query:“嘿,这份数据可能旧了,你去服务器问问最新的情况吧!”

精确控制失效范围

invalidateQueries 非常智能,它可以进行模糊或精确匹配。

1
2
3
4
5
6
7
8
const queryClient = useQueryClient();

// 1. 模糊匹配:让所有以 ['users'] 开头的查询都失效
// 这会同时刷新列表页 (['users']) 和所有详情页 (['users', 1], ['users', 2]...)
queryClient.invalidateQueries({ queryKey: ['users'] });

// 2. 精确匹配:只让用户列表页的查询失效,不影响详情页
queryClient.invalidateQueries({ queryKey: ['users'], exact: true });

invalidateQueries 的模式虽然健壮,但用户在提交后,列表仍然会有一个短暂的 fetching 刷新过程。有没有办法让这个过程也消失,达到“零延迟”的极致体验呢?这,就是 乐观更新 将在下一章解决的问题。


3.6. 本章核心速查总结

分类关键项核心描述
架构模式自定义 Hook(最佳实践)useMutation 逻辑封装起来,实现业务逻辑与 UI 的彻底解耦和复用。
核心 HookuseMutation(options)(数据变更) 用于执行创建、更新、删除等异步操作,并管理其状态。
状态同步queryClient.invalidateQueries()(黄金实践) 声明式地让缓存失效,触发自动后台刷新,保证数据最终一致性。

第四章: 终极用户体验:乐观更新

摘要: 在上一章,我们通过自定义 Hook 实现了优雅的数据变更。但 invalidateQueries 带来的“刷新感”仍有优化空间。本章,我们将挑战 TanStack Query 的终极用户体验模式——乐观更新。我们首先剖析其解决的痛点,然后学习其核心心智模型,最后通过分步实战,将我们的 useAddUser Hook 升级为具备“零延迟”交互能力的强大工具。


4.1. 我们为什么需要乐观更新?

痛点背景: 我们在第三章实现的 useAddUser Hook 已经非常健壮了。用户点击“添加”,数据被发送到后端,成功后通过 invalidateQueries 刷新列表。但在这个流程中,一个微小但真实的用户体验瑕疵依然存在:

  1. 用户点击【添加用户】按钮,isPending 变为 true
  2. 按钮进入 loading 状态,用户等待…
  3. 请求成功,onSuccess 触发 invalidateQueries
  4. useQuery 监听到缓存失效,isFetching 变为 true,用户列表右上角出现“刷新中…”的提示。
  5. 列表数据更新,UI 最终稳定下来。

整个过程可能会有 500ms 到 1s 甚至更长的延迟和视觉上的“闪烁”。对于用户而言,这是一个 被动等待服务器确认 的过程。我们能否让用户感觉操作是 立即完成 的呢?


4.2. 乐观更新的技术实现与流程

解决方案: 乐观更新

乐观更新是一种客户端 UI 优化策略,旨在通过消除等待服务器响应的延迟来提升用户体验。其核心原理是:在发起数据变更请求后,不等待服务器确认,而是立即在前端修改 UI 和本地缓存,以模拟操作成功的状态。如果后续操作失败,系统将状态回滚至变更前的版本;无论成功与否,最终都会与服务器的权威数据进行同步。

乐观更新执行流程图

以下流程图展示了使用 useMutation 进行乐观更新时,各个回调函数的执行顺序与逻辑分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[ 用户交互: 触发 useMutation ]
|
v
[ 1. onMutate 执行 ]
(取消已有查询, 备份当前数据, 手动更新缓存)
|
v
[ 2. mutationFn 执行 (发起 API 请求) ]
|
/-------------------\
| |
v v
[ 请求成功 ] [ 请求失败 ]
| |
| v
| [ 3. onError 执行 ]
| (使用备份数据回滚缓存)
| |
\---------+---------/
|
v
[ 4. onSettled 执行 ]
(无论成败, 都使查询失效, 与服务器最终同步)

乐观更新的生命周期回调

TanStack Query 通过 useMutation 钩子提供的三个关键回调函数,为实现上述流程提供了完整的支持。

  1. onMutate
    此函数在 mutationFn (实际的异步请求函数) 执行之前 同步触发。它是乐观更新的起点,负责所有预处理工作,以确保 UI 的即时响应和后续操作的安全性。

  2. onError
    mutationFn 执行失败(例如 Promise 被 reject,或抛出错误)时,此函数被调用。它的核心职责是处理异常,将 UI 状态恢复到操作之前的样子,确保用户看到的数据与实际状态一致。

  3. onSettled
    此函数在 mutationFn 执行完成之后 触发,无论请求是成功还是失败。它标志着整个变更操作的结束,主要负责清理和最终的数据同步工作,确保客户端缓存与服务器的“事实来源”保持一致。

核心职责总结
下表详细说明了每个回调函数在乐观更新流程中的具体任务:

回调函数执行时机核心职责
onMutatemutationFn 执行前1. 取消查询:防止旧数据覆盖乐观更新。
2. 创建数据快照:备份当前数据,用于失败时回滚。
3. 执行乐观更新:手动修改缓存,立即更新 UI。
onErrormutationFn 失败时1. 回滚数据:使用快照将缓存恢复到变更前的状态。
onSettledmutationFn 结束后
(无论成败)
1. 数据同步:使查询失效,触发后台重新获取,确保数据与服务器最终一致。

4.3. 分步升级 useAddUser Hook

现在,我们严格按照上述三步曲,一步步地为我们的 useAddUser Hook 增加乐观更新的能力。

第一步:在 onMutate 中执行预操作

这是最关键的一步。我们在这里执行取消、快照和手动更新。

文件路径: src/hooks/useUserMutations.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ... (保留 imports)

export const useAddUser = () => {
const { message } = AntdApp.useApp();
const queryClient = useQueryClient();

return useMutation({
mutationFn: (newUser: Omit<User, 'id'>) => createUser(newUser),

// ✨ 第一步:实现 onMutate
onMutate: async (newUser) => {
// a. 取消任何可能覆盖我们乐观更新的、正在进行的'users'查询
await queryClient.cancelQueries(usersQueryOptions);

// b. 获取当前缓存数据的快照,以便出错时回滚
const previousUsers = queryClient.getQueryData<User[]>(usersQueryOptions.queryKey);

// c. 乐观地、手动地更新 UI 缓存
queryClient.setQueryData<User[]>(usersQueryOptions.queryKey, (oldUsers = []) => [
...oldUsers,
{
...newUser,
id: Date.now(), // 临时生成一个唯一的 ID,用于 React 的 key
},
]);

// d. 返回一个包含旧数据的上下文对象,这个对象会被传递给 onError 和 onSettled
return { previousUsers };
},

// ... (保留 onError 和 onSuccess 的基础实现)
});
};

第二步:在 onError 中实现回滚

如果 mutationFn 失败,我们需要使用 onMutate 返回的 context 来恢复数据。

文件路径: src/hooks/useUserMutations.ts (修改 onError)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ... (保留 imports 和 onMutate)
// ...
// ✨ 第二步:实现 onError 回滚
onError: (error, _variables, context) => {
message.error(`添加失败: ${error.message}`);
// 检查 onMutate 是否返回了上下文(即旧数据快照)
if (context?.previousUsers) {
// 如果有,则使用快照数据覆盖缓存,实现回滚
queryClient.setQueryData(usersQueryOptions.queryKey, context.previousUsers);
message.info("数据已回滚至先前状态。");
}
},
// ...

第三步:在 onSettled 中确保最终一致性

无论乐观更新成功与否,我们最终都需要从服务器获取最准确的数据。

文件路径: src/hooks/useUserMutations.ts (添加 onSettled)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... (保留 imports, onMutate, onError)
// ...
// ✨ 第三步:实现 onSettled 最终同步
onSettled: () => {
// 无论成功还是失败,最终都要重新获取 'users' 查询,
// 以确保前端数据与服务器的“事实来源”完全一致。
queryClient.invalidateQueries(usersQueryOptions);
},

onSuccess: () => {
// onSuccess 的职责现在可以简化,因为它可能在 UI 更新后才执行
message.success("新用户已成功同步至服务器!");
},
//...

第四步:调整 UI 组件以获得最佳体验

文件路径: src/components/AddUserForm.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useAddUser } from "@/hooks/useUserMutations";
// ... (其他 imports)

export default function AddUserForm() {
const [form] = Form.useForm();

// Hook 的调用方式完全不变
const { mutate, isPending } = useAddUser();

const onFinish = (values: { name: string; email: string }) => {
// 为了达到最佳体验,我们在调用 mutate 的同时立即重置表单
// 因为UI是乐观更新的,用户感觉不到延迟
form.resetFields();
mutate(values);
};

// 不再需要 useEffect 来监听 isSuccess

return (
<Form form={form} onFinish={onFinish} layout="vertical" className="mb-8">
{/* ... Form.Items ... */}
<Form.Item>
<Button type="primary" htmlType="submit" loading={isPending}>
{isPending ? "处理中..." : "添加用户 (乐观更新)"}
</Button>
</Form.Item>
</Form>
);
}

现在,再次运行您的应用。当您点击添加按钮时,新用户会 瞬间 出现在列表中,表单也会被清空。您可以打开浏览器的网络限流工具,将网速调慢,更能体会到乐观更新带来的极致流畅感。


4.4. 本章核心速查总结

分类关键项核心描述
核心思想乐观更新(用户体验) 假定操作成功,立即更新 UI,然后在后台与服务器同步,达到“零延迟”交互。
核心回调onMutate(乐观更新入口)mutationFn 执行前同步触发,是执行取消查询、缓存快照、手动更新 UI 的唯一场所。
关键 APIqueryClient.cancelQueries()(防止覆盖)onMutate 中首先调用,用于取消正在进行的查询,避免其结果覆盖我们的乐观更新。
关键 APIqueryClient.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 中,放置一个全局的用户状态筛选器(例如:所有用户、活跃用户)。当用户选择一个筛选条件后,下方的用户列表页需要根据这个条件重新获取并展示数据。

img

传统方案的挣扎:

  • Props Drilling: 将筛选状态从顶层 App 组件,一层层地往下传递给 Users 页面,如果层级很深,会造成“属性钻透地狱”。
  • React Context: 相比 Props Drilling 稍好,但 Context 的性能问题(任何消费该 Context 的组件都会在状态变更时重新渲染)和创建 Provider、useContext 的模板代码,使其在管理简单状态时显得有些“重”。

这两种方案都远非理想。筛选器的状态,本质上是一个与具体页面无关的、全局共享的 客户端状态,这正是 Zustand 的用武之地。

5.2.2 架构设计:单向数据流

我们将构建一个清晰的单向数据流:

1
2
3
4
5
6
7
8
9
10
1. Filter 组件 (UI)
│ (用户交互)

2. Zustand Store (更新客户端状态)
│ (状态变更)

3. Users 页面 (订阅 Store)
│ (触发 queryKey 变化)

4. TanStack Query (自动重新获取服务器状态)

5.2.3 第一步:扩展 API 层与类型定义

首先,我们根据新的需求更新 User 类型,并添加一个专门用于按状态获取用户的 API 函数。

文件路径: src/types/UserType.ts (修改)

1
2
3
4
5
6
export interface User {
id?: number;
name: string;
email: string;
status?: "active" | "inactive"; // 👈 新增 status 字段
}

文件路径: src/api/user.ts (添加新函数)

1
2
3
4
5
6
7
8
9
10
// ... (保留 getUsers, getUserById, createUser 等)
import type { UserStatus } from "@/stores/slices/filterSlice";

// 专门用于获取特定状态用户的函数
export const getUsersByStatus = async (
status: "active" | "inactive"
): Promise<User[]> => {
const response = await axiosInstance.get(`/users?status=${status}`);
return response.data;
};

我们保留了 getUsers 函数,它将用于获取所有用户。

5.2.4 第二步:创建 filterSlice 并组合到 Root Store

这一步用于创建管理筛选器状态的 Zustand 切片,我们采用规范的切片模式来解耦

文件路径: src/stores/slices/filterSlice.ts (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { type StateCreator } from "zustand";

// 定义筛选状态的类型,与 API 保持一致,并增加 'all'
export type UserStatus = 'all' | 'active' | 'inactive';

export interface FilterSlice {
userStatus: UserStatus;
setUserStatus: (status: UserStatus) => void;
}

export const createFilterSlice: StateCreator<FilterSlice> = (set) => ({
userStatus: 'all',
setUserStatus: (status) => set({ userStatus: status }),
});

文件路径: src/stores/useAppStore.ts (新建或修改)

1
2
3
4
5
6
7
8
import { create } from 'zustand';
import { type FilterSlice, createFilterSlice } from './slices/filterSlice';

type AppState = FilterSlice;

export const useAppStore = create<AppState>()((...a) => ({
...createFilterSlice(...a),
}));

5.2.5 第三步:【核心】在 Query 层封装动态查询逻辑

queryFn 需要根据 userStatus 的值来决定调用哪个 API 函数 (getUsers 还是 getUsersByStatus)。将这个 if/else 逻辑放在组件中会破坏职责分离。

最佳实践 是将这个动态逻辑封装在 queries 层的 queryOptions 创建函数中。

文件路径: src/queries/userQueries.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { queryOptions } from '@tanstack/react-query';
import { getUsers, getUsersByStatus } from "@/api/user"; // 👈 导入两个 API
import type { UserStatus } from '@/stores/slices/filterSlice';

// 保留原始的 usersQueryOptions 作为获取所有用户的基础配置
export const usersQueryOptions = queryOptions({
queryKey: ['users'],
queryFn: getUsers,
});

// ✨ 新增一个动态的、带参数的 query options 创建函数
export const usersByStatusQueryOptions = (status: UserStatus) =>
queryOptions({
queryKey: ['users', { status }], // 使用对象结构让 queryKey 更具描述性

// 在 queryFn 中封装所有判断逻辑
queryFn: () => {
if (status === 'all') {
return getUsers();
}
return getUsersByStatus(status);
},
});

通过这种方式,我们将 “如何根据状态获取数据” 的复杂逻辑从组件中彻底剥离,封装在了可复用、可测试的 queries 层。组件只需要关心 “我需要什么状态的数据”,而无需关心数据是如何获取的。

5.2.6 第四步:创建筛选器 UI 组件

我们可以在 Ui 层 UserFilter 组件消费我们所封装的逻辑

文件路径: src/components/UserFilter.tsx (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Radio, type RadioChangeEvent } from "antd";
import { useAppStore } from "@/stores/useAppStore";
import type { UserStatus } from "@/stores/slices/filterSlice";

export default function UserFilter() {
// 从 Store 中读取状态和更新函数
const userStatus = useAppStore((state) => state.userStatus);
const setUserStatus = useAppStore((state) => state.setUserStatus);

const onChange = (e: RadioChangeEvent) => {
setUserStatus(e.target.value as UserStatus);
};

return (
<div className="mb-4">
<Radio.Group onChange={onChange} value={userStatus}>
<Radio.Button value="all">所有用户</Radio.Button>
<Radio.Button value="active">活跃用户</Radio.Button>
<Radio.Button value="inactive">非活跃用户</Radio.Button>
</Radio.Group>
</div>
);
}

5.2.7 第五步:最终改造 Users.tsx,实现优雅消费

现在,得益于 queries 层的封装,我们的页面组件变得极其简洁和清晰。

文件路径: src/pages/Users.tsx (最终版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useQuery } from "@tanstack/react-query";
import { List, Typography, Alert, Spin } from "antd";
import { useNavigate } from "react-router-dom";
import AddUserForm from "@/components/AddUserForm";
import UserFilter from "@/components/UserFilter";
import { useAppStore } from "@/stores/useAppStore";
// 👈 1. 只需导入我们新创建的动态 query options 函数
import { usersByStatusQueryOptions } from "@/queries/userQueries";

export default function Users() {
const navigate = useNavigate();
// 2. 从 Zustand Store 中订阅筛选状态
const userStatus = useAppStore((state) => state.userStatus);

// 3. ✨ 核心改造:一行代码,优雅地消费动态查询
const {
data: users,
isLoading,
isError,
error,
isFetching,
} = useQuery(usersByStatusQueryOptions(userStatus));

if (isError) {
return (
<Alert message="数据加载失败" description={error.message} type="error" showIcon/>
);
}

return (
<div>
<Typography.Title level={2} className="!mb-4">
添加新用户
</Typography.Title>
<AddUserForm />

<div className="flex items-center justify-between mt-8 mb-4">
<Typography.Title level={2} className="!mb-0">
用户列表
</Typography.Title>
{isFetching && !isLoading && <Spin size="small" tip="刷新中..." />}
</div>

<UserFilter />

<List
loading={isLoading}
bordered
dataSource={users}
renderItem={(user) => (
<List.Item
className="cursor-pointer hover:bg-gray-100"
onClick={() => navigate(`/users/${user.id}`)}
>
<List.Item.Meta title={user.name} description={user.email} />
</List.Item>
)}
/>
</div>
);
}
技术深度对话
此刻

我明白了!通过在 queries 层创建一个 usersByStatusQueryOptions 函数,我们把 if (status === 'all') 的判断逻辑彻底从组件中移除了。

架构师

正是如此。组件的职责回归纯粹:从 Zustand 获取 意图,将意图传递给 Query 层,然后渲染 Query 层返回的结果。组件本身不应该包含任何数据获取的实现细节。

这样做,如果未来筛选逻辑更复杂,比如增加一个按姓名搜索的输入框,我只需要修改 usersByStatusQueryOptions 函数,而 Users.tsx 组件可能一行代码都不用改。

架构师

这就是 关注点分离 带来的巨大威力。你的组件变得更加健壮、可预测,并且你的数据获取逻辑也拥有了独立的、可复用的单元。恭喜你,这正是企业级应用所推崇的架构模式。


5.3. 场景二:服务器状态变更后广播客户端状态

现在我们来看反向的通信:Mutation 成功后,如何通知全局 UI 进行更新。

5.3.1 业务场景与挑战

需求: 在 App.tsx 的 Header 中,有一个显示“当前用户总数”的徽标。当我们在 Users 页面成功添加一个新用户后,这个徽标的数字需要 立即、准确地 更新。

挑战: Header 组件和 AddUserForm 组件之间可能没有任何父子关系,它们是完全解耦的。我们如何实现这种跨组件通信?

img

5.3.2 架构思想:单一来源与衍生数据

要优雅地解决这个问题,我们首先要确立一个核心架构思想:

  1. 单一事实来源: 关于用户数据的唯一事实来源,是 GET /users 这个 API 端点返回的用户列表。
  2. 衍生数据: “用户总数”这个信息,并非一个独立的、需要专门请求的数据。它本质上是从“用户列表”这个事实来源中 衍生 出来的。
  3. 声明式同步: 我们不应该手动去维护这个总数(比如 count++),而应该建立一个机制,当“用户列表”这个源头数据更新时,“用户总数”能够 自动、声明式地 更新。

TanStack Queryselect 选项,正是实现这一点的完美工具。

5.3.3 实现步骤

第一步:在 Query 层创建衍生的“总数”查询

为了高效地获取用户总数而无需发起额外的网络请求,我们将基于已有的 usersQueryOptions,利用 select 创建一个只返回数量的衍生查询配置。

文件路径: src/queries/userQueries.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { queryOptions } from '@tanstack/react-query';
import { getUsers, getUserById } from "@/api/user";
import type { UserStatus } from '@/stores/slices/filterSlice';

// ... (保留 usersQueryOptions 和 usersByStatusQueryOptions) ...
export const usersQueryOptions = queryOptions({
queryKey: ['users'],
queryFn: getUsers,
});

// 新增:创建一个衍生的 query options
// 它将复用 usersQueryOptions 的 queryKey 和 queryFn
export const usersCountQueryOptions = queryOptions({
// 1. 复用获取所有用户的查询键和查询函数
...usersQueryOptions,

// 2. 使用 select 选项对结果进行转换
// `data` 是 `getUsers` 函数成功返回的 User [] 数组
// 这个函数只会在数据成功获取后执行
select: (data) => data.length,
});

我们定义了一个新的查询配置 usersCountQueryOptions。当它被 useQuery 使用时,TanStack Query 会:

  1. 查找 queryKey: ['users'] 的缓存。
  2. 如果没有或已过时,则执行 queryFn: getUsers 来获取完整的用户列表。
  3. 在返回数据给组件之前,执行 select 函数,只将数组的长度 (data.length) 作为最终的 data 返回。这一切都发生在同一个查询实例上,没有额外的网络请求

第二步:创建 statsSlice

我们的 Zustand 切片现在职责非常纯粹:仅负责存储从服务器同步过来的 userCount 这个客户端状态。

文件路径: src/stores/slices/statsSlice.ts

1
2
3
4
5
6
7
8
9
10
11
import { type StateCreator } from "zustand";

export interface StatsSlice {
userCount: number;
setUserCount: (count: number) => void;
}

export const createStatsSlice: StateCreator<StatsSlice> = (set) => ({
userCount: 0,
setUserCount: (count) => set({ userCount: count }),
});

第三步:组合 statsSlice 到 Root Store

文件路径: src/stores/useAppStore.ts

1
2
3
4
5
6
7
8
9
10
import { create } from 'zustand';
import { type FilterSlice, createFilterSlice } from './slices/filterSlice';
import { type StatsSlice, createStatsSlice } from './slices/statsSlice';

type AppState = FilterSlice & StatsSlice;

export const useAppStore = create<AppState>()((...a) => ({
...createFilterSlice(...a),
...createStatsSlice(...a),
}));

第四步:定义 useAddUser 自定义 Hook

此 Hook 的职责是清晰地向 TanStack Query 声明“服务器状态已变更”,而不必关心具体的 UI 更新逻辑。

文件路径: src/hooks/useUserMutations.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUser } from "@/api/user";
import { usersQueryOptions } from "@/queries/userQueries";
import { App as AntdApp } from "antd";

export const useAddUser = () => {
const { message } = AntdApp.useApp();
const queryClient = useQueryClient();

return useMutation({
mutationFn: createUser,
onSuccess: () => {
message.success("新用户添加成功!");
// 核心:只需让源头数据失效
// 因为 `usersCountQueryOptions` 衍生自 `usersQueryOptions`,
// 所以当 `['users']` 查询失效后,所有依赖它的查询都会自动刷新。
return queryClient.invalidateQueries(usersQueryOptions);
},
onError: (error) => {
message.error(`添加失败: ${error.message}`);
},
});
};
技术深度对话
2025-10-07

这个设计非常解耦!useAddUser Hook 完全不知道 Zustand 的存在,它的职责变得非常单一,就是和 TanStack Query 交互。

完全正确。高层逻辑(数据变更)不应该依赖于低层实现(具体的 UI 更新)。它只需要发出一个“数据已旧”的信号 (invalidateQueries),所有订阅了这个源头数据的部分,无论是用户列表还是用户总数,都会自动做出响应。

这样做,即便未来我们有更多衍生数据(比如“活跃用户数”),只要它们都源自 ['users'],这个 Hook 依然一行代码都不用改。

这就是声明式状态管理的魅力。你描述的是 “结果”(让数据失效),而不是 “过程”(手动去加一),系统会自动完成剩下的所有工作。

第五步:在 Header 中消费衍生查询与客户端状态

最后,我们在 App.tsx 组件中将所有部分连接起来。这里的 useEffect 扮演了关键的桥梁角色,负责在应用初始化时,将服务器状态同步到客户端状态存储中。

文件路径: src/App.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { Layout, Badge } from "antd";
import { Outlet, NavLink } from "react-router-dom";
import { useAppStore } from "./stores/useAppStore";
import { useQuery } from "@tanstack/react-query";
import { usersCountQueryOptions } from "./queries/userQueries";
import { useEffect } from "react";

const { Header, Content } = Layout;

export default function App() {
const userCount = useAppStore((state) => state.userCount);
const setUserCount = useAppStore((state) => state.setUserCount);

// 这个 useQuery 不会触发独立网络请求
// 它会智能地从 ['users'] 查询的缓存中派生数据
const { data: serverUserCount } = useQuery(usersCountQueryOptions);

// effect 的作用是:在应用加载时,用服务器的初始状态来“水合”我们的客户端 store
useEffect(() => {
// 只有当服务器返回了确切的数字时,才更新 Zustand 状态
if (typeof serverUserCount === "number") {
setUserCount(serverUserCount);
}
}, [serverUserCount, setUserCount]);

return (
<Layout className="min-h-scree">
<Header className="flex items-center" style={{ background: "#fff" }}>
<NavLink to="/users" className="text-blue-600">
<Badge count={userCount} overflowCount={99}>
<span className="pr-2">用户列表</span>
</Badge>
</NavLink>
</Header>
<Layout>
<Content className="m-6 p-6 bg-white">
<Outlet />
</Content>
</Layout>
</Layout>
);
}

至此,我们构建了一个高效且健壮的系统。当新用户被添加时,useAddUser 只需让 ['users'] 缓存失效,App.tsx 中订阅总数的 useQueryUsers.tsx 中订阅列表的 useQuery 都会自动收到通知并以最高效的方式更新,最终通过 useEffect 将最准确的数据同步到 Zustand Store 中。


5.4. 本章核心速查总结

模式核心思想数据流向关键代码
客户端驱动服务端使用全局 UI 状态(如筛选器)来动态构建 queryKey,触发 TanStack Query 自动查询。UI -> Zustand -> queryKey -> TanStack QueryuseQuery({ queryKey: ['entity', clientState] })
服务端驱动客户端useMutation 的回调函数(onSuccess / onMutate)中,调用 Zustand 的 action 来更新全局 UI 状态。useMutation -> onSuccess -> Zustand Action -> UIonSuccess: () => { 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
2
3
4
const { data } = useQuery({
queryKey: ['projects', page], // 页码是 queryKey 的一部分
queryFn: () => fetchProjects(page),
});

如果您运行这个例子,会发现当 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 数组)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
"projects": [
{ "id": 1, "name": "智慧城市建设项目" },
{ "id": 2, "name": "电商平台升级改造" },
{ "id": 3, "name": "移动支付系统开发" },
{ "id": 4, "name": "企业资源管理系统" },
{ "id": 5, "name": "在线教育平台搭建" },
{ "id": 6, "name": "智能物流配送系统" },
{ "id": 7, "name": "医疗信息化管理平台" },
{ "id": 8, "name": "云计算数据中心建设" },
{ "id": 9, "name": "人工智能客服系统" },
{ "id": 10, "name": "区块链溯源平台" },
{ "id": 11, "name": "大数据分析平台" },
{ "id": 12, "name": "物联网监控系统" },
{ "id": 13, "name": "金融风控系统开发" },
{ "id": 14, "name": "智能家居控制平台" },
{ "id": 15, "name": "视频会议系统升级" },
{ "id": 16, "name": "供应链管理优化项目" },
{ "id": 17, "name": "社交媒体运营平台" },
{ "id": 18, "name": "企业知识库系统" },
{ "id": 19, "name": "自动化办公流程系统" },
{ "id": 20, "name": "网络安全防护平台" },
{ "id": 21, "name": "客户关系管理系统" },
{ "id": 22, "name": "数字营销推广平台" },
{ "id": 23, "name": "智能仓储管理系统" },
{ "id": 24, "name": "在线协作办公工具" },
{ "id": 25, "name": "移动应用开发项目" },
{ "id": 26, "name": "云存储服务平台" },
{ "id": 27, "name": "电子合同签署系统" },
{ "id": 28, "name": "人力资源管理平台" },
{ "id": 29, "name": "智能推荐引擎开发" },
{ "id": 30, "name": "跨境电商平台建设" },
{ "id": 31, "name": "财务核算自动化系统" },
{ "id": 32, "name": "直播带货平台开发" },
{ "id": 33, "name": "智能问答机器人" },
{ "id": 34, "name": "内容管理发布系统" },
{ "id": 35, "name": "数据可视化分析工具" },
{ "id": 36, "name": "企业门户网站建设" },
{ "id": 37, "name": "即时通讯软件开发" },
{ "id": 38, "name": "云端备份恢复系统" },
{ "id": 39, "name": "智能排班调度系统" },
{ "id": 40, "name": "线上培训考试平台" },
{ "id": 41, "name": "电子发票管理系统" },
{ "id": 42, "name": "智慧园区管理平台" },
{ "id": 43, "name": "舆情监测分析系统" },
{ "id": 44, "name": "设备运维管理平台" },
{ "id": 45, "name": "智能客流分析系统" },
{ "id": 46, "name": "预算管理控制系统" },
{ "id": 47, "name": "项目进度跟踪平台" },
{ "id": 48, "name": "质量检测管理系统" },
{ "id": 49, "name": "合规审计监控平台" },
{ "id": 50, "name": "数字孪生仿真系统" }
]
}

验证 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import axiosInstance from "@/utils/server";

export interface Project {
id: number;
name: string;
}

// 定义 API 函数的返回类型,包含数据和总数
interface ProjectsResponse {
projects: Project[];
totalCount: number;
}

// API 函数接收页码,并返回解析后的数据和总数
export const getProjects = async (page: number): Promise<ProjectsResponse> => {
const limit = 5; // 每页数量
const response = await axiosInstance.get(`/projects?_page=${page}&_limit=${limit}`);

// 从响应头获取总数
const totalCount = Number(response.headers['x-total-count']);

return {
projects: response.data,
totalCount,
};
};

文件路径: src/queries/projectQueries.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
import { queryOptions, keepPreviousData } from '@tanstack/react-query';
import { getProjects } from '@/api/project';

// 创建一个动态的、支持分页的 query options
export const projectsQueryOptions = (page: number) =>
queryOptions({
queryKey: ['projects', { page }],
queryFn: () => getProjects(page),

// ✨ 核心:开启数据暂留功能
placeholderData: keepPreviousData,
});

6.1.5 第三步:创建分页 UI 组件

文件路径: src/pages/Projects.tsx (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { projectsQueryOptions } from "@/queries/projectQueries";
import { List, Spin, Typography, Alert } from "antd";

export default function Projects() {
const [page, setPage] = useState(1);
const {
data: projectsData,
isPending,
isError,
error,
isFetching,
} = useQuery(projectsQueryOptions(page));

if (isPending) {
return (
<div className="flex justify-center items-center mt-8">
<Spin tip="加载中..." size="large">
<div className="w-64 h-32" />
</Spin>
</div>
);
}

if (isError) {
return (
<Alert
message="加载失败"
description={error.message}
type="error"
showIcon
/>
);
}

return (
<div>
<div className="flex items-center justify-between mb-4">
<Typography.Title level={2} className="!mb-0">
项目列表 (分页)
</Typography.Title>
{isFetching && (
<Spin size="small" tip="刷新中...">
<div className="w-20 h-8" />
</Spin>
)}
</div>
<List
bordered
dataSource={projectsData?.projects}
renderItem={(project) => (
<List.Item>
<Typography.Text>{project?.name}</Typography.Text>
</List.Item>
)}
pagination={{
current: page,
pageSize: 5,
total: projectsData?.totalCount,
onChange: (newPage) => setPage(newPage),
showSizeChanger: false,
}}
/>
</div>
);
}

6.1.6 第四步:添加路由

最后,在路由中添加新页面。

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ... 其他 imports
import Projects from "@/pages/Projects"; // 👈 导入新页面

const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
// ... 其他路由
{ path: "projects", element: <Projects /> }, // 👈 添加新路由
],
},
]);

现在,访问 /projects 页面并点击翻页按钮。您会发现列表内容无缝切换,没有任何加载状态的“跳动”,同时右上角的“刷新中…”指示器会准确地反映后台获取状态。这正是我们想要的流畅体验!

img


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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {
queryOptions,
infiniteQueryOptions, // 注意,这里是核心,要引入不同的QueryOptions
keepPreviousData,
} from "@tanstack/react-query";
import { getProjects } from "@/api/project";

export const infiniteProjectsQueryOptions = infiniteQueryOptions({
queryKey: ["projects", "infinite"],
queryFn: ({ pageParam }) => getProjects(pageParam),
initialPageParam: 1, // 初始页码为 1
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
const limit = 5;
const totalPages = Math.ceil(lastPage.totalCount / limit);
// 如果当前页码 + 1 小于总页数,则返回下一页的页码
if (lastPageParam < totalPages) {
return lastPageParam + 1;
}
// 否则,没有更多数据
return undefined;
},
});

技术深度对话
此刻

这个 getNextPageParam 是关键!它接收三个参数:lastPage (最后一页的数据), allPages (所有已加载页面的数据数组), 和 lastPageParam (获取最后一页时用的参数)。

正是。你的核心逻辑就在这里:根据最后一页的数据(我们从中能知道 totalCount)和最后一个页面参数(lastPageParam,也就是当前页码),来计算出 下一页的参数。如果计算不出来(比如已经到了最后一页),就返回 undefined

TanStack Query 会将这个函数返回的值,在下一次调用 fetchNextPage 时,作为 pageParam 传给我们的 queryFn

完全正确。你已经掌握了无限查询的数据流转核心。

6.2.4 第三步:创建无限加载 UI 组件

文件路径: src/pages/InfiniteProjects.tsx (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import React from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { infiniteProjectsQueryOptions } from "@/queries/projectQueries";
import { Button, List, Spin, Typography, Alert } from "antd";

export default function InfiniteProjects() {
const {
data,
error,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery(infiniteProjectsQueryOptions);

if (isPending) {
return <Spin tip="加载中..." size="large" className="w-full mt-8" />;
}

if (isError) {
return <Alert message="加载失败" description={error.message} type="error" showIcon />;
}

return (
<div>
<div className="flex items-center justify-between mb-4">
<Typography.Title level={2} className="!mb-0">
项目列表 (无限加载)
</Typography.Title>
{isFetching && !isFetchingNextPage && <Spin size="small" tip="刷新中..." />}
</div>
{/* 遍历 pages 数组,然后遍历每个 page 内的项目 */}
{data.pages.map((group, i) => (
<React.Fragment key={i}>
<List
bordered
dataSource={group.projects}
renderItem={(project) => (
<List.Item>
<Typography.Text>{project.name}</Typography.Text>
</List.Item>
)}
className={i > 0 ? "mt-4" : ""}
/>
</React.Fragment>
))}

<div className="flex justify-center mt-4">
<Button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? "加载中..."
: hasNextPage
? "加载更多"
: "没有更多数据了"}
</Button>
</div>
</div>
);
}

6.2.5 第四步:添加路由

文件路径: src/router/index.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
import InfiniteProjects from "@/pages/InfiniteProjects"; // 👈 导入

const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
// ...
{ path: "projects", element: <Projects /> },
{ path: "infinite-projects", element: <InfiniteProjects /> }, // 👈 添加
],
},
]);

现在,访问 /infinite-projects 页面,您就可以通过点击“加载更多”按钮,不断地在列表下方追加新的数据,直到所有数据加载完毕。

img


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 分钟
技术深度对话
2025-10-07

所以,staleTimegcTime 是两个完全独立的计时器?

完全正确,这是最关键的概念!staleTime 关心的是 “数据是否需要更新”,它作用于活跃的查询。而 gcTime 关心的是 “数据是否需要被遗忘”,它只作用于不活跃的查询。

也就是说,一个查询的数据可以同时是“陈旧的”(stale),但距离被“垃圾回收”(gc) 还很久?

绝佳的总结!这正是常态。比如 staleTime: 0 (默认值) 和 gcTime: 5 * 60 * 1000 (默认值)。数据一获取就“陈旧”了,所以下次聚焦会刷新,但你离开页面后,它还会在缓存里躺 5 分钟等你回来。


7.2. 实战一:配置全局默认值

对于一个应用来说,最佳实践是首先设定一个合理的全局缓存策略。

文件路径: src/lib/queryClient.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { QueryClient } from "@tanstack/react-query";

// 创建 QueryClient 实例
export const queryClient = new QueryClient({
// 配置全局默认选项
defaultOptions: {
queries: {
// ✨ 设置所有查询的 staleTime 为 1 分钟
// 在这 1 分钟内,数据被认为是新鲜的,不会触发不必要的后台刷新
staleTime: 1000 * 60,

// gcTime 保持默认的 5 分钟
// gcTime: 1000 * 60 * 5,
},
},
});

只需这几行代码,您的整个应用就变得不那么“激进”了。在用户访问一个页面后的 1 分钟内,即便是来回切换浏览器标签页,也不会再频繁地请求 API,显著降低了服务器压力。


7.3. 实战二:为特定查询覆盖配置

全局配置虽好,但不同数据的更新频率显然不同。比如,“项目列表”可能变化不频繁,而“实时通知”则需要更短的保鲜期。我们可以在 queryOptions 中轻松覆盖全局配置。

文件路径: src/queries/projectQueries.ts (修改)

1
2
3
4
5
6
7
8
9
10
11
12
import { queryOptions, keepPreviousData } from '@tanstack/react-query';
import { getProjects } from '@/api/project';

export const projectsQueryOptions = (page: number) =>
queryOptions({
queryKey: ['projects', { page }],
queryFn: () => getProjects(page),
placeholderData: keepPreviousData,

// ✨ 为项目列表单独设置更长的 5 分钟保鲜期
staleTime: 1000 * 60 * 5,
});

如何验证?

  1. 启动应用并打开 React Query Devtools。
  2. 访问 /projects 页面。您会看到 ['projects', { page: 1 }] 这个查询实例。它的状态指示器会是 绿色 的,表示 fresh
  3. 等待,即使过了 1 分钟(我们设置的全局 staleTime),这个查询依然是绿色的。
  4. 直到 5 分钟后,它才会变为 蓝色,表示 stale
  5. 在这 5 分钟内,反复切换浏览器标签页,您会发现 Devtools 中不会出现 isFetching 状态,因为数据是新鲜的,无需刷新。

7.4. 实战三:控制自动刷新行为

除了基于时间的缓存策略,我们还可以直接控制 TanStack Query 的自动化行为。

  • refetchOnWindowFocus: 窗口重新获得焦点时是否自动刷新。
  • refetchOnMount: 组件挂载时,如果数据是 stale 状态,是否自动刷新。
  • refetchOnReconnect: 网络重新连接时是否自动刷新。

这些选项默认都为 true。但在某些场景下,比如一个包含复杂表单的页面,用户可能不希望在填写过程中因为切换窗口而导致数据意外刷新。此时我们可以禁用它。

全局禁用示例: src/lib/queryClient.ts

1
2
3
4
5
6
7
8
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
refetchOnWindowFocus: false, // 👈 全局禁用窗口聚焦刷新
},
},
});

局部禁用示例: src/queries/projectQueries.ts

1
2
3
4
5
6
export const projectsQueryOptions = (page: number) => 
queryOptions({
// ...
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false, // 👈 只对这个查询禁用
});

7.5. 本章核心速查总结

配置项核心职责最佳实践与思考
staleTime数据保鲜期。决定数据在多长时间内无需后台刷新。- 全局设置一个合理的短时长(如 1-2 分钟)。
- 对几乎不变的数据(如配置、分类)设置一个很长的时长(如 1 小时)。
gcTime缓存生命周期。决定不活跃数据多久后被从内存中清除。- 通常保持默认的 5 分钟即可,它提供了很好的导航体验。
- 如果应用内存敏感,可以适当缩短。
refetchOn...行为开关。控制是否在特定事件(聚焦、挂载等)时自动刷新 stale 数据。- 大部分情况保持默认 true
- 在不希望用户操作被打断的页面(如复杂表单)可以局部设置为 false

恭喜您!您已经完全掌握了 TanStack Query 的缓存核心。现在,您不仅能构建功能,更能随心所欲地优化应用的性能和用户体验。