Skip to content

Lesson 11:对接服务端 API — TanStack Query 与状态分类 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 2(进阶篇)
  • 推荐时长:75~120 分钟(首次学习)
  • 先修要求:完成 Lesson 10,已有项目 store、持久化与主题切换
  • 学习产出:启动Mock API,将侧栏和看板迁移到Query并新增项目
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 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 后端):

bash
# -D 表示开发依赖
npm install -D [email protected]

本课固定 0.17.4:后续使用 --delay、_limit 和数组分页响应。1.x 的参数与响应不同,不能直接替换。参见 json-server 版本迁移说明。

在项目根目录创建一个文件:db.json

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 等脚本。下面只展示新增项:

json
{
  "mock": "json-server --watch db.json --port 3001 --delay 800"
}

(注意我们给假服务器加了 800ms 的延迟,用来模拟真实网络的 Loading 体验!)

启动它(开一个新终端面板):

bash
npm run mock

👉 http://localhost:3001/projects 现在返回真实的 JSON 数据了!


三、安装并配置 TanStack Query ​

bash
npm install @tanstack/react-query@5 @tanstack/react-query-devtools@5

我们需要在应用的顶层(所有页面的外围)提供 QueryClientProvider:

tsx
// 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 的约定;接入未知后端时还应验证响应结构。

ts
// 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
}
ts
// 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),
  })
ts
// 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 })
tsx
// 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>
  )
}

体验一下:

  1. 第一次加载:人工延迟便于观察“正在从远端加载...”状态。
  2. 点进某个路由再切回来:缓存仍存在且新鲜时可直接显示数据。失效、手动刷新或其他配置仍可能触发请求;刷新整个浏览器页面则会重建内存缓存。
  3. 关闭假服务器 (Ctrl+C 停掉 json-server),然后强制刷新页面:它会自动重试 3 次!如果还是失败,才会走入 isError。

缓存过了 staleTime 只是变为 stale,不会立即自动发请求;挂载、窗口重新聚焦等条件才会触发后台刷新。参见 TanStack Query 默认行为。


五、发送数据改动 useMutation ​

我们获取数据用的是 useQuery,而当我们想提交表单,修改服务端数据时(例如新建一个项目),就要用到 useMutation。

为什么修改和获取不一样? ​

  1. Query 表示读取,可以缓存并按配置重新获取。没有订阅者的缓存仍会按 gcTime 回收。
  2. Mutation 表示一次写入尝试,需要由用户行为等显式触发;重复提交和服务端幂等仍需另行处理。

使用上面已经定义的 postNewProject。它会检查 HTTP 状态并返回解析后的项目,避免把 HTTP 500 当作成功。

tsx
// 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 将按钮接入项目列表 ​

tsx
// 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,同时加载项目和任务;本节先保留读取和新增项目,任务写入在后续课实现。

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。


七、练习 ​

  1. 在 db.json 中为 proj-2 添加任务,验证切换项目后只显示当前项目的任务,并覆盖空列表和服务端关闭场景。
  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):通常不应重试
tsx
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) ​

  1. L1:能区分列表 key 与详情 key,并在代码中正确使用。
  2. L2:实现“只失效当前项目任务列表”的 mutation 成功回调。

项目驱动 · 边写边学 · React 19