Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

React 生态 / React Ecosystem

1. React 生态知识体系 / React Ecosystem Knowledge System

2. React 组件渲染、协调与数据流 / React Components, Rendering, and Data Flow

3. Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency

4. 路由、表单与组件架构 / Routing, Forms, and Component Architecture

5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand

6. Next.js App Router:路由、渲染与工程边界 / Next.js App Router, Routing, Rendering, and Engineering Boundaries

7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations

8. TanStack Query 与服务端状态管理 / TanStack Query and Server State Management

9. React 测试、性能与生产工程 / React Testing, Performance, and Production Engineering

本页目录

TanStack Query 与服务端状态管理 / TanStack Query and Server State Management ​

📅 创建时间:2026-07-28 🏷️ 标签:#TanStackQuery #ServerState #Caching #useQuery #useMutation 📚 前置知识:[[02-hooks-state-and-effects]]

📋 本章目标 ​

  • 区分服务端状态与客户端状态,理解为何不能把二者塞进同一个 Store
  • 掌握 useQuery 的完整生命周期:fetching、fresh、stale、inactive、garbage collected
  • 精通 staleTime 与 gcTime 的语义区别,按业务场景选择合理值
  • 设计可维护的 queryKey 层级体系与工厂函数
  • 实现完整的乐观更新流程:快照保存、UI 立即响应、失败回滚、最终重验证
  • 使用 useInfiniteQuery 实现游标分页与双向无限滚动
  • 通过预取、selector 与持久化优化感知性能与缓存命中率

第1部分:为什么需要 TanStack Query ​

服务端状态与客户端状态的本质区别 ​

客户端状态由你的应用拥有,是同步的、确定性的、不会在你不知情时被修改。服务端状态由远端系统拥有,具有异步、可过期、可并发修改和可能失败等特征。

┌─────────────────────────────────────────────────────────────┐
│           客户端状态 vs 服务端状态                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  客户端状态                    服务端状态                     │
│  ┌───────────────────────┐   ┌───────────────────────────┐ │
│  │ 由前端拥有和控制       │   │ 由远端服务拥有             │ │
│  │ 同步可用               │   │ 异步获取(Promise)        │ │
│  │ 不会自主变化           │   │ 随时可能过期               │ │
│  │ 无需处理加载/错误态    │   │ 必须处理 loading/error     │ │
│  │ 例:选中的tab、表单草稿│   │ 例:用户列表、订单详情     │ │
│  └───────────────────────┘   └───────────────────────────┘ │
│                                                             │
│  ⚠️ 把服务端数据复制进 Redux/Zustand 会引入双重真实源        │
│     (dual source of truth),你需要自己处理:                  │
│     - 何时重新获取?       - 何时标记为过期?                 │
│     - 如何去重请求?       - 失败后如何重试?                 │
│     - 如何在多个组件间共享但只请求一次?                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

手动管理的典型痛点 ​

不使用专用库时,开发者通常用 useEffect + useState 组合自己实现:

tsx
// ❌ 手动管理服务端数据的反模式
function ProjectList() {
  const [projects, setProjects] = useState<Project[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    api.projects.list()
      .then(data => { if (!cancelled) { setProjects(data); setLoading(false) } })
      .catch(err => { if (!cancelled) { setError(err); setLoading(false) } })
    return () => { cancelled = true }
  }, [])

  if (loading) return <Spinner />
  if (error) return <ErrorBanner error={error} />
  return <ProjectTable data={projects} />
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

手动代码需要处理竞态条件(cleanup flag)、去重、缓存共享、窗口聚焦重取、重试、乐观更新等。每一个都是独立的 bug 源头。

缓存失效的固有复杂性 ​

缓存失效是计算机科学中最难的两个问题之一(另一个是命名)。你需要回答:

  • 这份数据在屏幕上停留了多久?
  • 服务端的数据已经被其他用户修改了吗?
  • 用户刚提交了一个 mutation —— 哪些缓存的查询会受到影响?
  • 如果网络断开又恢复,哪些数据需要重新获取?

TanStack Query 提供了一套声明式框架来回答这些问题。


第2部分:Query 核心 ​

useQuery 的完整生命周期 ​

┌─────────────────────────────────────────────────────────────┐
│             useQuery 生命周期状态机                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  组件挂载 / queryClient.fetchQuery()                         │
│     │                                                       │
│     ▼                                                       │
│  ┌──────────┐    请求完成    ┌──────────┐                   │
│  │ fetching │──────────────▶│  fresh   │                   │
│  │          │               │          │                   │
│  │ status:  │               │ 数据在    │                   │
│  │ loading  │               │ staleTime│                   │
│  └──────────┘               │ 内       │                   │
│                              └────┬─────┘                   │
│                                   │                         │
│                     staleTime 到期(或触发事件)              │
│                                   │                         │
│                                   ▼                         │
│                              ┌──────────┐                   │
│                              │  stale   │                   │
│                              │          │                   │
│                              │ 有订阅者 │                   │
│                              │ 仍可使用 │                   │
│                              │ 但下次   │                   │
│                              │ 触发会   │                   │
│                              │ 重新获取 │                   │
│                              └────┬─────┘                   │
│                                   │                         │
│                     所有订阅者卸载(gcTime 计时开始)         │
│                                   │                         │
│                                   ▼                         │
│                              ┌──────────┐                   │
│                              │ inactive │                   │
│                              │          │                   │
│                              │ 无活跃   │                   │
│                              │ 观察者   │                   │
│                              │ 数据仍   │                   │
│                              │ 在缓存中 │                   │
│                              └────┬─────┘                   │
│                                   │                         │
│                     gcTime 到期,无新订阅者挂载               │
│                                   │                         │
│                                   ▼                         │
│                              ┌──────────┐                   │
│                              │ garbage  │                   │
│                              │ collected│                   │
│                              │          │                   │
│                              │ 数据被    │                   │
│                              │ 清除     │                   │
│                              └──────────┘                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

staleTime vs gcTime 的精确语义 ​

两个参数经常被混淆,但实际上控制的是完全不同的维度。

staleTime 控制的是"数据多久内被视为新鲜"——即从数据获取成功那一刻起,多长时间内 TanStack Query 不会主动重新请求。staleTime 之内触发的重新获取(如组件重新挂载、窗口聚焦)会被跳过。当 staleTime 到期后,数据变为 stale(陈旧但仍可用),下一次触发事件会导致后台重新获取。

gcTime(旧称 cacheTime)控制的是"没有订阅者后缓存保留多久"——即当所有使用该查询的组件都卸载后,数据在内存中保留多长时间。在这段时间内如果有新组件挂载,可以直接使用缓存数据(如果 stale 则会后台重取)。gcTime 到期后数据被垃圾回收,下次挂载相当于全新查询。

┌─────────────────────────────────────────────────────────────┐
│            staleTime vs gcTime 时间线对比                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  时间轴 ──────────────────────────────────────────────▶      │
│                                                             │
│  staleTime (默认 0ms):                                     │
│  ┌────────────┐                                              │
│  │   fresh    │········ stale ·························     │
│  │ (不重取)   │  (可后台重取)                                │
│  └────────────┘                                              │
│  0s         staleTime                                        │
│                                                             │
│  gcTime (默认 5min):                                        │
│  ┌──────────────────────────────────────────────────────┐   │
│  │             缓存保留(有或没有订阅者)                  │   │
│  │                                                      │   │
│  │  所有订阅者卸载 ↓                       gcTime 到期 ↓ │   │
│  │  ┌─────────────────────────────────────────────┐     │   │
│  │  │  inactive(仍可被重新订阅)                   │     │   │
│  │  └─────────────────────────────────────────────┘     │   │
│  │                                              → 被清除 │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
│  典型配置策略:                                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 静态资源(国家列表、配置):staleTime: Infinity       │   │
│  │ 用户数据(个人资料):    staleTime: 5 * 60 * 1000   │   │
│  │ 列表数据(项目列表):    staleTime: 30 * 1000       │   │
│  │ 实时数据(通知计数):    staleTime: 0               │   │
│  │ gcTime 通常设为 staleTime 的 5-10 倍                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

queryKey 设计原则 ​

queryKey 是缓存的唯一标识。它的设计直接影响缓存命中率、失效精确度和代码可维护性。

tsx
// 原则1:从通用到具体,数组形式
// ['资源类型', '操作', ...具体参数]
useQuery({ queryKey: ['projects'],           queryFn: fetchProjects })
useQuery({ queryKey: ['projects', id],        queryFn: () => fetchProject(id) })
useQuery({ queryKey: ['projects', id, 'tasks'], queryFn: () => fetchTasks(id) })

// 原则2:包含查询函数依赖的所有变量
function useFilteredProjects(filters: ProjectFilters) {
  return useQuery({
    // ✅ filters 的每个字段都包含在 key 中
    queryKey: ['projects', 'list', filters],
    queryFn: () => api.projects.list(filters),
  })
}

// 原则3:键需要可序列化(能被 JSON.stringify 稳定处理)
// ❌ 不要把函数、DOM 节点、Symbol 放进 queryKey
// ✅ 使用简单值:string、number、boolean、plain object、array
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Query Key Factory 模式——中大型项目推荐将 key 集中管理:

tsx
// src/lib/query-keys.ts
export const projectKeys = {
  all:    ['projects'] as const,
  lists:  () => [...projectKeys.all, 'list'] as const,
  list:   (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const,
  details: () => [...projectKeys.all, 'detail'] as const,
  detail:  (id: string) => [...projectKeys.details(), id] as const,
  tasks:   (id: string) => [...projectKeys.detail(id), 'tasks'] as const,
}

// 使用
useQuery({ queryKey: projectKeys.list(filters), queryFn: ... })

// 失效时可以精确命中
queryClient.invalidateQueries({ queryKey: projectKeys.lists() })
// 这会失效所有 ['projects', 'list', ...] 的查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tsx
// queryFn 的类型安全写法
interface Project {
  id: string
  name: string
  status: 'active' | 'archived'
}

function useProject(id: string) {
  return useQuery<Project, Error>({
    queryKey: projectKeys.detail(id),
    queryFn: async ({ queryKey }) => {
      const [, , projectId] = queryKey  // 从 queryKey 解构参数
      const res = await fetch(`/api/projects/${projectId}`)
      if (!res.ok) {
        throw new Error(`Failed to fetch project: ${res.status}`)
      }
      return res.json() as Promise<Project>
    },
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

第3部分:缓存与重新获取策略 ​

自动重新获取的触发机制 ​

TanStack Query 提供多个"时机钩子",在检测到特定事件时自动后台重新获取 stale 数据。这些是可配置的开关,全部默认启用。

┌─────────────────────────────────────────────────────────────┐
│           自动重新获取的触发条件                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  触发条件                    配置项                默认值    │
│  ───────────────────────────────────────────────────────    │
│  组件挂载(首次或重新挂载)  refetchOnMount        true      │
│  浏览器窗口重新聚焦          refetchOnWindowFocus  true      │
│  网络断开后重新连接          refetchOnReconnect    true      │
│                                                             │
│  ⚠️ 以上只会重新获取标记为 stale 的查询                      │
│     fresh 数据直接使用缓存,不发请求                          │
│                                                             │
│  全局配置示例:                                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ const queryClient = new QueryClient({               │   │
│  │   defaultOptions: {                                 │   │
│  │     queries: {                                      │   │
│  │       staleTime: 30_000,          // 30s 内不过期   │   │
│  │       gcTime: 5 * 60_000,        // 5min 后 GC      │   │
│  │       retry: 3,                  // 失败重试 3 次   │   │
│  │       refetchOnWindowFocus: true,                   │   │
│  │       refetchOnReconnect: true,                     │   │
│  │     },                                              │   │
│  │   },                                                │   │
│  │ })                                                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

手动失效:invalidateQueries 的精确打击与范围轰炸 ​

Mutation 之后,你需要让受影响的查询重新获取。invalidateQueries 通过 queryKey 前缀匹配来决定哪些查询被标记为 stale 并触发重取。

tsx
const queryClient = useQueryClient()

// 精确失效:只影响 ['projects', projectId] 这一个查询
queryClient.invalidateQueries({
  queryKey: projectKeys.detail(projectId),
})

// 范围失效:影响所有以 ['projects'] 开头的查询
// 包括 projects 列表、各 project 详情等
queryClient.invalidateQueries({
  queryKey: projectKeys.all,
})

// 组合策略:精确更新详情 + 宽泛失效列表
const updateProject = useMutation({
  mutationFn: api.projects.update,
  onSuccess: (updated, variables) => {
    // 1. 立即更新缓存的详情,让当前页面秒级响应
    queryClient.setQueryData(
      projectKeys.detail(variables.id),
      updated,
    )
    // 2. 失效所有列表,让它们在后台静默重取
    queryClient.invalidateQueries({
      queryKey: projectKeys.lists(),
    })
  },
})
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
tsx
// 高级:条件失效 —— 只失效匹配特定条件的结果
queryClient.invalidateQueries({
  predicate: (query) => {
    // 只失效包含特定项目 ID 的查询缓存
    const key = query.queryKey
    return (
      Array.isArray(key) &&
      key[0] === 'projects' &&
      key.includes('list')
    )
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────────┐
│          失效策略对比                                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  策略             操作             网络请求    UI响应速度     │
│  ────────────────────────────────────────────────────────   │
│  invalidateQueries  标记stale     多(所有匹配  慢(等网络)  │
│  (宽泛)           →后台重取      的查询都重取)              │
│                                                             │
│  setQueryData      直接写入缓存    零          即时           │
│  + invalidateQueries 详情秒更      列表重取   详情即时/列表慢  │
│                                                             │
│  setQueryData only  只写缓存       零          即时           │
│                     ⚠️ 其他客户端的修改不会被感知              │
│                                                             │
│  推荐组合:setQueryData 更新已知受影响的缓存 +                │
│           invalidateQueries 让所有相关查询最终与服务端一致     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

手动重新获取 vs 手动失效 ​

tsx
// refetch:强制立即重新获取(无视 staleTime)
const { refetch } = useQuery({ ... })
await refetch()

// invalidate:标记 stale,由下一次触发事件驱动重取
queryClient.invalidateQueries({ queryKey: ['projects'] })

// 如果你需要立即获取但不想写缓存:
// refetch 适合用户点击"刷新"按钮
// invalidateQueries 适合 mutation 后"脏标记"
1
2
3
4
5
6
7
8
9
10

第4部分:Mutation 与乐观更新 ​

useMutation API 概览 ​

与 useQuery 不同,useMutation 不自动执行、不共享结果、不与缓存关联。它是"手动点火"的命令式模型。

tsx
const mutation = useMutation({
  mutationFn: (variables: TVariables) => apiEndpoint(variables),

  // 四个生命周期回调
  onMutate:   async (variables) => { /* 发起前 */ },
  onSuccess:  (data, variables, context) => { /* 成功 */ },
  onError:    (error, variables, context) => { /* 失败 */ },
  onSettled:  (data, error, variables, context) => { /* 无论如何 */ },

  // 重试策略(仅对瞬时错误重试)
  retry: 0,       // mutation 默认不重试(与 query 相反,合理)
  retryDelay: (attemptIndex, error) => Math.min(1000 * 2 ** attemptIndex, 30000),
})

// 调用
mutation.mutate({ name: 'New Project' })
mutation.mutateAsync({ name: 'New Project' })  // 返回 Promise
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

onMutate 的 context 机制是乐观更新的核心。onMutate 返回的值会作为 context 参数传递给 onSuccess、onError 和 onSettled——通常用于传递回滚所需的快照。

乐观更新完整流程 ​

┌─────────────────────────────────────────────────────────────┐
│           乐观更新完整协议                                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  用户点击 "完成" ──▶ 预期成功率极高 ──▶ 不想等网络            │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │                                                      │  │
│  │  Step 1: onMutate                                   │  │
│  │  ┌──────────────────────────────────────────────┐   │  │
│  │  │  ① cancelQueries —— 取消正在进行的相关请求    │   │  │
│  │  │     防止旧请求返回后覆盖乐观更新               │   │  │
│  │  │                                              │   │  │
│  │  │  ② getQueryData —— 保存当前缓存快照           │   │  │
│  │  │     这是回滚的"保险"                          │   │  │
│  │  │                                              │   │  │
│  │  │  ③ setQueryData —— 立即写入新数据             │   │  │
│  │  │     UI 秒级更新,用户感觉不到延迟             │   │  │
│  │  │                                              │   │  │
│  │  │  ④ return { previous } —— 传递快照给回调      │   │  │
│  │  └──────────────────────────────────────────────┘   │  │
│  │                      │                              │  │
│  │                      ▼                              │  │
│  │  Step 2: 发起网络请求 mutationFn                   │  │
│  │          │                                          │  │
│  │     ┌────┴────┐                                     │  │
│  │     ▼         ▼                                     │  │
│  │   成功       失败                                    │  │
│  │  onSuccess    onError                               │  │
│  │  ┌──────┐    ┌──────────────────────────────┐      │  │
│  │  │ 可选  │    │ setQueryData(previous)       │      │  │
│  │  │ 精细  │    │ → 恢复快照,UI 回退到操作前  │      │  │
│  │  │ 更新  │    │ → 展示错误提示               │      │  │
│  │  └──────┘    └──────────────────────────────┘      │  │
│  │          │                              │           │  │
│  │          └──────────┬───────────────────┘           │  │
│  │                     ▼                               │  │
│  │  Step 3: onSettled —— 无论如何都执行               │  │
│  │  ┌──────────────────────────────────────────────┐   │  │
│  │  │  invalidateQueries —— 确保缓存与服务端一致    │   │  │
│  │  │  即使乐观更新正确,也通过重取确认最终数据     │   │  │
│  │  │  服务端可能有额外的计算(时间戳、自动字段)   │   │  │
│  │  └──────────────────────────────────────────────┘   │  │
│  │                                                      │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

完整代码实现 ​

tsx
interface Todo {
  id: string
  title: string
  completed: boolean
}

function useToggleTodo() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (todo: Todo) =>
      api.todos.update(todo.id, { completed: !todo.completed }),

    onMutate: async (toggledTodo) => {
      // ═══ Step 1: 取消进行中的查询 ═══
      await queryClient.cancelQueries({
        queryKey: todoKeys.lists(),
      })

      // ═══ Step 2: 保存当前快照 ═══
      const previousTodos = queryClient.getQueryData<Todo[]>(
        todoKeys.list('all'),
      )

      // ═══ Step 3: 乐观更新 ═══
      queryClient.setQueryData<Todo[]>(
        todoKeys.list('all'),
        (old) =>
          old?.map((t) =>
            t.id === toggledTodo.id
              ? { ...t, completed: !t.completed }
              : t,
          ) ?? [],
      )

      // ═══ Step 4: 返回回滚上下文 ═══
      return { previousTodos }
    },

    onError: (_error, _todo, context) => {
      // ═══ 回滚:恢复快照 ═══
      if (context?.previousTodos) {
        queryClient.setQueryData(
          todoKeys.list('all'),
          context.previousTodos,
        )
      }
      toast.error('操作失败,已回滚')
    },

    onSettled: () => {
      // ═══ 最终一致性:重取确认 ═══
      queryClient.invalidateQueries({
        queryKey: todoKeys.lists(),
      })
    },
  })
}
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

cancelQueries vs setQueryData 的分工 ​

cancelQueries 取消的是正在飞行中的 query 请求 —— 如果不在 onMutate 中 cancel,旧的列表请求可能在 optimistic 更新写入后返回,覆盖掉乐观更新的结果。

setQueryData 直接写入缓存。它不触发网络请求,不改变 freshness 状态 —— 数据被标记为什么状态取决于你同时调用了什么。

tsx
// 典型错误:直接 setQueryData 但不 cancel
// 时序问题:
//   t0: 用户滚动,触发列表请求(需要 500ms)
//   t2: 用户点击删除
//   t2: onMutate → setQueryData(乐观删除)
//   t4: t0 的请求返回 → 覆盖了 t2 的乐观删除!
//       用户看到被删除的项又出现了

// 正确做法:onMutate 中先 cancel
onMutate: async (id) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] })
  // 现在安全了 —— 没有飞行中的请求会覆盖我们
  const previous = queryClient.getQueryData(['todos'])
  queryClient.setQueryData(['todos'], (old) =>
    old?.filter((t) => t.id !== id),
  )
  return { previous }
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第5部分:分页与无限滚动 ​

useInfiniteQuery 的工作原理 ​

┌─────────────────────────────────────────────────────────────┐
│          useInfiniteQuery 数据模型                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  与 useQuery 的核心区别:                                     │
│  useQuery:           data = 最后一次查询的结果                │
│  useInfiniteQuery:   data.pages = 所有页码的数组             │
│                      data.pageParams = 每页的游标/偏移量      │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ data: {                                            │   │
│  │   pages: [                                         │   │
│  │     [{ id:1 }, { id:2 }, { id:3 }],  // 第1页     │   │
│  │     [{ id:4 }, { id:5 }, { id:6 }],  // 第2页     │   │
│  │     [{ id:7 }, { id:8 }, { id:9 }],  // 第3页     │   │
│  │   ],                                               │   │
│  │   pageParams: [undefined, 'cursor_3', 'cursor_6'], │   │
│  │ }                                                  │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  关键方法:                                                  │
│  fetchNextPage()  → 请求下一页                               │
│  fetchPreviousPage() → 请求上一页(双向)                    │
│  hasNextPage      → getNextPageParam 的返回值是否为非空      │
│  isFetchingNextPage → 正在获取下一页(用于底部加载指示器)    │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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
tsx
// 游标分页实现
interface PaginatedResponse<T> {
  data: T[]
  nextCursor: string | null
}

function useInfiniteProjects(pageSize: number = 20) {
  return useInfiniteQuery<Project[], Error>({
    queryKey: ['projects', 'infinite', pageSize],
    queryFn: async ({ pageParam }) => {
      const cursor = pageParam as string | undefined
      const params = new URLSearchParams({ limit: String(pageSize) })
      if (cursor) params.set('cursor', cursor)
      const res = await fetch(`/api/projects?${params}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
  })
}

// 在组件中使用
function InfiniteProjectList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteProjects(20)

  if (status === 'pending') return <Spinner />
  if (status === 'error') return <ErrorBanner />

  return (
    <div>
      {data.pages.map((page, i) => (
        <Fragment key={i}>
          {page.data.map((project) => (
            <ProjectCard key={project.id} project={project} />
          ))}
        </Fragment>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? '加载中...'
          : hasNextPage
            ? '加载更多'
            : '已加载全部'}
      </button>
    </div>
  )
}
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

游标分页 vs Offset 分页 ​

┌─────────────────────────────────────────────────────────────┐
│           游标分页 vs Offset 分页                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Offset 分页(page=3&limit=20 → 跳过前 40 条)               │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ ✅ 实现简单,前端可以跳转到任意页                    │   │
│  │ ❌ 分页期间插入/删除会导致数据重复或丢失             │   │
│  │    例:正在读第2页时有人删了第1页的一条,            │   │
│  │        第2页的所有数据会整体前移,出现重叠           │   │
│  │ ❌ 大偏移量的性能差(OFFSET 10000 仍需扫描前10000行)│   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  游标分页(cursor=eyJpZCI6IjQyIn0=&limit=20 → id>42 的20条)│
│  ┌─────────────────────────────────────────────────────┐   │
│  │ ✅ 不受并发修改影响(基于不变的数据位置)            │   │
│  │ ✅ 性能稳定(WHERE id > ? LIMIT 20 走索引)         │   │
│  │ ❌ 不能跳页(只能"上一页""下一页")                 │   │
│  │ ❌ 服务端需要额外实现游标的编码/解码逻辑             │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  推荐:对数据频繁变化的场景(消息、通知、活动日志)使用游标; │
│        对管理后台(数据相对稳定,需要跳页)可用 offset          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

双向无限滚动 ​

聊天应用和日志查看器经常需要双向滚动(向上加载更早消息,向下加载更新消息)。

tsx
function useChatMessages(conversationId: string) {
  return useInfiniteQuery({
    queryKey: ['messages', conversationId],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(
        `/api/conversations/${conversationId}/messages?cursor=${pageParam ?? ''}`,
      )
      return res.json()
    },
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
    // 从"最新"开始,允许向上翻
    maxPages: 10, // 限制内存占用,防止无限累积
  })
}

// 配合 IntersectionObserver 实现自动加载
function ChatView({ conversationId }: { conversationId: string }) {
  const { data, fetchNextPage, fetchPreviousPage } =
    useChatMessages(conversationId)

  const topRef = useRef<HTMLDivElement>(null)
  const bottomRef = useRef<HTMLDivElement>(null)

  // 向上滚动 → 加载更早的消息
  useIntersectionObserver(topRef, fetchPreviousPage)
  // 向下滚动 → 加载更新的消息
  useIntersectionObserver(bottomRef, fetchNextPage)

  // 渲染所有 page 中的消息...
}
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

第6部分:预取策略 ​

预取的时机与方式 ​

┌─────────────────────────────────────────────────────────────┐
│           预取策略矩阵                                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  策略                  触发时机            适用场景           │
│  ───────────────────────────────────────────────────────    │
│  鼠标悬停预取          用户光标停在链接上   导航菜单、卡片    │
│  (hover prefetch)      或按钮上                               │
│                                                             │
│  路由级预取            路由匹配即将进入时   列表→详情页导航   │
│  (route prefetch)                                          │
│                                                             │
│  SSR 预取              服务端渲染时        首屏关键数据      │
│  (server prefetch)                                          │
│                                                             │
│  主动预取              特定用户行为后      表单提交后的       │
│  (eager prefetch)      (如搜索前)        下一步数据        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

鼠标悬停预取 ​

tsx
function ProjectLink({ project }: { project: ProjectSummary }) {
  const queryClient = useQueryClient()

  const prefetchProject = () => {
    queryClient.prefetchQuery({
      queryKey: projectKeys.detail(project.id),
      queryFn: () => api.projects.get(project.id),
      staleTime: 60_000, // 预取的数据 60s 内保持新鲜
    })
  }

  return (
    <Link
      href={`/projects/${project.id}`}
      onMouseEnter={prefetchProject}
      onFocus={prefetchProject}  // 键盘导航也触发
    >
      {project.name}
    </Link>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

SSR 预取:Next.js App Router 集成 ​

tsx
// app/projects/page.tsx —— 服务端组件
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function ProjectsPage() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        // 服务端预取的数据:服务端不设 staleTime,客户端由 hydration 接管
        staleTime: 30_000,
      },
    },
  })

  // 在服务端预取首屏数据
  await queryClient.prefetchQuery({
    queryKey: projectKeys.list({ page: 1 }),
    queryFn: () => api.projects.list({ page: 1 }),
  })

  // 也可以并行预取多个查询
  await Promise.all([
    queryClient.prefetchQuery({
      queryKey: ['user', 'preferences'],
      queryFn: fetchUserPreferences,
    }),
    queryClient.prefetchQuery({
      queryKey: ['notifications', 'count'],
      queryFn: fetchNotificationCount,
    }),
  ])

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProjectsClient />
    </HydrationBoundary>
  )
}
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
tsx
// app/projects/ProjectsClient.tsx —— 客户端组件
'use client'

function ProjectsClient() {
  // useQuery 会立即命中服务端预取的缓存,不发请求
  const { data } = useQuery({
    queryKey: projectKeys.list({ page: 1 }),
    queryFn: () => api.projects.list({ page: 1 }),
  })

  return <ProjectTable data={data} />
}
1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────────┐
│           SSR Hydration 陷阱                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ❌ 陷阱1:将用户 A 的缓存脱水给用户 B                        │
│     服务端的 QueryClient 必须是请求级别的(每个请求 new)     │
│     不能复用模块级单例 —— 数据会跨用户泄露                    │
│                                                             │
│  ❌ 陷阱2:staleTime 不合理导致 hydration 后立即重复请求      │
│     服务端预取的数据默认 staleTime = 0(立即 stale)         │
│     客户端挂载后立即触发重取 → 首屏闪烁,SSR 白做           │
│     解决:为预取查询设合理 staleTime,或使用 initialData      │
│                                                             │
│  ❌ 陷阱3:服务端和客户端 queryKey 不一致                     │
│     hydration 匹配靠的是 queryKey,不匹配则缓存未命中        │
│     使用共享的 queryKey factory 确保一致性                   │
│                                                             │
│  ❌ 陷阱4:把不可序列化字段放进脱水数据                        │
│     dehydrate 使用 JSON.stringify,Date → 字符串             │
│     在查询函数中做反序列化,或使用 select 转换                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

路由级预取 ​

tsx
// 在 Next.js 中使用 Link 的 prefetch 配合 TanStack Query
// Next.js 默认在视口中 prefetch <Link> 的目标页面 JS bundle
// 但业务数据需要我们自己处理

function ProjectList() {
  const queryClient = useQueryClient()
  const projects = useProjects()

  // 当列表数据加载完成后,预热所有可见项的详情
  useEffect(() => {
    if (!projects.data) return
    for (const project of projects.data.slice(0, 10)) {
      queryClient.prefetchQuery({
        queryKey: projectKeys.detail(project.id),
        queryFn: () => api.projects.get(project.id),
        staleTime: 60_000,
      })
    }
  }, [projects.data, queryClient])

  return (
    <div>
      {projects.data?.map((p) => (
        <ProjectRow key={p.id} project={p} />
      ))}
    </div>
  )
}
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

第7部分:性能优化 ​

选择器 (select):数据派生与渲染优化 ​

select 让你从查询结果中提取或转换数据。关键优势:只有当 select 的输出改变时,组件才会重渲染 —— 即使原始 data 对象变化了。

tsx
// 原始数据是大而全的 Project 对象
function useProjectName(projectId: string) {
  return useQuery({
    queryKey: projectKeys.detail(projectId),
    queryFn: () => api.projects.get(projectId),
    select: (project) => project.name,  // 只提取名称
  })
  // data 的类型推断为 string,不再是 Project
}

// 适合场景:组件只需要一个字段,但查询返回整个对象
function ProjectStatusBadge({ projectId }: { projectId: string }) {
  const { data: status } = useQuery({
    queryKey: projectKeys.detail(projectId),
    queryFn: () => api.projects.get(projectId),
    select: (project) => project.status,
  })
  // 只有 status 字段变化时才重渲染
  return <Badge variant={status}>{status}</Badge>
}

// 复杂派生:汇总计算
function ProjectStats() {
  const { data: stats } = useQuery({
    queryKey: projectKeys.list({}),
    queryFn: () => api.projects.list({}),
    select: (projects) => ({
      total: projects.length,
      active: projects.filter((p) => p.status === 'active').length,
      archived: projects.filter((p) => p.status === 'archived').length,
    }),
  })

  return (
    <div>
      <Stat label="总项目数" value={stats.total} />
      <Stat label="活跃" value={stats.active} />
      <Stat label="已归档" value={stats.archived} />
    </div>
  )
}
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

Structural Sharing(结构共享 / 引用稳定性) ​

这是 TanStack Query 默认启用的关键优化。当你执行 setQueryData 或查询重取时,如果新旧数据在值上相等(deep equal),TanStack Query 会保留旧对象的引用。

tsx
// 原理示意(TanStack Query 内部使用 replaceEqualDeep)
// 如果旧数据 = [{ id: 1, name: 'A' }]
// 新数据 =     [{ id: 1, name: 'A' }](内容相同,引用不同)
// 则返回旧引用 → React.memo 的浅比较不会触发重渲染

// 例外:对于频繁变化的数据(如股票行情),

// 你可以关闭 structural sharing:
useQuery({
  queryKey: ['stock-price', symbol],
  queryFn: () => api.stocks.getPrice(symbol),
  structuralSharing: false, // 每次都返回新引用
})
1
2
3
4
5
6
7
8
9
10
11
12
13

queryClient 单例与测试 ​

┌─────────────────────────────────────────────────────────────┐
│          QueryClient 生命周期管理                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  生产环境:一个应用一个 QueryClient 实例                       │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ import { QueryClient, QueryClientProvider }         │   │
│  │ from '@tanstack/react-query'                        │   │
│  │                                                     │   │
│  │ const queryClient = new QueryClient({               │   │
│  │   defaultOptions: { /* 全局默认配置 */ },            │   │
│  │ })                                                  │   │
│  │                                                     │   │
│  │ function App() {                                    │   │
│  │   return (                                          │   │
│  │     <QueryClientProvider client={queryClient}>      │   │
│  │       <Router />                                    │   │
│  │     </QueryClientProvider>                          │   │
│  │   )                                                 │   │
│  │ }                                                   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  测试环境:每个测试用例一个独立的 QueryClient                  │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ function createTestQueryClient() {                  │   │
│  │   return new QueryClient({                          │   │
│  │     defaultOptions: {                               │   │
│  │       queries: {                                    │   │
│  │         retry: false,        // 测试中不重试        │   │
│  │         gcTime: Infinity,    // 不自动 GC           │   │
│  │       },                                            │   │
│  │       mutations: {                                  │   │
│  │         retry: false,                               │   │
│  │       },                                            │   │
│  │     },                                              │   │
│  │   })                                                │   │
│  │ }                                                   │   │
│  │                                                     │   │
│  │ function TestWrapper({ children }) {                │   │
│  │   const [queryClient] = useState(createTestQueryClient)│ │
│  │   return (                                          │   │
│  │     <QueryClientProvider client={queryClient}>      │   │
│  │       {children}                                    │   │
│  │     </QueryClientProvider>                          │   │
│  │   )                                                 │   │
│  │ }                                                   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
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

持久化:persistQueryClient ​

对于需要离线缓存的应用(PWA、移动端 WebView),可以将缓存持久化到 localStorage / IndexedDB。

tsx
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24h —— 持久化场景下给足够长的时间
    },
  },
})

const persister = createSyncStoragePersister({
  storage: typeof window !== 'undefined' ? window.localStorage : undefined,
  throttleTime: 1000, // 最多每秒写入一次
})

persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24,   // 持久化数据最长保留 24h
  buster: process.env.NEXT_PUBLIC_APP_VERSION ?? 'v1', // 版本变更时清空缓存
  dehydrateOptions: {
    shouldDehydrateQuery: (query) => {
      // 只持久化有价值的查询(跳过实时数据、敏感数据)
      const key = query.queryKey
      if (key[0] === 'stock-price') return false
      if (key[0] === 'auth') return false
      return true
    },
  },
})
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
┌─────────────────────────────────────────────────────────────┐
│          window focus refetch 与用户体验                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  refetchOnWindowFocus = true 是默认行为,但有代价:           │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ ✅ 用户在另一个标签页做了操作 → 切回来自动看到最新   │   │
│  │ ✅ 长时间闲置后回到页面 → 数据不会"陈旧"             │   │
│  │                                                     │   │
│  │ ❌ 频繁切换标签页 → 大量不必要的网络请求             │   │
│  │ ❌ 移动端 App 切换 → 可能触发大量并发请求            │   │
│  │                                                     │   │
│  │  精细化控制:                                        │   │
│  │  refetchOnWindowFocus: 'always'  // 即使 fresh 也取 │   │
│  │  refetchOnWindowFocus: true      // 只取 stale 的   │   │
│  │  refetchOnWindowFocus: false     // 完全不取        │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
│  建议:协作应用保持开启;内容站点可以关闭。                    │
│        也可按查询级别覆盖全局策略。                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

核心总结 ​

概念一句话总结
服务端 vs 客户端状态服务端状态是异步、可过期、远端拥有的;不要把它复制进客户端 Store
queryKey缓存的唯一地址,从通用到具体,包含所有依赖参数,使用工厂函数管理
staleTime数据被视为"新鲜"的时长:在此期间跳过自动重取
gcTime无订阅者后内存中保留的时长:给用户返回留出窗口
useQuery 生命周期fetching → fresh → stale → inactive → garbage collected
invalidateQueries标记查询为 stale 并触发后台重取,靠 queryKey 前缀匹配
乐观更新协议cancelQueries → 保存快照 → setQueryData → 失败回滚 → onSettled 重验证
useInfiniteQuery累积多页数据到 data.pages[],通过 getNextPageParam 驱动游标
select数据转换 + 渲染优化:只有 select 输出变化时才重渲染
SSR Hydration每个请求 new QueryClient → prefetch → dehydrate → HydrationBoundary

章节测试 ​

  1. 选择题:一个查询组件卸载 2 分钟后重新挂载。staleTime = 30s, gcTime = 5min。重新挂载时会发生什么? A) 展示缓存数据,不发起网络请求 B) 展示缓存数据,并在后台重新获取 C) 展示 loading 状态,重新获取数据 D) 抛出错误,缓存已被清除

  2. 填空题:在乐观更新中,onMutate 回调返回的对象被称为 ______,它会被传递给 onError 用于回滚。

  3. 判断题:invalidateQueries 会立即发起网络请求,与 refetch 行为相同。

  4. 简答题:为什么在 onMutate 中需要先调用 cancelQueries 再调用 setQueryData?如果颠倒顺序会发生什么?

  5. 设计题:你有一个项目列表页面(支持筛选和分页),点击进入项目详情页(可编辑)。请设计:

    • 查询键层级结构
    • 详情页编辑保存后的失效策略
    • 如何利用预取优化从列表到详情的导航体验

参考答案 ​

  1. B。数据在 gcTime 内(5min > 2min),缓存仍存在,组件挂载时可以立即展示缓存。但由于 staleTime(30s)已过期,数据是 stale 的,会在后台重新获取。

  2. context。onMutate 的返回值作为 context 参数传递给 onSuccess/onError/onSettled,通常用于传递回滚所需的快照数据。

  3. 错误。invalidateQueries 只是将匹配的查询标记为 stale,实际的网络请求由这些查询的观察者(活跃的 useQuery 钩子)在下一个渲染周期触发。而 refetch 是立即发起的。

  4. 如果不在 onMutate 中 cancel,可能存在飞行中的旧请求在乐观更新写入缓存后返回,覆盖掉乐观更新的结果(race condition)。Cancel 必须在 setQueryData 之前,因为 cancel 是异步的 —— 你需要确保在写入前所有旧请求都已取消。颠倒顺序会导致:先写了新数据,旧请求返回后覆盖了新数据。

  5. 参考方案:

    tsx
    // 键层级
    const projectKeys = {
      all:    ['projects'] as const,
      lists:  () => [...projectKeys.all, 'list'] as const,
      list:   (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const,
    
      details: () => [...projectKeys.all, 'detail'] as const,
      detail:  (id: string) => [...projectKeys.details(), id] as const,
    }
    
    // 编辑保存后的失效策略
    const updateProject = useMutation({
      mutationFn: api.projects.update,
      onSuccess: (updated, variables) => {
        // 精确更新详情缓存(用户正在看这个页面)
        queryClient.setQueryData(
          projectKeys.detail(variables.id),
          updated,
        )
        // 失效所有列表(筛选条件未知,宽泛失效让全部列表重取)
        queryClient.invalidateQueries({
          queryKey: projectKeys.lists(),
        })
      },
    })
    
    // 预取策略:列表页面中,对可见项目的详情做 hover 预取
    // 或列表加载完成后立即预取前 N 项的详情
    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

相关笔记 ​

  • [[01-react-components-and-rendering]] —— 理解渲染与重渲染,是性能优化的基础
  • [[02-hooks-state-and-effects]] —— useEffect 与状态管理,服务端状态手动管理的对比
  • [[04-redux-zustand-state-management]] —— 客户端状态管理,与 TanStack Query 的职责边界
  • [[06-nextjs-data-cache-mutations]] —— Next.js 服务端数据缓存与 Server Actions
  • [[08-testing-performance-production]] —— 测试 Query 和 Mutation 的模式

下一步学习 ​

  • TanStack Query Overview
  • Query Keys
  • Mutations
  • Infinite Queries
  • Important Defaults
  • Window Focus Refetching

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations
下一篇9. React 测试、性能与生产工程 / React Testing, Performance, and Production Engineering

持续记录,持续成长

Copyright © Tidenflow