Lesson 11:对接服务端 API — TanStack Query 与状态分类
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 2(进阶篇)
- 推荐时长:75~120 分钟(首次学习)
- 先修要求:完成 Lesson 10,已有项目 store、持久化与主题切换
- 学习产出:启动Mock API,将侧栏和看板迁移到Query并新增项目
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:理解客户端状态与服务端状态的本质区别,引入 TanStack Query (原 React Query) 管理异步数据。
📦 本节产出:将项目列表数据从本地 Zustand 迁移到远端 Mock API,并实现带 loading 的优雅请求。
一、重新思考:客户端状态 vs 服务端状态
在前两节课,我们把所有的应用数据都放进了 Zustand Store 里。 在小型应用或者不联网的本地应用中,这没问题。
但在真实世界的全栈应用中,数据分两类,它们有着天壤之别:
| 状态类型 | 特征 | 例子 | 谁来管? |
|---|---|---|---|
| 客户端状态 (Client State) | 由客户端负责,例如临时交互状态或可持久化的本地偏好。 | UI 主题 (Dark/Light),侧边栏折叠状态,表单草稿。 | Zustand / Context / useState |
| 服务端状态 (Server State) | 保存在远端数据库,获取是异步的。可能在你不知情时被其他人修改(数据会过期)。需要处理 Loading / Error / 缓存。 | 项目列表,任务详情,用户资料。 | TanStack Query |
WARNING
手动用 Zustand 管理远端数据是可行的,但缓存、请求状态、重试和同步都需自己实现。采用 TanStack Query 后,避免再把同一份查询结果复制到另一个 store,以免两份客户端状态互相冲突。
TanStack Query 管理的是服务端数据在客户端的缓存和请求生命周期。
二、用 json-server 启动 Mock API
要体验请求,我们需要一个"假"服务器。 在真实开发中,前端进度如果快于后端,大家都会写或者开 Mock API。
我们使用 json-server 快速搞定(无需写 Node.js 后端):
# -D 表示开发依赖
npm install -D [email protected]本课固定 0.17.4:后续使用 --delay、_limit 和数组分页响应。1.x 的参数与响应不同,不能直接替换。参见 json-server 版本迁移说明。
在项目根目录创建一个文件:db.json
{
"projects": [
{ "id": "proj-1", "name": "新版官网开发", "icon": "🚀" },
{ "id": "proj-2", "name": "Q4 产品发布会", "icon": "🎉" }
],
"tasks": [
{ "id": "t-1", "projectId": "proj-1", "title": "搭建骨架", "status": "done" },
{ "id": "t-2", "projectId": "proj-1", "title": "设计图切片", "status": "todo" }
]
}在 package.json 的现有 scripts 中添加 mock,保留 build、lint 等脚本。下面只展示新增项:
{
"mock": "json-server --watch db.json --port 3001 --delay 800"
}(注意我们给假服务器加了 800ms 的延迟,用来模拟真实网络的 Loading 体验!)
启动它(开一个新终端面板):
npm run mock👉 http://localhost:3001/projects 现在返回真实的 JSON 数据了!
三、安装并配置 TanStack Query
npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5我们需要在应用的顶层(所有页面的外围)提供 QueryClientProvider:
// src/main.tsx(增补 import 和 queryClient,保留 Lesson 08 的路由及其他导入)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// 1. 创建一个全新的 Query 客户端
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分钟内视为新鲜,挂载/聚焦等通常不自动重新请求;失效或手动refetch除外
},
},
})
// ... 原有的路由配置 router ...
createRoot(document.getElementById('root')!).render(
<StrictMode>
{/* 2. 用 Provider 包裹 Router,注入能力 */}
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{/* 3. 赠品:极其强大的查询调试窗,只在开发环境有效! */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)四、实战:抓取项目列表 useQuery
回到 ProjectsLayout.tsx。我们要把 Zustand 里的 projects 换成从网络抓取。
先建立带类型和 HTTP 错误检查的请求层。这里的类型描述本地 db.json 的约定;接入未知后端时还应验证响应结构。
// src/api/http.ts
const API_URL = 'http://localhost:3001'
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_URL}${path}`, init)
if (!response.ok) throw new ApiError(`请求失败(${response.status})`, response.status)
return await response.json() as T
}// src/api/projectRequests.ts
import { apiRequest } from './http'
export interface Project { id: string; name: string; icon: string }
export const fetchProjects = (signal?: AbortSignal) =>
apiRequest<Project[]>('/projects', { signal })
export const postNewProject = (project: Project) =>
apiRequest<Project>('/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(project),
})// src/api/taskRequests.ts
import { apiRequest } from './http'
export interface Task {
id: string
projectId: string
title: string
status: 'todo' | 'in-progress' | 'done'
}
export const fetchTasks = (projectId: string, signal?: AbortSignal) =>
apiRequest<Task[]>(`/tasks?projectId=${encodeURIComponent(projectId)}`, { signal })// src/layouts/ProjectsLayout.tsx
import { NavLink, Outlet } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchProjects } from '../api/projectRequests'
export default function ProjectsLayout() {
// 🐻 魔法代码:向服务端索要数据,交出管辖权!
const {
data: projects, // 拿到数据
isPending, // 查询尚无成功数据(不等同于当前正在请求)
isError, // 请求崩溃了没?
error // 如果崩溃了,错误详情在哪?
} = useQuery({
queryKey: ['projects'], // 身份证号,用来做全局缓存的 key
queryFn: ({ signal }) => fetchProjects(signal), // 提供数据的 Promise 函数
})
return (
<div className="flex h-full">
<aside className="w-64 bg-white border-r border-gray-200 shrink-0 flex flex-col py-4">
<h2 className="px-6 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">近期项目</h2>
<nav className="flex-1 px-3 space-y-1">
{/* ✅ 分支 1: 处理中 */}
{isPending && (
<div className="px-3 py-2 text-sm text-gray-400 animate-pulse">正在从远端加载...</div>
)}
{/* ❌ 分支 2: 出错了 */}
{isError && (
<div className="px-3 py-2 text-sm text-red-500">😭 {error.message}</div>
)}
{/* ✨ 分支 3: 渲染数据 */}
{projects && projects.map(proj => (
<NavLink key={proj.id} to={`/projects/${proj.id}`} className="block px-3 py-2">
{proj.icon} {proj.name}
</NavLink>
))}
</nav>
</aside>
<div className="min-w-0 flex-1 overflow-auto bg-gray-50/50 p-8">
<Outlet />
</div>
</div>
)
}体验一下:
- 第一次加载:人工延迟便于观察“正在从远端加载...”状态。
- 点进某个路由再切回来:缓存仍存在且新鲜时可直接显示数据。失效、手动刷新或其他配置仍可能触发请求;刷新整个浏览器页面则会重建内存缓存。
- 关闭假服务器 (
Ctrl+C停掉json-server),然后强制刷新页面:它会自动重试 3 次!如果还是失败,才会走入isError。
缓存过了 staleTime 只是变为 stale,不会立即自动发请求;挂载、窗口重新聚焦等条件才会触发后台刷新。参见 TanStack Query 默认行为。
五、发送数据改动 useMutation
我们获取数据用的是 useQuery,而当我们想提交表单,修改服务端数据时(例如新建一个项目),就要用到 useMutation。
为什么修改和获取不一样?
- Query 表示读取,可以缓存并按配置重新获取。没有订阅者的缓存仍会按
gcTime回收。 - Mutation 表示一次写入尝试,需要由用户行为等显式触发;重复提交和服务端幂等仍需另行处理。
使用上面已经定义的 postNewProject。它会检查 HTTP 状态并返回解析后的项目,避免把 HTTP 500 当作成功。
// src/components/AddProjectButton.tsx
import { postNewProject } from '../api/projectRequests'
import { useMutation, useQueryClient } from '@tanstack/react-query'
export default function AddProjectButton() {
const queryClient = useQueryClient()
// 发起修改请求
const mutation = useMutation({
mutationFn: postNewProject, // 使用上面的 Promise
// 成功后,你需要告诉 Query "刚刚那批数据过期了,请重新获取!"
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
})
const handleAdd = () => {
mutation.mutate({ id: crypto.randomUUID(), name: '新任务箱', icon: '📦' })
}
return (
<div>
<button onClick={handleAdd} disabled={mutation.isPending}>
{mutation.isPending ? '正在写入数据库...' : '增加新项目'}
</button>
{mutation.isError && <p role="alert">{mutation.error.message}</p>}
</div>
)
}invalidateQueries 将匹配缓存标为 stale,并通常重新获取正在使用的查询;它不会删除缓存。返回它的 Promise,让 mutation 的 pending 状态持续到重新获取结束。下一课再用 setQueryData 做乐观更新。
5.1 将按钮接入项目列表
// src/pages/projects/ProjectList.tsx
import AddProjectButton from '../../components/AddProjectButton'
export default function ProjectList() {
return <section><h1 className="mb-4 text-xl">项目列表</h1><AddProjectButton /></section>
}5.2 看板同步迁移到 API
侧栏已使用 proj-1 等远端 ID,看板不能继续查找 Lesson 10 的本地数据。替换整个 Board.tsx,同时加载项目和任务;本节先保留读取和新增项目,任务写入在后续课实现。
// src/pages/projects/Board.tsx
import { useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchProjects } from '../../api/projectRequests'
import { fetchTasks } from '../../api/taskRequests'
export default function Board() {
const { id = '' } = useParams()
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: ({ signal }) => fetchProjects(signal),
})
const tasksQuery = useQuery({
queryKey: ['tasks', id],
queryFn: ({ signal }) => fetchTasks(id, signal),
enabled: Boolean(id),
})
if (!id) return <p>未选择项目</p>
if (projectsQuery.isPending || tasksQuery.isPending) return <p>加载看板...</p>
if (projectsQuery.isError || tasksQuery.isError) return <p role="alert">加载失败,请检查 Mock 服务。</p>
const project = projectsQuery.data.find(item => item.id === id)
if (!project) return <p>找不到这个项目</p>
return (
<section>
<h1 className="mb-4 text-2xl">{project.name}</h1>
{tasksQuery.data.length === 0 && <p>暂无任务</p>}
<ul>{tasksQuery.data.map(task => (
<li key={task.id} className="mb-2 rounded bg-white p-3 text-gray-900">
{task.title} · {task.status}
</li>
))}</ul>
</section>
)
}六、🧠 深度专题:请求瀑布与 Suspense 模式配合
当 ProjectsLayout.tsx 里用 useQuery 获取了项目列表后,里面的 Outlet (对应的 Board.tsx) 又使用了 useQuery 去请求任务详情。
上面的 ProjectsLayout 始终渲染 Outlet,没有等待项目请求完成才挂载看板。因此这两个查询可以重叠进行;相同的 ['projects'] 查询还会共享缓存和正在进行的请求。
如果父组件在数据返回前 return <Loading />,或父层的 Suspense 阻止了子组件挂载,子查询就可能延后。Loader、预取或 useSuspenseQueries 可以按场景减少瀑布。Suspense 本身只负责等待时的 UI,不会自动把请求并行化。普通 useQuery 也不会因为外面包了 Suspense 就自动挂起;第 16 课会使用 useSuspenseQuery。
七、练习
- 在
db.json中为proj-2添加任务,验证切换项目后只显示当前项目的任务,并覆盖空列表和服务端关闭场景。 - 搜索并移除旧
useProjectStore的 UI 引用,确认都已切换 API 后再删除旧 store;保留useThemeStore。本地删除项目功能若要恢复,需改为服务端 mutation,不能继续只删本地缓存。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 了解状态被划分为"客户端"与"服务端" | Zustand 管本地,TanStack Query 管网络 |
运行了 json-server 的 Mock API 环境 | Mock 开发流的实践姿势 |
用 useQuery 抓取数据并展示加载条 | 缓存隔离 (queryKey) 与重试机制 |
用 useMutation 写入数据 | 控制状态过期 (invalidateQueries) 倒逼前端同步 |
| — | 理解由于组件挂载时机引起的嵌套瀑布流请求 |
八、进阶补强:错误重试、Query Key 与失效策略
8.1 不要把所有请求都“无脑重试”
5xx/ 网络抖动:可重试4xx(如 401/403/404):通常不应重试
import { ApiError } from '../api/http'
// 合并到 ProjectsLayout 的 useQuery 配置:
useQuery({
queryKey: ['projects'],
queryFn: ({ signal }) => fetchProjects(signal),
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false
return failureCount < 2
},
})8.2 Query Key 设计建议
- 列表:
['projects', filters] - 详情:
['project', projectId] - 任务列表:
['tasks', projectId, status]
原则:同一资源同一 key;不同筛选条件必须进入 key。
8.3 精准失效,避免全量抖动
新增任务后优先失效 ['tasks', projectId],避免用不带筛选条件的 invalidateQueries() 让所有查询都失效。
8.4 验收标准(L1/L2)
- L1:能区分列表 key 与详情 key,并在代码中正确使用。
- L2:实现“只失效当前项目任务列表”的 mutation 成功回调。