@qiaopeng/tanstack-query-plus 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,625 +1,3531 @@
1
- # @qiaopeng/tanstack-query-plus
1
+ # @qiaopeng/tanstack-query-plus 完整使用教程
2
2
 
3
- 一个基于 React 与 TanStack Query v5 的增强工具包。文档以循序渐进方式介绍核心能力,参考 React Query 官网的学习路径:从 Provider 到查询,再到高级能力(Suspense/无限加载/批量/持久化/离线/Prefetch/Devtools)。
3
+ > 本教程将带你从零开始,循序渐进地学习如何使用 `@qiaopeng/tanstack-query-plus`。每个章节都会自然地引出下一个概念,帮助你建立完整的知识体系。
4
4
 
5
5
  ## 目录
6
6
 
7
- 1. 安装与环境
8
- 2. 初始化与 Provider
9
- 3. 基础查询(Queries)
10
- 4. Suspense 与 Error 边界
11
- 5. 变更(Mutations)与乐观更新
12
- 6. 无限加载(Infinite Queries)
13
- 7. 批量查询与批量操作
14
- 8. 预取(Prefetch)
15
- 9. 持久化与离线支持
16
- 10. Devtools(可选)
17
- 11. 配置与最佳实践
18
- 12. 子路径导出与常见问题
7
+ 1. [前言:为什么需要这个库?](#1-前言为什么需要这个库)
8
+ 2. [安装与环境准备](#2-安装与环境准备)
9
+ 3. [第一步:配置 Provider](#3-第一步配置-provider)
10
+ 4. [第二步:发起你的第一个查询](#4-第二步发起你的第一个查询)
11
+ 5. [第三步:使用增强查询追踪性能](#5-第三步使用增强查询追踪性能)
12
+ 6. [第四步:管理 Query Key](#6-第四步管理-query-key)
13
+ 7. [第五步:数据变更与乐观更新](#7-第五步数据变更与乐观更新)
14
+ 8. [第六步:无限滚动与分页](#8-第六步无限滚动与分页)
15
+ 9. [第七步:批量查询与仪表盘](#9-第七步批量查询与仪表盘)
16
+ 10. [第八步:智能预取](#10-第八步智能预取)
17
+ 11. [第九步:Suspense 模式](#11-第九步suspense-模式)
18
+ 12. [第十步:离线支持与持久化](#12-第十步离线支持与持久化)
19
+ 13. [第十一步:焦点管理](#13-第十一步焦点管理)
20
+ 14. [第十二步:工具函数与选择器](#14-第十二步工具函数与选择器)
21
+ 15. [最佳实践与常见问题](#15-最佳实践与常见问题)
19
22
 
20
- ## 1. 安装与环境
23
+ ---
21
24
 
22
- - 要求:Node `>=16`、React 18、TanStack Query v5
23
- - 安装:
25
+ ## 1. 前言:为什么需要这个库?
26
+
27
+ 在使用 TanStack Query(原 React Query)时,你可能会遇到以下问题:
28
+
29
+ - **配置繁琐**:每次新项目都要重新配置 staleTime、gcTime、重试策略等
30
+ - **缺乏最佳实践**:不确定什么样的配置才是最优的
31
+ - **重复代码**:乐观更新、错误处理、性能追踪等逻辑需要反复编写
32
+ - **离线支持复杂**:实现离线队列和数据持久化需要大量代码
33
+
34
+ `@qiaopeng/tanstack-query-plus` 就是为了解决这些问题而生的。它在 TanStack Query v5 的基础上,提供了:
35
+
36
+ - 🚀 **开箱即用的最佳实践配置**
37
+ - 🔄 **增强的 Hooks**(性能追踪、慢查询检测、错误日志)
38
+ - 💾 **一键启用的持久化**
39
+ - 📡 **完整的离线支持**
40
+ - ⚡ **多种智能预取策略**
41
+ - 🎯 **内置乐观更新**
42
+
43
+ 接下来,让我们一步步学习如何使用这些功能。
44
+
45
+ ---
46
+
47
+ ## 2. 安装与环境准备
48
+
49
+ ### 2.1 安装核心依赖
50
+
51
+ 首先,安装必需的包:
24
52
 
25
53
  ```bash
26
54
  npm install @qiaopeng/tanstack-query-plus @tanstack/react-query @tanstack/react-query-persist-client
27
55
  ```
28
56
 
29
- - 可选能力(按需安装):
30
- - Devtools:`npm install @tanstack/react-query-devtools`
31
- - InView 预取:`npm install react-intersection-observer`
57
+ 这三个包的作用分别是:
58
+ - `@qiaopeng/tanstack-query-plus`:本库,提供增强功能
59
+ - `@tanstack/react-query`:TanStack Query 核心库
60
+ - `@tanstack/react-query-persist-client`:持久化支持
61
+
62
+ ### 2.2 安装可选依赖
63
+
64
+ 根据你的需求,可以选择安装以下可选依赖:
65
+
66
+ ```bash
67
+ # 开发调试工具(强烈推荐在开发环境使用)
68
+ npm install @tanstack/react-query-devtools
69
+
70
+ # 视口预取功能(如果需要 useInViewPrefetch)
71
+ npm install react-intersection-observer
72
+ ```
73
+
74
+ ### 2.3 环境要求
32
75
 
33
- ## 2. 初始化与 Provider
76
+ 确保你的项目满足以下要求:
77
+ - Node.js >= 16
78
+ - React >= 18
79
+ - TypeScript(推荐,但非必需)
34
80
 
35
- 使用增强版 Provider,自动启用离线监听与持久化(浏览器环境),在 SSR 环境自动降级为普通 Provider:
81
+ 现在环境准备好了,让我们开始配置应用。
82
+
83
+ ---
84
+
85
+ ## 3. 第一步:配置 Provider
86
+
87
+
88
+ 任何使用 TanStack Query 的应用都需要一个 Provider 来提供 QueryClient 实例。本库提供了一个增强版的 Provider,让配置变得更简单。
89
+
90
+ ### 3.1 最简配置
91
+
92
+ 最简单的配置只需要几行代码:
36
93
 
37
94
  ```tsx
38
- import { QueryClient, PersistQueryClientProvider, useIsMutating } from '@qiaopeng/tanstack-query-plus'
95
+ // App.tsx
96
+ import { QueryClient, PersistQueryClientProvider } from '@qiaopeng/tanstack-query-plus'
39
97
  import { GLOBAL_QUERY_CONFIG } from '@qiaopeng/tanstack-query-plus/core'
40
98
 
41
- const queryClient = new QueryClient({ defaultOptions: GLOBAL_QUERY_CONFIG })
99
+ // 创建 QueryClient,使用预配置的最佳实践
100
+ const queryClient = new QueryClient({
101
+ defaultOptions: GLOBAL_QUERY_CONFIG
102
+ })
42
103
 
43
- export function App() {
104
+ function App() {
44
105
  return (
45
- <PersistQueryClientProvider client={queryClient} enablePersistence enableOfflineSupport>
46
- <Root />
106
+ <PersistQueryClientProvider client={queryClient}>
107
+ <YourApp />
47
108
  </PersistQueryClientProvider>
48
109
  )
49
110
  }
50
-
51
- // 统一门面层:直接使用顶层导出的 React Query 运行时
52
- // 如需类型,可从 react-query 子路径获取:
53
- // import type { UseQueryOptions, UseInfiniteQueryOptions, QueryKey } from '@qiaopeng/tanstack-query-plus/react-query'
54
111
  ```
55
112
 
56
- ## 3. 基础查询(Queries)
113
+ 这段代码做了什么?
57
114
 
58
- 最简用法:
115
+ 1. **创建 QueryClient**:使用 `GLOBAL_QUERY_CONFIG` 预配置,包含了经过优化的默认值
116
+ 2. **包裹应用**:`PersistQueryClientProvider` 让所有子组件都能访问 QueryClient
59
117
 
60
- ```tsx
61
- import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
62
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
118
+ ### 3.2 启用持久化和离线支持
63
119
 
64
- function UserProfile({ id }: { id: string }) {
65
- const result = useEnhancedQuery({
66
- queryKey: queryKeys.user(id),
67
- queryFn: async () => {
68
- const res = await fetch(`/api/users/${id}`)
69
- if (!res.ok) throw new Error('Failed to fetch user')
70
- return res.json()
71
- }
72
- })
120
+ `PersistQueryClientProvider` 默认就启用了持久化和离线支持(`enablePersistence` `enableOfflineSupport` 默认都是 `true`)。如果你想显式配置或禁用某些功能:
121
+
122
+ ```tsx
123
+ <PersistQueryClientProvider
124
+ client={queryClient}
125
+ enablePersistence={true} // 启用 localStorage 持久化(默认 true)
126
+ enableOfflineSupport={true} // 启用离线状态监听(默认 true)
127
+ cacheKey="my-app-cache" // 自定义缓存 key(默认 'tanstack-query-cache')
128
+ onPersistRestore={() => console.log('缓存已恢复')} // 缓存恢复回调
129
+ >
130
+ <YourApp />
131
+ </PersistQueryClientProvider>
132
+ ```
73
133
 
74
- if (result.isLoading) return <div>Loading...</div>
75
- if (result.isError) return <div>Error: {String(result.error?.message || 'unknown')}</div>
76
- return <div>{result.data.name}</div>
134
+ **enablePersistence** 的作用:
135
+ - 自动将查询缓存保存到 localStorage
136
+ - 页面刷新后自动恢复缓存数据
137
+ - 用户可以立即看到上次的数据,无需等待网络请求
138
+ - 设为 `false` 可禁用持久化
139
+
140
+ **enableOfflineSupport** 的作用:
141
+ - 监听网络状态变化
142
+ - 离线时暂停请求,在线时自动恢复
143
+ - 配合离线队列管理器使用
144
+ - 设为 `false` 可禁用离线支持
145
+
146
+ ### 3.3 理解预配置
147
+
148
+ `GLOBAL_QUERY_CONFIG` 包含了以下默认值:
149
+
150
+ ```typescript
151
+ {
152
+ queries: {
153
+ staleTime: 30000, // 数据 30 秒内视为新鲜
154
+ gcTime: 600000, // 缓存保留 10 分钟
155
+ retry: smartRetry, // 智能重试(4xx 不重试,5xx 最多重试 3 次)
156
+ retryDelay: exponential, // 指数退避(1s, 2s, 4s...最大 30s)
157
+ refetchOnWindowFocus: true, // 窗口聚焦时刷新
158
+ refetchOnReconnect: true, // 网络恢复时刷新
159
+ },
160
+ mutations: {
161
+ retry: 0, // mutation 默认不重试
162
+ gcTime: 600000,
163
+ }
77
164
  }
78
165
  ```
79
166
 
80
- 更严格的选项工厂(统一最佳实践,例如 staleTime/gcTime、指数退避重试):
167
+ 这些值是经过实践验证的最佳实践,适合大多数应用场景。
168
+
169
+ ### 3.4 根据环境选择配置
170
+
171
+ 本库还提供了针对不同环境的预配置:
81
172
 
82
173
  ```tsx
83
- import { createAppQueryOptions, queryKeys } from '@qiaopeng/tanstack-query-plus/core'
84
- import { useQuery } from '@qiaopeng/tanstack-query-plus/react-query'
174
+ import { getConfigByEnvironment } from '@qiaopeng/tanstack-query-plus/core'
85
175
 
86
- const options = createAppQueryOptions({
87
- queryKey: queryKeys.settings(),
88
- queryFn: () => fetch('/api/settings').then(r => r.json())
89
- })
176
+ // 根据环境自动选择配置
177
+ const config = getConfigByEnvironment(process.env.NODE_ENV)
178
+ const queryClient = new QueryClient({ defaultOptions: config })
179
+ ```
180
+
181
+ 不同环境的配置差异:
182
+
183
+ | 配置项 | development | production | test |
184
+ |--------|-------------|------------|------|
185
+ | staleTime | 0 | 10 分钟 | 0 |
186
+ | retry | 1 | 3 | 0 |
187
+ | refetchOnWindowFocus | true | true | false |
90
188
 
91
- function Settings() {
92
- const result = useQuery(options)
93
- return <pre>{JSON.stringify(result.data, null, 2)}</pre>
189
+ ### 3.5 添加 DevTools(开发环境)
190
+
191
+ 在开发环境中,强烈建议添加 DevTools 来调试查询状态:
192
+
193
+ ```tsx
194
+ import { ReactQueryDevtools, isDevToolsEnabled } from '@qiaopeng/tanstack-query-plus/core/devtools'
195
+
196
+ function App() {
197
+ return (
198
+ <PersistQueryClientProvider client={queryClient}>
199
+ <YourApp />
200
+ {isDevToolsEnabled() && <ReactQueryDevtools initialIsOpen={false} />}
201
+ </PersistQueryClientProvider>
202
+ )
94
203
  }
95
204
  ```
96
205
 
97
- ## 4. Suspense 与 Error 边界
206
+ DevTools 可以让你:
207
+ - 查看所有查询的状态
208
+ - 手动触发 refetch
209
+ - 查看缓存数据
210
+ - 调试查询问题
211
+
212
+ 现在 Provider 配置好了,让我们开始发起第一个查询!
98
213
 
99
- 为 Suspense 查询提供更顺滑的体验,并在错误时给出可重试界面:
214
+ ---
215
+
216
+ ## 4. 第二步:发起你的第一个查询
217
+
218
+ 配置好 Provider 后,我们就可以在组件中使用查询了。
219
+
220
+ ### 4.1 基础查询
221
+
222
+ 最基本的查询可以使用 TanStack Query 原生的 `useQuery`,或者本库提供的增强版 `useEnhancedQuery`:
100
223
 
101
224
  ```tsx
102
- import { SuspenseWrapper, DefaultLoadingFallback } from '@qiaopeng/tanstack-query-plus/components'
103
- import { useEnhancedSuspenseQuery } from '@qiaopeng/tanstack-query-plus/hooks'
104
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
225
+ // 方式一:使用 TanStack Query 原生 useQuery
226
+ import { useQuery } from '@tanstack/react-query'
227
+
228
+ // 方式二:使用本库的增强版(推荐,支持性能追踪等功能)
229
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
105
230
 
106
- function SuspenseUser({ id }: { id: string }) {
107
- const result = useEnhancedSuspenseQuery({
108
- queryKey: queryKeys.user(id),
109
- queryFn: () => fetch(`/api/users/${id}`).then(r => r.json())
231
+ function UserProfile({ userId }) {
232
+ // 两者用法相同,useEnhancedQuery 额外支持性能追踪
233
+ const { data, isLoading, isError, error } = useEnhancedQuery({
234
+ queryKey: ['user', userId], // 查询的唯一标识
235
+ queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), // 获取数据的函数
110
236
  })
111
- return <div>{result.data.name}</div>
237
+
238
+ if (isLoading) return <div>加载中...</div>
239
+ if (isError) return <div>错误: {error.message}</div>
240
+
241
+ return <div>用户名: {data.name}</div>
112
242
  }
243
+ ```
113
244
 
114
- export function Page({ id }: { id: string }) {
115
- return (
116
- <SuspenseWrapper fallback={<DefaultLoadingFallback />}>
117
- <SuspenseUser id={id} />
118
- </SuspenseWrapper>
119
- )
245
+ **关键概念解释:**
246
+
247
+ 1. **queryKey**:查询的唯一标识符,是一个数组。TanStack Query 用它来:
248
+ - 缓存数据
249
+ - 判断是否需要重新请求
250
+ - 在多个组件间共享数据
251
+
252
+ 2. **queryFn**:实际获取数据的异步函数。可以是 fetch、axios 或任何返回 Promise 的函数。
253
+
254
+ 3. **返回值**:
255
+ - `data`:查询成功后的数据
256
+ - `isLoading`:首次加载中
257
+ - `isError`:是否出错
258
+ - `error`:错误对象
259
+
260
+ ### 4.2 条件查询
261
+
262
+ 有时候我们需要在满足某些条件时才发起查询:
263
+
264
+ ```tsx
265
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
266
+
267
+ function UserProfile({ userId }) {
268
+ const { data } = useEnhancedQuery({
269
+ queryKey: ['user', userId],
270
+ queryFn: () => fetchUser(userId),
271
+ enabled: !!userId, // 只有 userId 存在时才查询
272
+ })
273
+
274
+ // ...
120
275
  }
121
276
  ```
122
277
 
123
- ## 5. 变更(Mutations)与乐观更新
278
+ ### 4.3 使用 skipToken 禁用查询
124
279
 
125
- 常规变更:
280
+ 另一种禁用查询的方式是使用 `skipToken`:
126
281
 
127
282
  ```tsx
128
- import { useMutation } from '@qiaopeng/tanstack-query-plus/hooks'
129
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
130
- import { useQueryClient } from '@tanstack/react-query'
283
+ import { useEnhancedQuery, skipToken } from '@qiaopeng/tanstack-query-plus/hooks'
131
284
 
132
- function UpdateUserName({ id }: { id: string }) {
133
- const qc = useQueryClient()
134
- const mutation = useMutation({
135
- mutationFn: async (name: string) =>
136
- fetch(`/api/users/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
137
- .then(r => r.json()),
138
- onSuccess: () => { qc.invalidateQueries({ queryKey: queryKeys.user(id) }) }
285
+ function UserProfile({ userId }) {
286
+ const { data } = useEnhancedQuery({
287
+ queryKey: ['user', userId],
288
+ queryFn: userId ? () => fetchUser(userId) : skipToken,
139
289
  })
140
- return <button onClick={() => mutation.mutate('new name')}>Update</button>
290
+
291
+ // ...
141
292
  }
142
293
  ```
143
294
 
144
- 乐观更新(支持字段映射、回滚与批量处理):详见 `useMutation` `useConditionalOptimisticMutation` 的选项。
295
+ **注意**:`skipToken` 也可以从 `@qiaopeng/tanstack-query-plus` 主包导入,或者从 `@tanstack/react-query` 导入。
145
296
 
146
- ## 6. 无限加载(Infinite Queries)
297
+ `skipToken` 的好处是 TypeScript 类型推断更准确。
147
298
 
148
- 提供三种分页模型的选项工厂,统一分页逻辑:
299
+ ### 4.4 自定义缓存时间
300
+
301
+ 你可以为特定查询设置不同的缓存策略:
149
302
 
150
303
  ```tsx
151
- import { useEnhancedInfiniteQuery, createCursorPaginationOptions, createOffsetPaginationOptions, createPageNumberPaginationOptions } from '@qiaopeng/tanstack-query-plus/hooks'
152
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
304
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
153
305
 
154
- const cursorOptions = createCursorPaginationOptions({
155
- queryKey: queryKeys.posts(),
156
- queryFn: async (cursor) => fetch(`/api/posts?cursor=${cursor ?? ''}`).then(r => r.json()),
157
- initialCursor: null
306
+ const { data } = useEnhancedQuery({
307
+ queryKey: ['user', userId],
308
+ queryFn: () => fetchUser(userId),
309
+ staleTime: 5 * 60 * 1000, // 5 分钟内数据视为新鲜
310
+ gcTime: 30 * 60 * 1000, // 缓存保留 30 分钟
158
311
  })
312
+ ```
159
313
 
160
- const offsetOptions = createOffsetPaginationOptions({
161
- queryKey: queryKeys.posts(),
162
- queryFn: async (offset, limit) => fetch(`/api/posts?offset=${offset}&limit=${limit}`).then(r => r.json()),
163
- limit: 20
164
- })
314
+ **staleTime vs gcTime 的区别:**
165
315
 
166
- const pageNumberOptions = createPageNumberPaginationOptions({
167
- queryKey: queryKeys.posts(),
168
- queryFn: async (page) => fetch(`/api/posts?page=${page}`).then(r => r.json())
169
- })
316
+ - **staleTime**:数据被认为是"新鲜"的时间。在这段时间内,即使组件重新挂载,也不会重新请求。
317
+ - **gcTime**:数据在缓存中保留的时间。超过这个时间,数据会被垃圾回收。
170
318
 
171
- function InfiniteList() {
172
- const result = useEnhancedInfiniteQuery(offsetOptions)
173
- const pages = result.data?.pages ?? []
319
+ 现在你已经会发起基本查询了。但在实际项目中,我们往往需要追踪查询性能、检测慢查询。这就是增强查询的用武之地。
320
+
321
+ ---
322
+
323
+ ## 5. 第三步:使用增强查询追踪性能
324
+
325
+
326
+ `useEnhancedQuery` 是本库的核心 Hook 之一,它在原生 `useQuery` 的基础上增加了性能追踪、慢查询检测和错误日志功能。
327
+
328
+ ### 5.1 基本使用
329
+
330
+ ```tsx
331
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
332
+
333
+ function UserProfile({ userId }) {
334
+ const {
335
+ data,
336
+ isLoading,
337
+ isError,
338
+ error,
339
+ // 增强的返回值
340
+ refetchCount, // 重新获取次数
341
+ lastQueryDuration // 最后一次查询耗时(毫秒)
342
+ } = useEnhancedQuery({
343
+ queryKey: ['user', userId],
344
+ queryFn: () => fetchUser(userId),
345
+ })
346
+
347
+ if (isLoading) return <div>加载中...</div>
348
+ if (isError) return <div>错误: {error.message}</div>
349
+
174
350
  return (
175
351
  <div>
176
- {pages.map((p, i) => (
177
- <div key={i}>{p.items.length} items</div>
178
- ))}
179
- <button disabled={!result.hasNextPage || result.isFetchingNextPage} onClick={() => result.fetchNextPage()}>
180
- 下一页
181
- </button>
352
+ <h1>{data.name}</h1>
353
+ <p className="text-sm text-gray-500">
354
+ 查询耗时: {lastQueryDuration}ms | 刷新次数: {refetchCount}
355
+ </p>
182
356
  </div>
183
357
  )
184
358
  }
185
359
  ```
186
360
 
187
- ## 7. 批量查询与批量操作
361
+ ### 5.2 启用性能追踪
188
362
 
189
- 在多查询场景下聚合统计并提供批量操作工具:
363
+ 要追踪查询性能,需要显式启用 `trackPerformance`:
190
364
 
191
365
  ```tsx
192
- import { useEnhancedQueries, batchQueryUtils } from '@qiaopeng/tanstack-query-plus/hooks'
193
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
366
+ const { data, lastQueryDuration } = useEnhancedQuery({
367
+ queryKey: ['user', userId],
368
+ queryFn: () => fetchUser(userId),
369
+ trackPerformance: true, // 启用性能追踪
370
+ })
371
+ ```
194
372
 
195
- function Dashboard() {
196
- const { data: results, stats, operations } = useEnhancedQueries([
197
- { queryKey: queryKeys.users(), queryFn: () => fetch('/api/users').then(r => r.json()) },
198
- { queryKey: queryKeys.posts(), queryFn: () => fetch('/api/posts').then(r => r.json()) }
199
- ])
373
+ 启用后,`lastQueryDuration` 会记录每次查询的耗时。
374
+
375
+ ### 5.3 检测慢查询
376
+
377
+ 在生产环境中,检测慢查询对于性能优化至关重要:
378
+
379
+ ```tsx
380
+ const { data } = useEnhancedQuery({
381
+ queryKey: ['user', userId],
382
+ queryFn: () => fetchUser(userId),
383
+ trackPerformance: true,
384
+ slowQueryThreshold: 2000, // 超过 2 秒视为慢查询
385
+ onSlowQuery: (duration, queryKey) => {
386
+ // 上报到监控系统
387
+ analytics.track('slow_query', {
388
+ queryKey: JSON.stringify(queryKey),
389
+ duration,
390
+ })
391
+ console.warn(`慢查询警告: ${JSON.stringify(queryKey)} 耗时 ${duration}ms`)
392
+ },
393
+ })
394
+ ```
395
+
396
+ **实际应用场景:**
397
+
398
+ 1. **性能监控**:将慢查询上报到 APM 系统(如 Sentry、DataDog)
399
+ 2. **开发调试**:在开发环境中快速发现性能问题
400
+ 3. **用户体验优化**:识别需要优化的 API 接口
401
+
402
+ ### 5.4 错误日志
403
+
404
+ `useEnhancedQuery` 默认在开发环境自动记录错误:
405
+
406
+ ```tsx
407
+ const { data } = useEnhancedQuery({
408
+ queryKey: ['user', userId],
409
+ queryFn: () => fetchUser(userId),
410
+ logErrors: true, // 默认在开发环境为 true
411
+ })
412
+ ```
413
+
414
+ 当查询出错时,控制台会输出:
415
+ ```
416
+ [useEnhancedQuery Error] ["user","123"]: Error: Network request failed
417
+ ```
418
+
419
+ 如果你想在生产环境禁用错误日志:
420
+
421
+ ```tsx
422
+ logErrors: process.env.NODE_ENV === 'development'
423
+ ```
424
+
425
+ ### 5.5 完整示例:带监控的用户详情页
426
+
427
+ ```tsx
428
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
429
+
430
+ function UserDetailPage({ userId }) {
431
+ const {
432
+ data: user,
433
+ isLoading,
434
+ isError,
435
+ error,
436
+ refetchCount,
437
+ lastQueryDuration,
438
+ refetch
439
+ } = useEnhancedQuery({
440
+ queryKey: ['user', userId],
441
+ queryFn: async () => {
442
+ const response = await fetch(`/api/users/${userId}`)
443
+ if (!response.ok) throw new Error('获取用户失败')
444
+ return response.json()
445
+ },
446
+ trackPerformance: true,
447
+ slowQueryThreshold: 3000,
448
+ onSlowQuery: (duration, queryKey) => {
449
+ // 发送到监控系统
450
+ reportSlowQuery({ queryKey, duration })
451
+ },
452
+ })
453
+
454
+ if (isLoading) {
455
+ return <LoadingSkeleton />
456
+ }
457
+
458
+ if (isError) {
459
+ return (
460
+ <ErrorDisplay
461
+ message={error.message}
462
+ onRetry={() => refetch()}
463
+ />
464
+ )
465
+ }
200
466
 
201
467
  return (
202
468
  <div>
203
- <div>成功率:{stats.successRate.toFixed(2)}%</div>
204
- <button onClick={() => operations.refetchAll()}>刷新全部</button>
205
- {batchQueryUtils.hasError(results) && <div>部分查询失败</div>}
469
+ <UserCard user={user} />
470
+
471
+ {/* 开发环境显示调试信息 */}
472
+ {process.env.NODE_ENV === 'development' && (
473
+ <div className="mt-4 p-2 bg-gray-100 text-xs">
474
+ <p>查询耗时: {lastQueryDuration}ms</p>
475
+ <p>刷新次数: {refetchCount}</p>
476
+ </div>
477
+ )}
206
478
  </div>
207
479
  )
208
480
  }
209
481
  ```
210
482
 
211
- ## 8. 预取(Prefetch)
483
+ 现在你已经掌握了增强查询的使用。但你可能注意到,我们一直在手写 queryKey,比如 `['user', userId]`。随着项目变大,管理这些 key 会变得困难。接下来,让我们学习如何优雅地管理 Query Key。
484
+
485
+ ---
486
+
487
+ ## 6. 第四步:管理 Query Key
488
+
489
+ Query Key 是 TanStack Query 的核心概念。好的 Key 管理策略可以让你的代码更易维护、更不容易出错。
490
+
491
+ ### 6.1 为什么需要管理 Query Key?
492
+
493
+ 考虑以下场景:
494
+
495
+ ```tsx
496
+ // 组件 A
497
+ useQuery({ queryKey: ['user', userId], ... })
498
+
499
+ // 组件 B
500
+ useQuery({ queryKey: ['users', userId], ... }) // 拼写错误!
501
+
502
+ // 组件 C - 需要失效用户缓存
503
+ queryClient.invalidateQueries({ queryKey: ['user', userId] })
504
+ ```
505
+
506
+ 问题:
507
+ 1. 拼写错误导致缓存不共享
508
+ 2. 修改 key 结构时需要全局搜索替换
509
+ 3. 没有类型提示
510
+
511
+ ### 6.2 使用内置的 Key 工厂
212
512
 
213
- 提供多种预取策略:悬停、路由变化、智能预取、视口内预取(可选依赖),并支持 `minInterval` 节流与 `stale` 检查。
513
+ 本库提供了一套预定义的 Key 工厂:
214
514
 
215
515
  ```tsx
216
- import { useEffect } from 'react'
217
- import { useHoverPrefetch, useRoutePrefetch, useSmartPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
218
516
  import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
219
517
 
220
- function LinkWithPrefetch({ id }: { id: string }) {
221
- const hover = useHoverPrefetch(queryKeys.user(id), () => fetch(`/api/users/${id}`).then(r => r.json()), { hoverDelay: 200, minInterval: 1000 })
222
- return <a href={`/user/${id}`} {...hover}>用户详情</a>
223
- }
518
+ // 用户相关
519
+ queryKeys.users() // ['tanstack-query', 'users']
520
+ queryKeys.user('123') // ['tanstack-query', 'users', '123']
521
+ queryKeys.userProfile('123') // ['tanstack-query', 'users', '123', 'profile']
522
+ queryKeys.userSettings('123') // ['tanstack-query', 'users', '123', 'settings']
523
+ queryKeys.usersByRole('admin') // ['tanstack-query', 'users', 'by-role', 'admin']
524
+
525
+ // 文章相关
526
+ queryKeys.posts() // ['tanstack-query', 'posts']
527
+ queryKeys.post('456') // ['tanstack-query', 'posts', '456']
528
+ queryKeys.postsByUser('123') // ['tanstack-query', 'posts', 'by-user', '123']
529
+ queryKeys.postComments('456') // ['tanstack-query', 'posts', '456', 'comments']
530
+
531
+ // 搜索
532
+ queryKeys.search('react', 'posts') // ['tanstack-query', 'search', { query: 'react', type: 'posts' }]
533
+
534
+ // 通知
535
+ queryKeys.notifications() // ['tanstack-query', 'notifications']
536
+ queryKeys.unreadNotifications() // ['tanstack-query', 'notifications', 'unread']
537
+ ```
224
538
 
225
- function RouterChange() {
226
- const prefetch = useRoutePrefetch()
227
- useEffect(() => {
228
- prefetch(queryKeys.settings(), () => fetch('/api/settings').then(r => r.json()), { minInterval: 1000 })
229
- }, [])
230
- return null
539
+ **使用示例:**
540
+
541
+ ```tsx
542
+ import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
543
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
544
+
545
+ function UserProfile({ userId }) {
546
+ const { data } = useEnhancedQuery({
547
+ queryKey: queryKeys.user(userId), // 类型安全,不会拼错
548
+ queryFn: () => fetchUser(userId),
549
+ })
550
+
551
+ // ...
231
552
  }
232
553
 
233
- function SmartCard({ id }: { id: string }) {
234
- const { prefetch } = useSmartPrefetch()
235
- return <div onMouseEnter={() => prefetch(queryKeys.user(id), () => fetch(`/api/users/${id}`).then(r => r.json()), { minInterval: 1000 })}>卡片</div>
554
+ // 失效缓存时也使用同样的 key
555
+ function useUpdateUser() {
556
+ const queryClient = useQueryClient()
557
+
558
+ return useMutation({
559
+ mutationFn: updateUser,
560
+ onSuccess: (_, { userId }) => {
561
+ // 失效该用户的所有相关缓存
562
+ queryClient.invalidateQueries({ queryKey: queryKeys.user(userId) })
563
+ }
564
+ })
236
565
  }
237
566
  ```
238
567
 
239
- 视口内预取(可选依赖):
568
+ ### 6.3 创建自定义域 Key 工厂
569
+
570
+ 对于内置 Key 工厂没有覆盖的业务领域,可以创建自定义工厂:
240
571
 
241
572
  ```tsx
242
- import { useInViewPrefetch } from '@qiaopeng/tanstack-query-plus/hooks/inview'
243
- import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
573
+ import { createDomainKeyFactory } from '@qiaopeng/tanstack-query-plus/core'
574
+
575
+ // 创建产品域的 Key 工厂
576
+ const productKeys = createDomainKeyFactory('products')
577
+
578
+ productKeys.all() // ['tanstack-query', 'products']
579
+ productKeys.lists() // ['tanstack-query', 'products', 'list']
580
+ productKeys.list({ page: 1 }) // ['tanstack-query', 'products', 'list', { page: 1 }]
581
+ productKeys.details() // ['tanstack-query', 'products', 'detail']
582
+ productKeys.detail('abc') // ['tanstack-query', 'products', 'detail', 'abc']
583
+ productKeys.subResource('abc', 'reviews') // ['tanstack-query', 'products', 'detail', 'abc', 'reviews']
584
+ productKeys.byRelation('category', 'electronics') // ['tanstack-query', 'products', 'by-category', 'electronics']
585
+ ```
244
586
 
245
- function PrefetchOnView() {
246
- const ref = useInViewPrefetch(queryKeys.posts(), () => fetch('/api/posts').then(r => r.json()), { threshold: 0.2 })
247
- return <div ref={ref} />
248
- }
587
+ **实际项目中的组织方式:**
588
+
589
+ ```tsx
590
+ // src/queries/keys.ts
591
+ import { createDomainKeyFactory } from '@qiaopeng/tanstack-query-plus/core'
592
+
593
+ export const productKeys = createDomainKeyFactory('products')
594
+ export const orderKeys = createDomainKeyFactory('orders')
595
+ export const cartKeys = createDomainKeyFactory('cart')
596
+ export const reviewKeys = createDomainKeyFactory('reviews')
597
+
598
+ // 使用
599
+ import { productKeys } from '@/queries/keys'
600
+
601
+ useQuery({
602
+ queryKey: productKeys.detail(productId),
603
+ queryFn: () => fetchProduct(productId),
604
+ })
249
605
  ```
250
606
 
251
- ## 9. 持久化与离线支持
607
+ ### 6.4 高级 Key 工具函数
252
608
 
253
- 浏览器环境下 Provider 自动启用持久化与离线状态监听;通过 features 子路径获取离线工具与持久化管理:
609
+ 本库还提供了一些高级的 Key 工具函数:
254
610
 
255
611
  ```tsx
256
- import { useEffect, useState } from 'react'
257
- import { isOnline, subscribeToOnlineStatus, clearCache } from '@qiaopeng/tanstack-query-plus/features'
612
+ import {
613
+ createFilteredKey,
614
+ createPaginatedKey,
615
+ createSortedKey,
616
+ createSearchKey,
617
+ createComplexKey,
618
+ matchesKeyPattern,
619
+ areKeysEqual
620
+ } from '@qiaopeng/tanstack-query-plus/core'
621
+
622
+ // 带筛选的 Key
623
+ const filteredKey = createFilteredKey(
624
+ productKeys.lists(),
625
+ { category: 'electronics', inStock: true }
626
+ )
627
+ // ['tanstack-query', 'products', 'list', 'filtered', { category: 'electronics', inStock: true }]
628
+
629
+ // 带分页的 Key
630
+ const paginatedKey = createPaginatedKey(productKeys.lists(), 1, 20)
631
+ // ['tanstack-query', 'products', 'list', 'paginated', { page: 1, pageSize: 20 }]
632
+
633
+ // 带排序的 Key
634
+ const sortedKey = createSortedKey(productKeys.lists(), 'price', 'desc')
635
+ // ['tanstack-query', 'products', 'list', 'sorted', { sortBy: 'price', sortOrder: 'desc' }]
636
+
637
+ // 复杂查询 Key(组合多个条件)
638
+ const complexKey = createComplexKey(productKeys.lists(), {
639
+ page: 1,
640
+ pageSize: 20,
641
+ filters: { category: 'electronics' },
642
+ sortBy: 'price',
643
+ sortOrder: 'desc',
644
+ search: 'phone'
645
+ })
258
646
 
259
- function OfflineBanner() {
260
- const [online, setOnline] = useState(isOnline())
261
- useEffect(() => subscribeToOnlineStatus(setOnline), [])
262
- return online ? null : <div>当前离线,数据将使用缓存</div>
647
+ // 检查 Key 是否匹配模式
648
+ const matches = matchesKeyPattern(
649
+ ['tanstack-query', 'products', 'detail', '123'],
650
+ ['tanstack-query', 'products'] // 模式
651
+ )
652
+ // true - 可用于批量失效
653
+
654
+ // 比较两个 Key 是否相等
655
+ const equal = areKeysEqual(key1, key2)
656
+ ```
657
+
658
+ ### 6.5 Mutation Key 工厂
659
+
660
+ 除了查询 Key,mutation 也可以有 Key(用于去重、追踪等):
661
+
662
+ ```tsx
663
+ import { createMutationKeyFactory } from '@qiaopeng/tanstack-query-plus/core'
664
+
665
+ const productMutations = createMutationKeyFactory('products')
666
+
667
+ productMutations.create() // ['products', 'create']
668
+ productMutations.update('123') // ['products', 'update', '123']
669
+ productMutations.delete('123') // ['products', 'delete', '123']
670
+ productMutations.batch('archive') // ['products', 'batch', 'archive']
671
+ ```
672
+
673
+ 现在你已经掌握了 Query Key 的管理。接下来,让我们学习如何进行数据变更(Mutation)以及如何实现乐观更新。
674
+
675
+ ---
676
+
677
+ ## 7. 第五步:数据变更与乐观更新
678
+
679
+
680
+ 查询(Query)用于获取数据,而变更(Mutation)用于创建、更新或删除数据。本库的 `useMutation` 提供了内置的乐观更新支持,让用户体验更流畅。
681
+
682
+ ### 7.1 基础 Mutation
683
+
684
+ 最基本的 mutation 使用:
685
+
686
+ ```tsx
687
+ import { useMutation, useQueryClient } from '@qiaopeng/tanstack-query-plus'
688
+
689
+ function UpdateUserButton({ userId }) {
690
+ const queryClient = useQueryClient()
691
+
692
+ const mutation = useMutation({
693
+ mutationFn: (newName) =>
694
+ fetch(`/api/users/${userId}`, {
695
+ method: 'PATCH',
696
+ body: JSON.stringify({ name: newName })
697
+ }).then(r => r.json()),
698
+ onSuccess: () => {
699
+ // 成功后刷新用户数据
700
+ queryClient.invalidateQueries({ queryKey: ['user', userId] })
701
+ },
702
+ onError: (error) => {
703
+ alert(`更新失败: ${error.message}`)
704
+ }
705
+ })
706
+
707
+ return (
708
+ <button
709
+ onClick={() => mutation.mutate('新名字')}
710
+ disabled={mutation.isPending}
711
+ >
712
+ {mutation.isPending ? '更新中...' : '更新名字'}
713
+ </button>
714
+ )
263
715
  }
716
+ ```
717
+
718
+ ### 7.2 什么是乐观更新?
719
+
720
+ **传统流程:**
721
+ 1. 用户点击"更新"
722
+ 2. 显示 loading
723
+ 3. 等待服务器响应
724
+ 4. 更新 UI
725
+
726
+ **乐观更新流程:**
727
+ 1. 用户点击"更新"
728
+ 2. **立即更新 UI**(假设会成功)
729
+ 3. 后台发送请求
730
+ 4. 如果失败,**回滚到之前的状态**
731
+
732
+ 乐观更新让用户感觉应用响应更快,体验更好。
733
+
734
+ ### 7.3 使用内置乐观更新
735
+
736
+ 本库的 `useMutation` 内置了乐观更新支持,无需手写复杂的 onMutate/onError 逻辑:
737
+
738
+ ```tsx
739
+ import { useMutation } from '@qiaopeng/tanstack-query-plus/hooks'
740
+
741
+ function UpdateUserName({ userId, currentName }) {
742
+ const mutation = useMutation({
743
+ mutationFn: (newName) => updateUserAPI(userId, { name: newName }),
744
+
745
+ // 乐观更新配置
746
+ optimistic: {
747
+ queryKey: ['user', userId], // 要更新的缓存 key
748
+
749
+ // 更新函数:接收旧数据和变量,返回新数据
750
+ updater: (oldData, newName) => ({
751
+ ...oldData,
752
+ name: newName
753
+ }),
754
+
755
+ // 回滚回调(可选):失败时执行
756
+ rollback: (previousData, error) => {
757
+ console.error('更新失败,已回滚:', error.message)
758
+ toast.error(`更新失败: ${error.message}`)
759
+ }
760
+ },
761
+
762
+ // 标准回调仍然可用
763
+ onSuccess: () => {
764
+ toast.success('更新成功')
765
+ }
766
+ })
264
767
 
265
- function ClearCacheButton() {
266
- return <button onClick={() => clearCache()}>清空持久化缓存</button>
768
+ return (
769
+ <button onClick={() => mutation.mutate('新名字')}>
770
+ 更新名字
771
+ </button>
772
+ )
267
773
  }
268
774
  ```
269
775
 
270
- ## 10. Devtools(可选)
776
+ **工作原理:**
777
+
778
+ 1. 调用 `mutation.mutate('新名字')` 时:
779
+ - 取消该 queryKey 的进行中请求
780
+ - 保存当前缓存数据(用于回滚)
781
+ - 调用 `updater` 立即更新缓存
782
+ - 发送实际请求
783
+
784
+ 2. 如果请求成功:
785
+ - 自动失效该 queryKey,触发重新获取最新数据
786
+ - 调用 `onSuccess` 回调
787
+
788
+ 3. 如果请求失败:
789
+ - 自动回滚到之前的数据
790
+ - 调用 `rollback` 回调
791
+ - 调用 `onError` 回调
792
+
793
+ ### 7.4 字段映射
271
794
 
272
- 按需引入,避免未安装时报错:
795
+ 有时候 mutation 的变量名和缓存数据的字段名不一致,可以使用字段映射:
273
796
 
274
797
  ```tsx
275
- import { ReactQueryDevtools } from '@qiaopeng/tanstack-query-plus/core/devtools'
798
+ const mutation = useMutation({
799
+ mutationFn: ({ newTitle }) => updateTodo(todoId, { title: newTitle }),
800
+
801
+ optimistic: {
802
+ queryKey: ['todo', todoId],
803
+ updater: (oldData, variables) => ({
804
+ ...oldData,
805
+ ...variables // 映射后的变量会自动应用
806
+ }),
807
+ // 将 mutation 变量的 newTitle 映射到缓存数据的 title
808
+ fieldMapping: {
809
+ 'newTitle': 'title'
810
+ }
811
+ }
812
+ })
813
+
814
+ // 调用时
815
+ mutation.mutate({ newTitle: '新标题' })
816
+ // 缓存会更新 title 字段
276
817
  ```
277
818
 
278
- ## 11. 配置与最佳实践
819
+ ### 7.5 条件性乐观更新
820
+
821
+ 有时候只想在特定条件下执行乐观更新:
279
822
 
280
- 提供统一默认值与校验工具:
823
+ ```tsx
824
+ import { useConditionalOptimisticMutation } from '@qiaopeng/tanstack-query-plus/hooks'
825
+
826
+ const mutation = useConditionalOptimisticMutation(
827
+ // 第一个参数:mutation 函数
828
+ updateTodo,
829
+ // 第二个参数:条件函数,只有返回 true 时才执行乐观更新
830
+ (variables) => variables.priority === 'high',
831
+ // 第三个参数:配置选项
832
+ {
833
+ mutationKey: ['updateTodo'], // 可选的 mutation key
834
+ optimistic: {
835
+ queryKey: ['todos'],
836
+ updater: (oldTodos, updatedTodo) =>
837
+ oldTodos?.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
838
+ },
839
+ onSuccess: () => {
840
+ console.log('更新成功')
841
+ }
842
+ }
843
+ )
281
844
 
282
- ```ts
283
- import { GLOBAL_QUERY_CONFIG, PRODUCTION_CONFIG, DEVELOPMENT_CONFIG, ensureBestPractices, validateConfig } from '@qiaopeng/tanstack-query-plus/core'
845
+ // 使用
846
+ mutation.mutate({ id: '1', title: '新标题', priority: 'high' }) // 会乐观更新
847
+ mutation.mutate({ id: '2', title: '新标题', priority: 'low' }) // 不会乐观更新
284
848
  ```
285
849
 
286
- - 默认值包含合理的 `staleTime/gcTime`、重试与指数退避、焦点/重连自动刷新等
287
- - `ensureBestPractices(config)` 会自动修正不合理配置(如 `gcTime <= staleTime`)
850
+ ### 7.6 列表操作的简化 Mutation
288
851
 
289
- ## 12. 子路径导出与常见问题
852
+ 对于常见的列表 CRUD 操作,可以使用 `useListMutation`:
290
853
 
291
- - 顶层:`@qiaopeng/tanstack-query-plus`
292
- - 核心:`@qiaopeng/tanstack-query-plus/core`
293
- - Hooks:`@qiaopeng/tanstack-query-plus/hooks`
294
- - 组件:`@qiaopeng/tanstack-query-plus/components`
295
- - 工具与类型:`@qiaopeng/tanstack-query-plus/utils`、`@qiaopeng/tanstack-query-plus/types`
296
- - React Query 直通子路径:`@qiaopeng/tanstack-query-plus/react-query`
297
- - 运行时再导出:`QueryClient`、`QueryClientProvider`、`useQueryClient`、`skipToken`、`useIsMutating`
298
- - 类型再导出:`UseQueryOptions`、`UseSuspenseQueryOptions`、`UseInfiniteQueryOptions`、`QueryKey`、`MutationKey`、`InfiniteData`
299
- - 可选能力:
300
- - Devtools:`@qiaopeng/tanstack-query-plus/core/devtools`
301
- - InView 预取:`@qiaopeng/tanstack-query-plus/hooks/inview`
854
+ ```tsx
855
+ import { useListMutation } from '@qiaopeng/tanstack-query-plus/hooks'
856
+
857
+ function TodoList() {
858
+ const mutation = useListMutation(
859
+ async ({ operation, data }) => {
860
+ switch (operation) {
861
+ case 'create':
862
+ return api.createTodo(data)
863
+ case 'update':
864
+ return api.updateTodo(data.id, data)
865
+ case 'delete':
866
+ return api.deleteTodo(data.id)
867
+ }
868
+ },
869
+ ['todos'] // 操作完成后自动失效这个 queryKey
870
+ )
302
871
 
303
- 常见问题:
872
+ const handleCreate = () => {
873
+ mutation.mutate({
874
+ operation: 'create',
875
+ data: { title: '新任务', done: false }
876
+ })
877
+ }
304
878
 
305
- - Devtools 未安装时报错?使用子路径 `core/devtools` 并安装 `@tanstack/react-query-devtools`
306
- - InView 预取未安装时报错?使用子路径 `hooks/inview` 并安装 `react-intersection-observer`
307
- - SSR 如何工作?Provider 会在无法持久化时自动降级;所有浏览器 API 都有守卫
879
+ const handleUpdate = (todo) => {
880
+ mutation.mutate({
881
+ operation: 'update',
882
+ data: { ...todo, done: !todo.done }
883
+ })
884
+ }
308
885
 
309
- ## 许可
886
+ const handleDelete = (todoId) => {
887
+ mutation.mutate({
888
+ operation: 'delete',
889
+ data: { id: todoId }
890
+ })
891
+ }
310
892
 
311
- MIT
893
+ // ...
894
+ }
895
+ ```
312
896
 
313
- ## API 参考
897
+ ### 7.7 批量 Mutation
314
898
 
315
- 本节对导出的 Hooks 与 TSX 组件进行逐项说明,并给出最小示例,便于快速查阅与上手。
899
+ 处理批量操作:
316
900
 
317
- ### Provider 与持久化/离线
901
+ ```tsx
902
+ import { useBatchMutation } from '@qiaopeng/tanstack-query-plus/hooks'
318
903
 
319
- - PersistQueryClientProvider(props)
320
- - 作用:集成 TanStack Query Provider,浏览器环境自动启用持久化与离线监听;SSR 环境安全降级
321
- - 关键 props:`client`、`cacheKey?`、`enablePersistence?`、`enableOfflineSupport?`
322
- - 用法:参见“初始化与 Provider”章节示例
323
- - 离线/持久化工具(features 子路径)
324
- - `isOnline()` / `subscribeToOnlineStatus(callback)` / `configureOfflineQueries(queryClient)`
325
- - `clearCache(key?)` / `clearExpiredCache(key?, maxAge?)` / `createPersister(key?, storage?)`
326
- - 用法:参见“持久化与离线支持”章节示例
904
+ const batchMutation = useBatchMutation(
905
+ async (todoIds) => {
906
+ // 批量删除
907
+ return Promise.all(todoIds.map(id => api.deleteTodo(id)))
908
+ }
909
+ )
327
910
 
328
- ### 组件(TSX)
911
+ // 使用
912
+ batchMutation.mutate(['id1', 'id2', 'id3'])
913
+ ```
329
914
 
330
- - SuspenseWrapper({ children, fallback?, errorFallback?, onError?, resetKeys? })
331
- - 作用:将 ErrorBoundary 与 React.Suspense 合并封装
332
- - QueryErrorBoundary({ children, fallback?, onError?, resetKeys? })
333
- - 作用:结合 React Query 的错误重置能力,提供查询错误兜底 UI
334
- - LoadingFallback 族
335
- - DefaultLoadingFallback / SmallLoadingIndicator / FullScreenLoading / TextSkeletonFallback / CardSkeletonFallback / ListSkeletonFallback / PageSkeletonFallback
336
- - 作用:一致的加载与骨架 UI,可按需组合
915
+ ### 7.8 乐观更新工具函数
337
916
 
338
- ### Queries(标准与 Suspense)
917
+ 本库还提供了一些工具函数来简化列表的乐观更新:
339
918
 
340
- - useEnhancedQuery(options)
341
- - 作用:基于 v5 `useQuery` 的增强封装,携带统一默认最佳实践(重试、指数退避、焦点刷新等)
342
- - 示例:参见“基础查询”章节
343
- - useEnhancedSuspenseQuery(options)
344
- - 作用:针对 Suspense 模式的查询封装
345
- - createAppQueryOptions(config)
346
- - 作用:生成带默认最佳实践的 `UseQueryOptions`;推荐与 `useQuery` 搭配
347
- - createAppQueryOptionsWithSelect(config)
348
- - 作用:在服务器返回数据基础上做选择/映射
349
- - createSuspenseQuery(config)、createSuspenseInfiniteQuery(config)
350
- - 作用:为 Suspense 场景生成标准化选项
919
+ ```tsx
920
+ import {
921
+ listUpdater,
922
+ createAddItemConfig,
923
+ createUpdateItemConfig,
924
+ createRemoveItemConfig,
925
+ batchUpdateItems,
926
+ batchRemoveItems,
927
+ reorderItems,
928
+ conditionalUpdateItems
929
+ } from '@qiaopeng/tanstack-query-plus/utils'
930
+
931
+ // 列表更新器(要求列表项有 id 字段)
932
+ const list1 = listUpdater.add(items, newItem) // 添加到头部(如果 id 已存在则更新)
933
+ const list2 = listUpdater.update(items, { id: '1', title: '新标题' }) // 更新项
934
+ const list3 = listUpdater.remove(items, '1') // 按 id 移除项
935
+
936
+ // 创建预配置的乐观更新配置(返回 { queryKey, updater, rollback?, enabled } 对象)
937
+ const addConfig = createAddItemConfig(['todos'], {
938
+ addToTop: true, // 默认 true,添加到头部
939
+ onRollback: (error) => console.error('添加失败:', error)
940
+ })
941
+ const updateConfig = createUpdateItemConfig(['todos'])
942
+ const removeConfig = createRemoveItemConfig(['todos'])
351
943
 
352
- 签名参考:
944
+ // 在 mutation 中使用这些配置
945
+ const addMutation = useMutation({
946
+ mutationFn: createTodo,
947
+ optimistic: addConfig, // 直接使用预配置
948
+ })
353
949
 
354
- ```ts
355
- // 标准查询
356
- declare function useEnhancedQuery<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
357
- options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
358
- ): UseQueryResult<TData, TError>
950
+ // 批量更新(每个更新对象必须包含 id)
951
+ const list4 = batchUpdateItems(items, [
952
+ { id: '1', done: true },
953
+ { id: '2', done: true }
954
+ ])
359
955
 
360
- declare function createAppQueryOptions<TData>(
361
- config: { queryKey: QueryKey; queryFn: QueryFunction<TData, QueryKey>; staleTime?: number; gcTime?: number; enabled?: boolean }
362
- ): UseQueryOptions<TData, DefaultError, TData, QueryKey>
956
+ // 批量移除
957
+ const list5 = batchRemoveItems(items, ['1', '2', '3'])
363
958
 
364
- declare function createAppQueryOptionsWithSelect<TData, TSelected = TData>(
365
- config: { queryKey: QueryKey; queryFn: QueryFunction<TData, QueryKey>; select: (data: TData) => TSelected; staleTime?: number; gcTime?: number }
366
- ): UseQueryOptions<TData, DefaultError, TSelected, QueryKey>
959
+ // 重新排序(将 fromIndex 位置的项移动到 toIndex)
960
+ const list6 = reorderItems(items, 0, 2) // 将第一项移到第三位
367
961
 
368
- // Suspense 查询
369
- declare function useEnhancedSuspenseQuery<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
370
- options: UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>
371
- ): UseSuspenseQueryResult<TData, TError>
962
+ // 条件更新(满足条件的项才更新)
963
+ const list7 = conditionalUpdateItems(
964
+ items,
965
+ (item) => item.status === 'pending', // 条件
966
+ (item) => ({ status: 'completed' }) // 更新内容
967
+ )
968
+ ```
372
969
 
373
- declare function createSuspenseQuery<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TVariables = void>(
374
- getQueryKey: (variables: TVariables) => TQueryKey,
375
- queryFn: QueryFunction<TQueryFnData, TQueryKey>,
376
- options?: Omit<UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryKey' | 'queryFn'>
377
- ): (variables: TVariables) => UseSuspenseQueryResult<TData, TError>
378
-
379
- // 说明:对于 v5 推荐在调用时将 TError 显式指定为 DefaultError,以与官方类型一致:
380
- // useEnhancedQuery<..., DefaultError>(options)
381
- // useEnhancedSuspenseQuery<..., DefaultError>(options)
382
- ```
383
-
384
- ### Infinite Queries(三种分页模型)
385
-
386
- - useEnhancedInfiniteQuery(options)
387
- - 作用:增强版无限查询钩子
388
- - createCursorPaginationOptions({ queryKey, queryFn, initialCursor?, staleTime?, gcTime? })
389
- - createOffsetPaginationOptions({ queryKey, queryFn, limit?, staleTime?, gcTime? })
390
- - createPageNumberPaginationOptions({ queryKey, queryFn, staleTime?, gcTime? })
391
- - 作用:三种分页模型的统一选项工厂;示例参见“无限加载”章节
392
-
393
- 签名参考:
394
-
395
- ```ts
396
- declare function useEnhancedInfiniteQuery<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown>(
397
- options: UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
398
- ): UseInfiniteQueryResult<TData, TError>
399
-
400
- declare function createInfiniteQueryOptions<TQueryFnData = unknown, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown>(
401
- config: {
402
- queryKey: TQueryKey
403
- queryFn: QueryFunction<TQueryFnData, TQueryKey, TPageParam>
404
- initialPageParam: TPageParam
405
- getNextPageParam: (lastPage: TQueryFnData, allPages: TQueryFnData[], lastPageParam: TPageParam, allPageParams: TPageParam[]) => TPageParam | undefined | null
406
- getPreviousPageParam?: (firstPage: TQueryFnData, allPages: TQueryFnData[], firstPageParam: TPageParam, allPageParams: TPageParam[]) => TPageParam | undefined | null
407
- staleTime?: number
408
- gcTime?: number
409
- }
410
- ): UseInfiniteQueryOptions<TQueryFnData, DefaultError, TQueryFnData, TQueryKey, TPageParam>
411
-
412
- declare function createCursorPaginationOptions<T>(
413
- config: { queryKey: QueryKey; queryFn: (cursor: string | null) => Promise<CursorPaginatedResponse<T>>; initialCursor?: string | null; staleTime?: number; gcTime?: number }
414
- ): UseInfiniteQueryOptions<CursorPaginatedResponse<T>, DefaultError, CursorPaginatedResponse<T>, QueryKey, string | null>
415
-
416
- declare function createOffsetPaginationOptions<T>(
417
- config: { queryKey: QueryKey; queryFn: (offset: number, limit: number) => Promise<OffsetPaginatedResponse<T>>; limit?: number; staleTime?: number; gcTime?: number }
418
- ): UseInfiniteQueryOptions<OffsetPaginatedResponse<T>, DefaultError, OffsetPaginatedResponse<T>, QueryKey, number>
419
-
420
- declare function createPageNumberPaginationOptions<T>(
421
- config: { queryKey: QueryKey; queryFn: (page: number) => Promise<PageNumberPaginatedResponse<T>>; staleTime?: number; gcTime?: number }
422
- ): UseInfiniteQueryOptions<PageNumberPaginatedResponse<T>, DefaultError, PageNumberPaginatedResponse<T>, QueryKey, number>
423
- ```
424
-
425
- ### 批量查询与操作
426
-
427
- - useEnhancedQueries(queries, config?) / useEnhancedSuspenseQueries(queries, config?)
428
- - 作用:批量执行查询并返回聚合统计与批量操作
429
- - useCombinedQueries({ queries, combine? })
430
- - 作用:自定义组合函数,得到合并后的结果
431
- - useDashboardQueries(queriesMap)
432
- - 作用:以对象映射驱动多查询,返回 `combinedData`
433
- - useDependentBatchQueries({ primaryQuery, dependentQueries, config? })
434
- - 作用:依赖查询链路(先主查询,再基于结果构造从查询集合)
435
- - useDynamicBatchQueries({ items, queryKeyPrefix, queryFn, enabled?, staleTime?, gcTime?, config? })
436
- - 作用:根据动态 items 批量生成查询
437
- - usePaginatedBatchQueries({ pageNumbers, queryKeyPrefix, queryFn, staleTime?, config? })
438
- - 作用:分页编号驱动的批量查询
439
- - useConditionalBatchQueries(queries)
440
- - 作用:对 `enabled !== false` 的查询执行批量
441
- - useRetryBatchQueries({ queries, retry?, retryDelay?, config? })
442
- - 作用:统一设置批量查询的重试策略
443
- - batchQueryUtils
444
- - 常用聚合工具:`isAllLoading/isAllSuccess/hasError/hasStale/getAllErrors/getAllData/...`
445
- - calculateBatchStats(results)
446
- - 返回成功率/错误率等批量统计
447
- - createBatchQueryConfig(config?)
448
- - 作用:构建批量查询配置的默认值
449
- - useBatchQueryPerformance(results)
450
- - 作用:统计平均拉取时间等性能指标
451
-
452
- 签名参考:
453
-
454
- ```ts
455
- declare function useEnhancedQueries(
456
- queries: UseQueryOptions[],
457
- config?: BatchQueryConfig
458
- ): { data: UseQueryResult[]; stats: BatchQueryStats; operations: InternalBatchQueryOperations; config: BatchQueryConfig }
459
-
460
- declare function useEnhancedSuspenseQueries(
461
- queries: UseSuspenseQueryOptions[],
462
- config?: BatchQueryConfig
463
- ): { data: UseSuspenseQueryResult[]; stats: BatchQueryStats; operations: InternalBatchQueryOperations; config: BatchQueryConfig }
464
-
465
- declare function useCombinedQueries<TCombinedResult = UseQueryResult[]>(
466
- options: { queries: UseQueryOptions[]; combine?: (results: UseQueryResult[]) => TCombinedResult }
467
- ): TCombinedResult
468
-
469
- declare function useDashboardQueries<T extends Record<string, UseQueryOptions>>(
470
- queriesMap: T
471
- ): { data: { [K in keyof T]: T[K] extends UseQueryOptions<infer D> ? D : unknown }; results: UseQueryResult[]; stats: BatchQueryStats; isLoading: boolean; isError: boolean; isSuccess: boolean }
472
-
473
- declare function useDependentBatchQueries<TPrimaryData>(
474
- options: { primaryQuery: UseQueryOptions<TPrimaryData>; dependentQueries: (data: TPrimaryData) => UseQueryOptions[]; config?: BatchQueryConfig }
475
- ): { primaryResult: UseQueryResult<TPrimaryData, DefaultError>; results: UseQueryResult[]; stats: BatchQueryStats; operations: InternalBatchQueryOperations }
476
- ```
477
-
478
- ### Prefetch(预取)
479
-
480
- - useHoverPrefetch(queryKey, queryFn, options?)
481
- - useRoutePrefetch()
482
- - 返回:`prefetch(queryKey, queryFn, options?)`
483
- - useSmartPrefetch()
484
- - 返回:`{ prefetch, shouldPrefetch, clearPrefetchHistory }`
485
- - useConditionalPrefetch(queryKey, queryFn, condition, options?)
486
- - useIdlePrefetch(queryKey, queryFn, { timeout?, ...options })
487
- - usePeriodicPrefetch(queryKey, queryFn, { interval?, ...options })
488
- - useBatchPrefetch([{ queryKey, queryFn, staleTime? }, ...])
489
- - usePredictivePrefetch()
490
- - 返回:`{ recordInteraction, getPredictions, prefetchPredicted, clearHistory }`
491
- - usePriorityPrefetch()
492
- - 返回:队列化按优先级执行预取任务
493
- - useInViewPrefetch(queryKey, queryFn, options?)(子路径 `hooks/inview`)
494
- - 需安装 `react-intersection-observer`
495
-
496
- 签名参考:
497
-
498
- ```ts
499
- declare function useHoverPrefetch<TData = unknown>(
500
- queryKey: QueryKey,
501
- queryFn: QueryFunction<TData>,
502
- options?: { delay?: number; enabled?: boolean; staleTime?: number; hoverDelay?: number }
503
- ): { onMouseEnter: () => void; onMouseLeave: () => void; onFocus: () => void }
504
-
505
- declare function useRoutePrefetch(): <TData = unknown>(
506
- queryKey: QueryKey,
507
- queryFn: QueryFunction<TData>,
508
- options?: { enabled?: boolean; staleTime?: number }
509
- ) => void
510
-
511
- declare function useSmartPrefetch(): {
512
- prefetch: <TData = unknown>(queryKey: QueryKey, queryFn: QueryFunction<TData>, options?: { staleTime?: number }) => void
513
- shouldPrefetch: boolean
514
- clearPrefetchHistory: () => void
515
- }
516
-
517
- declare function useInViewPrefetch<TData = unknown>(
518
- queryKey: QueryKey,
519
- queryFn: QueryFunction<TData>,
520
- options?: { threshold?: number; rootMargin?: string; triggerOnce?: boolean; enabled?: boolean; staleTime?: number }
521
- ): (el: Element | null) => void
522
- ```
523
-
524
- ### 焦点管理(Focus)
525
-
526
- - useFocusRefetch(options?) / useConditionalFocusRefetch(condition, options?)
527
- - 作用:基于页面焦点变化自动刷新
528
- - useFocusCallback(callback)
529
- - 作用:焦点恢复时执行回调
530
- - useFocusState()
531
- - 返回:当前是否处于焦点(布尔值)
532
- - usePageVisibility()
533
- - 返回:页面是否可见(布尔值)
534
- - usePauseFocus(options?) / useSmartFocusManager()
535
- - 作用:暂停/恢复全局焦点管理与最小间隔刷新的辅助
536
-
537
- 签名参考:
538
-
539
- ```ts
540
- declare function useFocusRefetch(options?: { enabled?: boolean; minInterval?: number; queryKeys?: QueryKey[] }): void
541
- declare function useConditionalFocusRefetch(queryKey: QueryKey, condition: () => boolean, options?: { minInterval?: number; enabled?: boolean }): void
542
- declare function useFocusCallback(callback: () => void, options?: { minInterval?: number; enabled?: boolean; queryKey?: QueryKey }): void
543
- declare function useFocusState(): boolean
544
- declare function usePageVisibility(): boolean
545
- declare function usePauseFocus(options?: { autoPause?: boolean; pauseWhen?: boolean }): { pause: () => void; resume: () => void }
546
- declare function useSmartFocusManager(): { pause: () => void; resume: () => void; getStats: () => { isPaused: boolean; pauseCount: number; isFocused: boolean }; stats: { isPaused: boolean; pauseCount: number; isFocused: boolean } }
547
- ```
548
-
549
- ### Mutations(变更)
550
-
551
- - useMutation(options)
552
- - 作用:增强版 `useMutation`,内置乐观更新支持(字段映射、回滚、用户上下文)
553
- - useConditionalOptimisticMutation(mutationFn, condition, options?)
554
- - 作用:满足条件时才执行乐观更新
555
- - useListMutation(mutationFn, queryKey, options?)
556
- - 作用:列表场景的简化变更(完成后自动 `invalidateQueries`)
557
- - useBatchMutation(mutationFn, options?)
558
- - 作用:批量数据变更
559
- - setupMutationDefaults(queryClient, config)
560
- - 作用:按 mutationKey 设置全局默认变更选项
561
- - cancelQueriesBatch(queryClient, queryKeys)
562
- - setQueryDataBatch(queryClient, updates)
563
- - invalidateQueriesBatch(queryClient, queryKeys)
564
- - 作用:批量取消/更新/失效查询便捷工具
565
-
566
- 签名参考:
567
-
568
- ```ts
569
- declare function useMutation<TData = unknown, TError = Error, TVariables = void, TContext = unknown>(
570
- options: MutationOptions<TData, TError, TVariables, TContext>
571
- ): UseMutationResult<TData, TError, TVariables, TContext>
572
-
573
- declare function useConditionalOptimisticMutation<TData = unknown, TError = Error, TVariables = void, TContext = unknown>(
574
- mutationFn: MutationFunction<TData, TVariables>,
575
- condition: (variables: TVariables) => boolean,
576
- options?: Omit<MutationOptions<TData, TError, TVariables, TContext>, 'mutationFn'> & { mutationKey?: readonly unknown[] }
577
- ): UseMutationResult<TData, TError, TVariables, TContext>
578
-
579
- declare function useListMutation<T extends EntityWithId>(
580
- mutationFn: MutationFunction<T, { operation: string; data: Partial<T> }>,
581
- queryKey: QueryKey,
582
- options?: UseMutationOptions<T, Error, { operation: string; data: Partial<T> }> & { mutationKey?: readonly unknown[] }
583
- ): UseMutationResult<T, Error, { operation: string; data: Partial<T> }>
584
-
585
- declare function useBatchMutation<TData = unknown, TError = Error, TVariables = unknown[]>(
586
- mutationFn: MutationFunction<TData[], TVariables>,
587
- options?: UseMutationOptions<TData[], TError, TVariables> & { mutationKey?: readonly unknown[] }
588
- ): UseMutationResult<TData[], TError, TVariables>
589
-
590
- declare function setupMutationDefaults(queryClient: QueryClient, config: { [key: string]: UseMutationOptions<any, any, any, any> }): void
591
- declare function cancelQueriesBatch(queryClient: QueryClient, queryKeys: Array<Parameters<QueryClient['cancelQueries']>[0]>): Promise<void>
592
- declare function setQueryDataBatch(queryClient: QueryClient, updates: Array<{ queryKey: Parameters<QueryClient['setQueryData']>[0]; updater: Parameters<QueryClient['setQueryData']>[1] }>): void
593
- declare function invalidateQueriesBatch(queryClient: QueryClient, queryKeys: Array<Parameters<QueryClient['invalidateQueries']>[0]>): Promise<void>
594
- ```
595
-
596
- ### 组件错误与加载(TSX)
597
-
598
- - QueryErrorBoundary / ErrorBoundary
599
- - 作用:错误显示与重试,支持 `fallback` 自定义
600
- - SuspenseWrapper
601
- - 作用:Suspense + 错误边界组合
602
- - LoadingFallback 族
603
- - 作用:加载与骨架 UI,按需选择与组合
604
-
605
- 示例:
606
-
607
- ```tsx
608
- import { DefaultLoadingFallback, FullScreenLoading, TextSkeletonFallback } from '@qiaopeng/tanstack-query-plus/components'
970
+ ### 7.9 完整示例:Todo 应用
971
+
972
+ ```tsx
973
+ import { useEnhancedQuery, useMutation } from '@qiaopeng/tanstack-query-plus/hooks'
974
+ import { listUpdater } from '@qiaopeng/tanstack-query-plus/utils'
975
+
976
+ function TodoApp() {
977
+ // 查询 todos
978
+ const { data: todos, isLoading } = useEnhancedQuery({
979
+ queryKey: ['todos'],
980
+ queryFn: fetchTodos,
981
+ })
982
+
983
+ // 添加 todo(乐观更新)
984
+ const addMutation = useMutation({
985
+ mutationFn: (title) => api.createTodo({ title, done: false }),
986
+ optimistic: {
987
+ queryKey: ['todos'],
988
+ updater: (oldTodos, title) => [
989
+ { id: `temp-${Date.now()}`, title, done: false },
990
+ ...(oldTodos || [])
991
+ ],
992
+ rollback: (_, error) => toast.error(`添加失败: ${error.message}`)
993
+ }
994
+ })
995
+
996
+ // 切换完成状态(乐观更新)
997
+ const toggleMutation = useMutation({
998
+ mutationFn: (todo) => api.updateTodo(todo.id, { done: !todo.done }),
999
+ optimistic: {
1000
+ queryKey: ['todos'],
1001
+ updater: (oldTodos, todo) =>
1002
+ oldTodos?.map(t => t.id === todo.id ? { ...t, done: !t.done } : t),
1003
+ }
1004
+ })
1005
+
1006
+ // 删除 todo(乐观更新)
1007
+ const deleteMutation = useMutation({
1008
+ mutationFn: (todoId) => api.deleteTodo(todoId),
1009
+ optimistic: {
1010
+ queryKey: ['todos'],
1011
+ updater: (oldTodos, todoId) => oldTodos?.filter(t => t.id !== todoId),
1012
+ }
1013
+ })
1014
+
1015
+ if (isLoading) return <div>加载中...</div>
609
1016
 
610
- function LoadingExamples() {
611
1017
  return (
612
1018
  <div>
613
- <DefaultLoadingFallback />
614
- <FullScreenLoading message="加载中..." />
615
- <TextSkeletonFallback lines={4} />
1019
+ <AddTodoForm onAdd={(title) => addMutation.mutate(title)} />
1020
+
1021
+ <ul>
1022
+ {todos?.map(todo => (
1023
+ <li key={todo.id}>
1024
+ <input
1025
+ type="checkbox"
1026
+ checked={todo.done}
1027
+ onChange={() => toggleMutation.mutate(todo)}
1028
+ />
1029
+ <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
1030
+ {todo.title}
1031
+ </span>
1032
+ <button onClick={() => deleteMutation.mutate(todo.id)}>
1033
+ 删除
1034
+ </button>
1035
+ </li>
1036
+ ))}
1037
+ </ul>
616
1038
  </div>
617
1039
  )
618
1040
  }
619
1041
  ```
620
1042
 
621
- ### 说明与约定
1043
+ 现在你已经掌握了数据变更和乐观更新。接下来,让我们学习如何处理无限滚动和分页场景。
1044
+
1045
+ ---
1046
+
1047
+ ## 8. 第六步:无限滚动与分页
1048
+
1049
+
1050
+ 无限滚动是现代应用中常见的交互模式。本库提供了 `useEnhancedInfiniteQuery` 和多种分页模式的工厂函数,让实现变得简单。
1051
+
1052
+ ### 8.1 理解三种分页模式
1053
+
1054
+ 在实际项目中,后端 API 通常采用以下三种分页方式之一:
1055
+
1056
+ 1. **游标分页(Cursor Pagination)**
1057
+ - 使用游标(通常是最后一条记录的 ID)来获取下一页
1058
+ - 适合:社交媒体 feed、聊天记录
1059
+ - 示例:`/api/posts?cursor=abc123`
1060
+
1061
+ 2. **偏移分页(Offset Pagination)**
1062
+ - 使用 offset 和 limit 来获取数据
1063
+ - 适合:传统列表、搜索结果
1064
+ - 示例:`/api/posts?offset=20&limit=10`
1065
+
1066
+ 3. **页码分页(Page Number Pagination)**
1067
+ - 使用页码来获取数据
1068
+ - 适合:传统分页 UI
1069
+ - 示例:`/api/posts?page=2`
1070
+
1071
+ ### 8.2 游标分页
1072
+
1073
+ ```tsx
1074
+ import {
1075
+ useEnhancedInfiniteQuery,
1076
+ createCursorPaginationOptions
1077
+ } from '@qiaopeng/tanstack-query-plus/hooks'
1078
+
1079
+ // 假设 API 返回格式:
1080
+ // { items: [...], cursor: 'next-cursor' | null }
1081
+
1082
+ function PostFeed() {
1083
+ // 创建游标分页配置
1084
+ const options = createCursorPaginationOptions({
1085
+ queryKey: ['posts', 'feed'],
1086
+ queryFn: async (cursor) => {
1087
+ const url = cursor
1088
+ ? `/api/posts?cursor=${cursor}`
1089
+ : '/api/posts'
1090
+ const response = await fetch(url)
1091
+ return response.json()
1092
+ // 返回 { items: Post[], cursor: string | null }
1093
+ },
1094
+ initialCursor: null, // 初始游标
1095
+ staleTime: 30000,
1096
+ })
1097
+
1098
+ const {
1099
+ data,
1100
+ fetchNextPage,
1101
+ hasNextPage,
1102
+ isFetchingNextPage,
1103
+ isLoading,
1104
+ } = useEnhancedInfiniteQuery(options)
1105
+
1106
+ if (isLoading) return <div>加载中...</div>
1107
+
1108
+ return (
1109
+ <div>
1110
+ {/* 展平所有页的数据 */}
1111
+ {data?.pages.map((page, pageIndex) => (
1112
+ <div key={pageIndex}></div> {page.items.map(post => (
1113
+ <PostCard key={post.id} post={post} />
1114
+ ))}
1115
+ </div>
1116
+ ))}
1117
+
1118
+ {/* 加载更多按钮 */}
1119
+ <button
1120
+ onClick={() => fetchNextPage()}
1121
+ disabled={!hasNextPage || isFetchingNextPage}
1122
+ >
1123
+ {isFetchingNextPage
1124
+ ? '加载中...'
1125
+ : hasNextPage
1126
+ ? '加载更多'
1127
+ : '没有更多了'}
1128
+ </button>
1129
+ </div>
1130
+ )
1131
+ }
1132
+ ```
1133
+
1134
+ ### 8.3 偏移分页
1135
+
1136
+ ```tsx
1137
+ import {
1138
+ useEnhancedInfiniteQuery,
1139
+ createOffsetPaginationOptions
1140
+ } from '@qiaopeng/tanstack-query-plus/hooks'
1141
+
1142
+ // 假设 API 返回格式:
1143
+ // { items: [...], total: 100, hasMore: true }
1144
+
1145
+ function ProductList() {
1146
+ const options = createOffsetPaginationOptions({
1147
+ queryKey: ['products'],
1148
+ queryFn: async (offset, limit) => {
1149
+ const response = await fetch(
1150
+ `/api/products?offset=${offset}&limit=${limit}`
1151
+ )
1152
+ return response.json()
1153
+ // 返回 { items: Product[], total: number, hasMore: boolean }
1154
+ },
1155
+ limit: 20, // 每页数量
1156
+ })
1157
+
1158
+ const {
1159
+ data,
1160
+ fetchNextPage,
1161
+ hasNextPage,
1162
+ isFetchingNextPage,
1163
+ } = useEnhancedInfiniteQuery(options)
1164
+
1165
+ // 计算已加载的总数
1166
+ const loadedCount = data?.pages.reduce(
1167
+ (sum, page) => sum + page.items.length,
1168
+ 0
1169
+ ) || 0
1170
+
1171
+ return (
1172
+ <div>
1173
+ <div className="grid grid-cols-4 gap-4">
1174
+ {data?.pages.flatMap(page => page.items).map(product => (
1175
+ <ProductCard key={product.id} product={product} />
1176
+ ))}
1177
+ </div>
1178
+
1179
+ <div className="mt-4 text-center">
1180
+ <p>已加载 {loadedCount} / {data?.pages[0]?.total || 0} 个商品</p>
1181
+
1182
+ {hasNextPage && (
1183
+ <button
1184
+ onClick={() => fetchNextPage()}
1185
+ disabled={isFetchingNextPage}
1186
+ className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
1187
+ >
1188
+ {isFetchingNextPage ? '加载中...' : '加载更多'}
1189
+ </button>
1190
+ )}
1191
+ </div>
1192
+ </div>
1193
+ )
1194
+ }
1195
+ ```
1196
+
1197
+ ### 8.4 页码分页
1198
+
1199
+ ```tsx
1200
+ import {
1201
+ useEnhancedInfiniteQuery,
1202
+ createPageNumberPaginationOptions
1203
+ } from '@qiaopeng/tanstack-query-plus/hooks'
1204
+
1205
+ // 假设 API 返回格式:
1206
+ // { items: [...], page: 1, totalPages: 10 }
1207
+
1208
+ function ArticleList() {
1209
+ const options = createPageNumberPaginationOptions({
1210
+ queryKey: ['articles'],
1211
+ queryFn: async (page) => {
1212
+ const response = await fetch(`/api/articles?page=${page}`)
1213
+ return response.json()
1214
+ // 返回 { items: Article[], page: number, totalPages: number }
1215
+ },
1216
+ })
1217
+
1218
+ const {
1219
+ data,
1220
+ fetchNextPage,
1221
+ fetchPreviousPage,
1222
+ hasNextPage,
1223
+ hasPreviousPage,
1224
+ isFetchingNextPage,
1225
+ } = useEnhancedInfiniteQuery(options)
1226
+
1227
+ const currentPage = data?.pages.length || 0
1228
+ const totalPages = data?.pages[0]?.totalPages || 0
1229
+
1230
+ return (
1231
+ <div>
1232
+ {data?.pages.map((page, i) => (
1233
+ <div key={i}>
1234
+ {page.items.map(article => (
1235
+ <ArticleCard key={article.id} article={article} />
1236
+ ))}
1237
+ </div>
1238
+ ))}
1239
+
1240
+ <div className="flex justify-between mt-4">
1241
+ <button
1242
+ onClick={() => fetchPreviousPage()}
1243
+ disabled={!hasPreviousPage}
1244
+ >
1245
+ 上一页
1246
+ </button>
1247
+
1248
+ <span>第 {currentPage} / {totalPages} 页</span>
1249
+
1250
+ <button
1251
+ onClick={() => fetchNextPage()}
1252
+ disabled={!hasNextPage || isFetchingNextPage}
1253
+ >
1254
+ {isFetchingNextPage ? '加载中...' : '下一页'}
1255
+ </button>
1256
+ </div>
1257
+ </div>
1258
+ )
1259
+ }
1260
+ ```
1261
+
1262
+ ### 8.5 无限滚动(自动加载)
1263
+
1264
+ 结合 Intersection Observer 实现滚动到底部自动加载:
1265
+
1266
+ ```tsx
1267
+ import { useRef, useEffect } from 'react'
1268
+ import { useEnhancedInfiniteQuery, createOffsetPaginationOptions } from '@qiaopeng/tanstack-query-plus/hooks'
1269
+
1270
+ function InfiniteScrollList() {
1271
+ const loadMoreRef = useRef(null)
1272
+
1273
+ const options = createOffsetPaginationOptions({
1274
+ queryKey: ['items'],
1275
+ queryFn: (offset, limit) => fetchItems(offset, limit),
1276
+ limit: 20,
1277
+ })
1278
+
1279
+ const {
1280
+ data,
1281
+ fetchNextPage,
1282
+ hasNextPage,
1283
+ isFetchingNextPage,
1284
+ } = useEnhancedInfiniteQuery(options)
1285
+
1286
+ // 监听滚动到底部
1287
+ useEffect(() => {
1288
+ const observer = new IntersectionObserver(
1289
+ (entries) => {
1290
+ if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
1291
+ fetchNextPage()
1292
+ }
1293
+ },
1294
+ { threshold: 0.1 }
1295
+ )
1296
+
1297
+ if (loadMoreRef.current) {
1298
+ observer.observe(loadMoreRef.current)
1299
+ }
1300
+
1301
+ return () => observer.disconnect()
1302
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage])
1303
+
1304
+ return (
1305
+ <div>
1306
+ {data?.pages.flatMap(page => page.items).map(item => (
1307
+ <ItemCard key={item.id} item={item} />
1308
+ ))}
1309
+
1310
+ {/* 触发加载的哨兵元素 */}
1311
+ <div ref={loadMoreRef} className="h-10">
1312
+ {isFetchingNextPage && <div>加载中...</div>}
1313
+ {!hasNextPage && <div>已经到底了</div>}
1314
+ </div>
1315
+ </div>
1316
+ )
1317
+ }
1318
+ ```
1319
+
1320
+ ### 8.6 自定义无限查询配置
1321
+
1322
+ 如果预设的分页模式不满足需求,可以使用 `createInfiniteQueryOptions` 创建自定义配置:
1323
+
1324
+ ```tsx
1325
+ import { createInfiniteQueryOptions, useEnhancedInfiniteQuery } from '@qiaopeng/tanstack-query-plus/hooks'
1326
+
1327
+ // 使用 createInfiniteQueryOptions 创建自定义分页配置
1328
+ const customOptions = createInfiniteQueryOptions({
1329
+ queryKey: ['custom-list'],
1330
+ queryFn: ({ pageParam }) => fetchCustomData(pageParam),
1331
+ initialPageParam: { page: 1, filter: 'active' },
1332
+ getNextPageParam: (lastPage, allPages, lastPageParam) => {
1333
+ if (lastPage.hasMore) {
1334
+ return { ...lastPageParam, page: lastPageParam.page + 1 }
1335
+ }
1336
+ return undefined // 没有更多数据
1337
+ },
1338
+ getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
1339
+ if (firstPageParam.page > 1) {
1340
+ return { ...firstPageParam, page: firstPageParam.page - 1 }
1341
+ }
1342
+ return undefined
1343
+ },
1344
+ staleTime: 60000,
1345
+ gcTime: 300000,
1346
+ })
1347
+
1348
+ const result = useEnhancedInfiniteQuery(customOptions)
1349
+ ```
1350
+
1351
+ **方式二**:也可以直接传递配置给 `useEnhancedInfiniteQuery`:
1352
+
1353
+ ```tsx
1354
+ const result = useEnhancedInfiniteQuery({
1355
+ queryKey: ['custom-list'],
1356
+ queryFn: ({ pageParam }) => fetchCustomData(pageParam),
1357
+ initialPageParam: { page: 1, filter: 'active' },
1358
+ getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
1359
+ })
1360
+ ```
1361
+
1362
+ **方式三**:使用 TanStack Query 的 `infiniteQueryOptions`(如果你需要与原生 API 保持一致):
1363
+
1364
+ ```tsx
1365
+ import { infiniteQueryOptions } from '@tanstack/react-query'
1366
+
1367
+ const customOptions = infiniteQueryOptions({
1368
+ queryKey: ['custom-list'],
1369
+ queryFn: ({ pageParam }) => fetchCustomData(pageParam),
1370
+ initialPageParam: { page: 1, filter: 'active' },
1371
+ getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
1372
+ })
1373
+
1374
+ const result = useEnhancedInfiniteQuery(customOptions)
1375
+ ```
1376
+
1377
+ 现在你已经掌握了无限滚动和分页。在复杂的应用中,我们经常需要同时发起多个查询。接下来,让我们学习批量查询。
1378
+
1379
+ ---
1380
+
1381
+ ## 9. 第七步:批量查询与仪表盘
1382
+
1383
+
1384
+ 在仪表盘、数据概览等场景中,我们经常需要同时发起多个查询。本库提供了强大的批量查询功能,包括统计信息、批量操作和错误聚合。
1385
+
1386
+ ### 9.1 基础批量查询
1387
+
1388
+ 使用 `useEnhancedQueries` 同时发起多个查询:
1389
+
1390
+ ```tsx
1391
+ import { useEnhancedQueries, batchQueryUtils } from '@qiaopeng/tanstack-query-plus/hooks'
1392
+
1393
+ function Dashboard() {
1394
+ const { data: results, stats, operations } = useEnhancedQueries([
1395
+ { queryKey: ['users'], queryFn: fetchUsers },
1396
+ { queryKey: ['posts'], queryFn: fetchPosts },
1397
+ { queryKey: ['comments'], queryFn: fetchComments },
1398
+ { queryKey: ['analytics'], queryFn: fetchAnalytics },
1399
+ ])
1400
+
1401
+ // stats 包含聚合统计信息
1402
+ // {
1403
+ // total: 4, // 总查询数
1404
+ // loading: 1, // 加载中的数量
1405
+ // success: 2, // 成功的数量
1406
+ // error: 1, // 失败的数量
1407
+ // stale: 0, // 过期的数量
1408
+ // successRate: 50, // 成功率 (%)
1409
+ // errorRate: 25, // 错误率 (%)
1410
+ // }
1411
+
1412
+ return (
1413
+ <div>
1414
+ {/* 显示加载状态 */}
1415
+ <div className="mb-4 p-4 bg-gray-100 rounded">
1416
+ <p>加载进度: {stats.success}/{stats.total}</p>
1417
+ <p>成功率: {stats.successRate.toFixed(1)}%</p>
1418
+ {stats.loading > 0 && <p>正在加载 {stats.loading} 个查询...</p>}
1419
+ </div>
1420
+
1421
+ {/* 批量操作按钮 */}
1422
+ <div className="space-x-2 mb-4">
1423
+ <button onClick={() => operations.refetchAll()}>
1424
+ 刷新全部
1425
+ </button>
1426
+ <button onClick={() => operations.invalidateAll()}>
1427
+ 失效全部
1428
+ </button>
1429
+ <button onClick={() => operations.cancelAll()}>
1430
+ 取消全部
1431
+ </button>
1432
+ </div>
1433
+
1434
+ {/* 错误处理 */}
1435
+ {batchQueryUtils.hasError(results) && (
1436
+ <div className="p-4 bg-red-100 rounded mb-4">
1437
+ <p>部分查询失败</p>
1438
+ <button onClick={() => operations.retryFailed()}>
1439
+ 重试失败的查询
1440
+ </button>
1441
+ </div>
1442
+ )}
1443
+
1444
+ {/* 数据展示 */}
1445
+ {batchQueryUtils.isAllSuccess(results) && (
1446
+ <div className="grid grid-cols-2 gap-4">
1447
+ <UserStats data={results[0].data} />
1448
+ <PostStats data={results[1].data} />
1449
+ <CommentStats data={results[2].data} />
1450
+ <AnalyticsChart data={results[3].data} />
1451
+ </div>
1452
+ )}
1453
+ </div>
1454
+ )
1455
+ }
1456
+ ```
1457
+
1458
+ ### 9.2 批量查询工具函数
1459
+
1460
+ `batchQueryUtils` 提供了丰富的工具函数:
1461
+
1462
+ ```tsx
1463
+ import { batchQueryUtils } from '@qiaopeng/tanstack-query-plus/hooks'
1464
+
1465
+ // 状态检查
1466
+ batchQueryUtils.isAllLoading(results) // 是否全部加载中
1467
+ batchQueryUtils.isAllSuccess(results) // 是否全部成功
1468
+ batchQueryUtils.isAllPending(results) // 是否全部待处理
1469
+ batchQueryUtils.hasError(results) // 是否有错误
1470
+ batchQueryUtils.hasStale(results) // 是否有过期数据
1471
+ batchQueryUtils.isAnyFetching(results) // 是否有正在获取的
1472
+
1473
+ // 数据提取
1474
+ batchQueryUtils.getAllData(results) // 获取所有成功的数据
1475
+ batchQueryUtils.getSuccessData(results) // 获取成功数据(带类型)
1476
+ batchQueryUtils.getAllErrors(results) // 获取所有错误
1477
+ batchQueryUtils.getFirstError(results) // 获取第一个错误
1478
+
1479
+ // 高级功能
1480
+ batchQueryUtils.createErrorAggregate(results, queries) // 创建错误聚合
1481
+ batchQueryUtils.createOperationReport(results, queries, startTime) // 创建操作报告
1482
+ ```
1483
+
1484
+ ### 9.3 仪表盘查询(命名数据)
1485
+
1486
+ `useDashboardQueries` 让你可以用对象形式定义查询,返回命名的数据:
1487
+
1488
+ ```tsx
1489
+ import { useDashboardQueries } from '@qiaopeng/tanstack-query-plus/hooks'
1490
+
1491
+ function AdminDashboard() {
1492
+ const {
1493
+ data, // 命名的数据对象
1494
+ isLoading, // 任一查询加载中
1495
+ isError, // 任一查询出错
1496
+ isSuccess, // 全部成功
1497
+ stats, // 统计信息
1498
+ results // 原始结果数组
1499
+ } = useDashboardQueries({
1500
+ users: {
1501
+ queryKey: ['dashboard', 'users'],
1502
+ queryFn: fetchUserStats
1503
+ },
1504
+ revenue: {
1505
+ queryKey: ['dashboard', 'revenue'],
1506
+ queryFn: fetchRevenueStats
1507
+ },
1508
+ orders: {
1509
+ queryKey: ['dashboard', 'orders'],
1510
+ queryFn: fetchOrderStats
1511
+ },
1512
+ traffic: {
1513
+ queryKey: ['dashboard', 'traffic'],
1514
+ queryFn: fetchTrafficStats
1515
+ },
1516
+ })
1517
+
1518
+ if (isLoading) return <DashboardSkeleton />
1519
+ if (isError) return <DashboardError />
1520
+
1521
+ // 直接通过名称访问数据
1522
+ return (
1523
+ <div className="grid grid-cols-2 gap-6">
1524
+ <StatCard title="用户" value={data.users?.total} />
1525
+ <StatCard title="收入" value={data.revenue?.total} />
1526
+ <StatCard title="订单" value={data.orders?.count} />
1527
+ <TrafficChart data={data.traffic} />
1528
+ </div>
1529
+ )
1530
+ }
1531
+ ```
1532
+
1533
+ ### 9.4 依赖查询链
1534
+
1535
+ 有时候后续查询依赖于前一个查询的结果。使用 `useDependentBatchQueries`:
1536
+
1537
+ ```tsx
1538
+ import { useDependentBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'
1539
+
1540
+ function UserDashboard({ userId }) {
1541
+ const {
1542
+ primaryResult, // 主查询结果
1543
+ results, // 从查询结果数组
1544
+ stats, // 统计信息
1545
+ operations // 批量操作
1546
+ } = useDependentBatchQueries({
1547
+ // 主查询:获取用户信息
1548
+ primaryQuery: {
1549
+ queryKey: ['user', userId],
1550
+ queryFn: () => fetchUser(userId),
1551
+ },
1552
+ // 从查询:基于用户信息获取相关数据
1553
+ dependentQueries: (user) => [
1554
+ {
1555
+ queryKey: ['posts', user.id],
1556
+ queryFn: () => fetchUserPosts(user.id)
1557
+ },
1558
+ {
1559
+ queryKey: ['followers', user.id],
1560
+ queryFn: () => fetchFollowers(user.id)
1561
+ },
1562
+ {
1563
+ queryKey: ['following', user.id],
1564
+ queryFn: () => fetchFollowing(user.id)
1565
+ },
1566
+ // 可以使用用户数据中的任何信息
1567
+ ...(user.isAdmin ? [
1568
+ {
1569
+ queryKey: ['admin-stats'],
1570
+ queryFn: fetchAdminStats
1571
+ }
1572
+ ] : [])
1573
+ ],
1574
+ })
1575
+
1576
+ if (primaryResult.isLoading) return <div>加载用户信息...</div>
1577
+ if (primaryResult.isError) return <div>加载失败</div>
1578
+
1579
+ const user = primaryResult.data
1580
+ const [postsResult, followersResult, followingResult] = results
1581
+
1582
+ return (
1583
+ <div>
1584
+ <UserHeader user={user} />
1585
+
1586
+ <div className="grid grid-cols-3 gap-4">
1587
+ <PostList
1588
+ posts={postsResult?.data}
1589
+ isLoading={postsResult?.isLoading}
1590
+ />
1591
+ <FollowerList
1592
+ followers={followersResult?.data}
1593
+ isLoading={followersResult?.isLoading}
1594
+ />
1595
+ <FollowingList
1596
+ following={followingResult?.data}
1597
+ isLoading={followingResult?.isLoading}
1598
+ />
1599
+ </div>
1600
+ </div>
1601
+ )
1602
+ }
1603
+ ```
1604
+
1605
+ ### 9.5 动态批量查询
1606
+
1607
+ 当查询数量是动态的(比如基于一个 ID 列表):
1608
+
1609
+ ```tsx
1610
+ import { useDynamicBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'
1611
+
1612
+ function ProductComparison({ productIds }) {
1613
+ const { data: results, stats } = useDynamicBatchQueries({
1614
+ items: productIds, // 动态的 ID 列表
1615
+ queryKeyPrefix: ['product'],
1616
+ queryFn: (productId) => fetchProduct(productId),
1617
+ enabled: productIds.length > 0,
1618
+ staleTime: 60000,
1619
+ })
1620
+
1621
+ if (stats.loading > 0) {
1622
+ return <div>加载中... ({stats.success}/{stats.total})</div>
1623
+ }
1624
+
1625
+ const products = batchQueryUtils.getSuccessData(results)
1626
+
1627
+ return (
1628
+ <div className="grid grid-cols-3 gap-4">
1629
+ {products.map(product => (
1630
+ <ProductCard key={product.id} product={product} />
1631
+ ))}
1632
+ </div>
1633
+ )
1634
+ }
1635
+ ```
1636
+
1637
+ ### 9.6 自动刷新批量查询
1638
+
1639
+ 对于需要定期刷新的仪表盘:
1640
+
1641
+ ```tsx
1642
+ import { useAutoRefreshBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'
1643
+
1644
+ function LiveDashboard() {
1645
+ const { data: results, stats } = useAutoRefreshBatchQueries({
1646
+ queries: [
1647
+ { queryKey: ['live-users'], queryFn: fetchLiveUsers },
1648
+ { queryKey: ['live-orders'], queryFn: fetchLiveOrders },
1649
+ { queryKey: ['live-revenue'], queryFn: fetchLiveRevenue },
1650
+ ],
1651
+ refreshInterval: 30000, // 每 30 秒刷新
1652
+ enabled: true,
1653
+ })
1654
+
1655
+ // ...
1656
+ }
1657
+ ```
1658
+
1659
+ ### 9.7 条件批量查询
1660
+
1661
+ 只执行满足条件的查询:
1662
+
1663
+ ```tsx
1664
+ import { useConditionalBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'
1665
+
1666
+ function ConditionalDashboard({ userRole }) {
1667
+ const { data: results } = useConditionalBatchQueries([
1668
+ {
1669
+ queryKey: ['basic-stats'],
1670
+ queryFn: fetchBasicStats,
1671
+ enabled: true // 总是执行
1672
+ },
1673
+ {
1674
+ queryKey: ['admin-stats'],
1675
+ queryFn: fetchAdminStats,
1676
+ enabled: userRole === 'admin' // 只有管理员执行
1677
+ },
1678
+ {
1679
+ queryKey: ['premium-stats'],
1680
+ queryFn: fetchPremiumStats,
1681
+ enabled: userRole === 'premium' || userRole === 'admin'
1682
+ },
1683
+ ])
1684
+
1685
+ // ...
1686
+ }
1687
+ ```
1688
+
1689
+ 现在你已经掌握了批量查询。为了提升用户体验,我们可以在用户需要数据之前就预先获取。接下来,让我们学习智能预取。
1690
+
1691
+ ---
1692
+
1693
+ ## 10. 第八步:智能预取
1694
+
1695
+
1696
+ 预取(Prefetch)是指在用户实际需要数据之前就提前获取。这可以显著提升用户体验,让页面切换感觉更快。本库提供了多种预取策略。
1697
+
1698
+ ### 10.1 悬停预取
1699
+
1700
+ 当用户将鼠标悬停在链接上时预取数据:
1701
+
1702
+ ```tsx
1703
+ import { useHoverPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1704
+
1705
+ function UserLink({ userId, userName }) {
1706
+ // 返回需要绑定到元素的事件处理器
1707
+ const hoverProps = useHoverPrefetch(
1708
+ ['user', userId], // queryKey
1709
+ () => fetchUser(userId), // queryFn
1710
+ {
1711
+ hoverDelay: 200, // 悬停 200ms 后开始预取(避免快速划过触发)
1712
+ minInterval: 1000, // 同一个 key 最小预取间隔
1713
+ staleTime: 30000, // 数据新鲜时不预取
1714
+ }
1715
+ )
1716
+
1717
+ return (
1718
+ <a
1719
+ href={`/user/${userId}`}
1720
+ {...hoverProps} // 绑定 onMouseEnter, onMouseLeave, onFocus
1721
+ >
1722
+ {userName}
1723
+ </a>
1724
+ )
1725
+ }
1726
+ ```
1727
+
1728
+ **工作原理:**
1729
+ 1. 用户鼠标移入元素
1730
+ 2. 等待 `hoverDelay` 毫秒
1731
+ 3. 检查数据是否已缓存且新鲜
1732
+ 4. 如果需要,发起预取请求
1733
+ 5. 用户点击链接时,数据已经准备好了
1734
+
1735
+ ### 10.2 智能预取
1736
+
1737
+ `useSmartPrefetch` 会自动检测网络状态,在慢网络时跳过预取:
1738
+
1739
+ ```tsx
1740
+ import { useSmartPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1741
+
1742
+ function ProductCard({ productId }) {
1743
+ const { prefetch, shouldPrefetch, clearPrefetchHistory } = useSmartPrefetch()
1744
+
1745
+ const handleMouseEnter = () => {
1746
+ // 自动检测网络状态,慢网络时不预取
1747
+ prefetch(
1748
+ ['product', productId],
1749
+ () => fetchProduct(productId),
1750
+ { staleTime: 60000 }
1751
+ )
1752
+ }
1753
+
1754
+ return (
1755
+ <div
1756
+ onMouseEnter={handleMouseEnter}
1757
+ className="product-card"
1758
+ >
1759
+ <ProductImage id={productId} />
1760
+ <ProductInfo id={productId} />
1761
+
1762
+ {/* 可选:显示网络状态 */}
1763
+ {!shouldPrefetch && (
1764
+ <span className="text-xs text-gray-400">
1765
+ 慢网络,已禁用预取
1766
+ </span>
1767
+ )}
1768
+ </div>
1769
+ )
1770
+ }
1771
+ ```
1772
+
1773
+ ### 10.3 视口预取
1774
+
1775
+ 当元素进入视口时预取(需要安装 `react-intersection-observer`):
1776
+
1777
+ ```tsx
1778
+ import { useInViewPrefetch } from '@qiaopeng/tanstack-query-plus/hooks/inview'
1779
+
1780
+ function LazySection({ sectionId }) {
1781
+ // 返回一个 ref,绑定到需要监听的元素
1782
+ const ref = useInViewPrefetch(
1783
+ ['section', sectionId],
1784
+ () => fetchSectionData(sectionId),
1785
+ {
1786
+ threshold: 0.1, // 10% 可见时触发
1787
+ rootMargin: '100px', // 提前 100px 触发(元素还没完全进入视口)
1788
+ triggerOnce: true, // 只触发一次
1789
+ }
1790
+ )
1791
+
1792
+ return (
1793
+ <section ref={ref}>
1794
+ <SectionContent id={sectionId} />
1795
+ </section>
1796
+ )
1797
+ }
1798
+ ```
1799
+
1800
+ **使用场景:**
1801
+ - 长页面的各个区块
1802
+ - 图片懒加载
1803
+ - 无限滚动列表的下一批数据
1804
+
1805
+ ### 10.4 路由预取
1806
+
1807
+ 在路由切换前预取下一个页面的数据:
1808
+
1809
+ ```tsx
1810
+ import { useRoutePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1811
+ import { Link, useNavigate } from 'react-router-dom'
1812
+
1813
+ function Navigation() {
1814
+ const prefetch = useRoutePrefetch()
1815
+ const navigate = useNavigate()
1816
+
1817
+ const handlePrefetchUser = (userId) => {
1818
+ prefetch(
1819
+ ['user', userId],
1820
+ () => fetchUser(userId),
1821
+ { staleTime: 30000 }
1822
+ )
1823
+ }
1824
+
1825
+ return (
1826
+ <nav>
1827
+ <Link
1828
+ to="/user/123"
1829
+ onMouseEnter={() => handlePrefetchUser('123')}
1830
+ >
1831
+ 用户 123
1832
+ </Link>
1833
+
1834
+ {/* 或者在按钮点击前预取 */}
1835
+ <button
1836
+ onMouseEnter={() => handlePrefetchUser('456')}
1837
+ onClick={() => navigate('/user/456')}
1838
+ >
1839
+ 查看用户 456
1840
+ </button>
1841
+ </nav>
1842
+ )
1843
+ }
1844
+ ```
1845
+
1846
+ ### 10.5 条件预取
1847
+
1848
+ 只在满足条件时预取:
1849
+
1850
+ ```tsx
1851
+ import { useConditionalPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1852
+
1853
+ function SearchResults({ query, isHovered }) {
1854
+ // 当 isHovered 为 true 时预取
1855
+ useConditionalPrefetch(
1856
+ ['search', query],
1857
+ () => fetchSearchResults(query),
1858
+ isHovered, // 条件
1859
+ { delay: 300 } // 延迟 300ms
1860
+ )
1861
+
1862
+ // ...
1863
+ }
1864
+ ```
1865
+
1866
+ ### 10.6 空闲时预取
1867
+
1868
+ 利用浏览器空闲时间预取:
1869
+
1870
+ ```tsx
1871
+ import { useIdlePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1872
+
1873
+ function App() {
1874
+ // 在浏览器空闲时预取常用数据
1875
+ useIdlePrefetch(
1876
+ ['common-data'],
1877
+ fetchCommonData,
1878
+ {
1879
+ timeout: 2000, // 最多等待 2 秒进入空闲
1880
+ enabled: true
1881
+ }
1882
+ )
1883
+
1884
+ return <MainContent />
1885
+ }
1886
+ ```
1887
+
1888
+ **工作原理:**
1889
+ - 使用 `requestIdleCallback` API
1890
+ - 在浏览器空闲时执行预取
1891
+ - 不影响主线程性能
1892
+
1893
+ ### 10.7 周期预取
1894
+
1895
+ 定期预取数据,保持缓存新鲜:
1896
+
1897
+ ```tsx
1898
+ import { usePeriodicPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1899
+
1900
+ function Dashboard() {
1901
+ // 每分钟预取一次
1902
+ usePeriodicPrefetch(
1903
+ ['dashboard-stats'],
1904
+ fetchDashboardStats,
1905
+ {
1906
+ interval: 60000, // 60 秒
1907
+ enabled: true
1908
+ }
1909
+ )
1910
+
1911
+ // ...
1912
+ }
1913
+ ```
1914
+
1915
+ ### 10.8 批量预取
1916
+
1917
+ 一次预取多个查询:
1918
+
1919
+ ```tsx
1920
+ import { useBatchPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1921
+
1922
+ function HomePage() {
1923
+ const batchPrefetch = useBatchPrefetch()
1924
+
1925
+ useEffect(() => {
1926
+ // 页面加载后预取常用数据
1927
+ batchPrefetch([
1928
+ { queryKey: ['featured-products'], queryFn: fetchFeaturedProducts },
1929
+ { queryKey: ['categories'], queryFn: fetchCategories },
1930
+ { queryKey: ['promotions'], queryFn: fetchPromotions },
1931
+ ])
1932
+ }, [batchPrefetch])
1933
+
1934
+ // ...
1935
+ }
1936
+ ```
1937
+
1938
+ ### 10.9 优先级预取
1939
+
1940
+ 按优先级执行预取任务:
1941
+
1942
+ ```tsx
1943
+ import { usePriorityPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1944
+
1945
+ function App() {
1946
+ const { addPrefetchTask, processTasks, taskCount } = usePriorityPrefetch()
1947
+
1948
+ useEffect(() => {
1949
+ // 添加不同优先级的预取任务
1950
+ addPrefetchTask(['critical-data'], fetchCriticalData, 'high')
1951
+ addPrefetchTask(['important-data'], fetchImportantData, 'medium')
1952
+ addPrefetchTask(['optional-data'], fetchOptionalData, 'low')
1953
+
1954
+ // 按优先级顺序执行
1955
+ processTasks()
1956
+ }, [])
1957
+
1958
+ return (
1959
+ <div>
1960
+ {taskCount > 0 && <span>预取中... ({taskCount} 个任务)</span>}
1961
+ <MainContent />
1962
+ </div>
1963
+ )
1964
+ }
1965
+ ```
1966
+
1967
+ ### 10.10 预测性预取
1968
+
1969
+ 基于用户行为预测并预取:
1970
+
1971
+ ```tsx
1972
+ import { usePredictivePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
1973
+
1974
+ function ProductBrowser() {
1975
+ const {
1976
+ recordInteraction,
1977
+ getPredictions,
1978
+ prefetchPredicted
1979
+ } = usePredictivePrefetch()
1980
+
1981
+ const handleProductClick = (productId) => {
1982
+ // 记录用户交互
1983
+ recordInteraction('click', productId)
1984
+ navigate(`/product/${productId}`)
1985
+ }
1986
+
1987
+ const handleProductHover = (productId) => {
1988
+ recordInteraction('hover', productId)
1989
+ }
1990
+
1991
+ // 基于历史行为预取
1992
+ useEffect(() => {
1993
+ prefetchPredicted((target) => ({
1994
+ queryKey: ['product', target],
1995
+ queryFn: () => fetchProduct(target)
1996
+ }))
1997
+ }, [prefetchPredicted])
1998
+
1999
+ return (
2000
+ <div>
2001
+ {products.map(product => (
2002
+ <ProductCard
2003
+ key={product.id}
2004
+ product={product}
2005
+ onClick={() => handleProductClick(product.id)}
2006
+ onMouseEnter={() => handleProductHover(product.id)}
2007
+ />
2008
+ ))}
2009
+ </div>
2010
+ )
2011
+ }
2012
+ ```
2013
+
2014
+ ### 10.11 预取最佳实践
2015
+
2016
+ 1. **不要过度预取**:只预取用户很可能需要的数据
2017
+ 2. **设置合理的 staleTime**:避免重复预取新鲜数据
2018
+ 3. **考虑网络状况**:使用 `useSmartPrefetch` 在慢网络时禁用
2019
+ 4. **使用延迟**:悬停预取应该有延迟,避免快速划过触发
2020
+ 5. **优先级管理**:关键数据优先预取
2021
+
2022
+ 现在你已经掌握了预取策略。接下来,让我们学习 Suspense 模式,它可以让你的代码更简洁。
2023
+
2024
+ ---
2025
+
2026
+ ## 11. 第九步:Suspense 模式
2027
+
2028
+
2029
+ React Suspense 是一种声明式的加载状态处理方式。配合 TanStack Query 的 Suspense 模式,可以让组件代码更简洁,不再需要手动处理 `isLoading` 状态。
2030
+
2031
+ ### 11.1 传统模式 vs Suspense 模式
2032
+
2033
+ **传统模式:**
2034
+ ```tsx
2035
+ function UserProfile({ userId }) {
2036
+ const { data, isLoading, isError, error } = useQuery({
2037
+ queryKey: ['user', userId],
2038
+ queryFn: () => fetchUser(userId),
2039
+ })
2040
+
2041
+ if (isLoading) return <Loading />
2042
+ if (isError) return <Error message={error.message} />
2043
+
2044
+ return <div>{data.name}</div>
2045
+ }
2046
+ ```
2047
+
2048
+ **Suspense 模式:**
2049
+ ```tsx
2050
+ function UserProfile({ userId }) {
2051
+ // 数据一定存在,不需要处理 loading 状态
2052
+ const { data } = useSuspenseQuery({
2053
+ queryKey: ['user', userId],
2054
+ queryFn: () => fetchUser(userId),
2055
+ })
2056
+
2057
+ return <div>{data.name}</div>
2058
+ }
2059
+
2060
+ // 在父组件处理 loading 和 error
2061
+ function UserPage({ userId }) {
2062
+ return (
2063
+ <Suspense fallback={<Loading />}>
2064
+ <ErrorBoundary fallback={<Error />}>
2065
+ <UserProfile userId={userId} />
2066
+ </ErrorBoundary>
2067
+ </Suspense>
2068
+ )
2069
+ }
2070
+ ```
2071
+
2072
+ ### 11.2 使用增强 Suspense 查询
2073
+
2074
+ ```tsx
2075
+ import { useEnhancedSuspenseQuery } from '@qiaopeng/tanstack-query-plus/hooks'
2076
+
2077
+ function UserData({ userId }) {
2078
+ const { data } = useEnhancedSuspenseQuery({
2079
+ queryKey: ['user', userId],
2080
+ queryFn: () => fetchUser(userId),
2081
+ })
2082
+
2083
+ // data 一定存在,TypeScript 类型也是非空的
2084
+ return (
2085
+ <div>
2086
+ <h1>{data.name}</h1>
2087
+ <p>{data.email}</p>
2088
+ </div>
2089
+ )
2090
+ }
2091
+ ```
2092
+
2093
+ ### 11.3 使用 SuspenseWrapper 组件
2094
+
2095
+ 本库提供了 `SuspenseWrapper` 和 `QuerySuspenseWrapper` 组件,它们组合了 Suspense 和 ErrorBoundary:
2096
+
2097
+ ```tsx
2098
+ import { SuspenseWrapper, QuerySuspenseWrapper } from '@qiaopeng/tanstack-query-plus/components'
2099
+
2100
+ function UserPage({ userId }) {
2101
+ return (
2102
+ <SuspenseWrapper
2103
+ fallback={<UserSkeleton />}
2104
+ errorFallback={(error, reset) => (
2105
+ <div className="error-container">
2106
+ <p>加载失败: {error.message}</p>
2107
+ <button onClick={reset}>重试</button>
2108
+ </div>
2109
+ )}
2110
+ onError={(error, info) => {
2111
+ // 上报错误到监控系统
2112
+ reportError(error, info)
2113
+ }}
2114
+ resetKeys={[userId]} // userId 变化时重置错误状态
2115
+ >
2116
+ <UserProfile userId={userId} />
2117
+ </SuspenseWrapper>
2118
+ )
2119
+ }
2120
+
2121
+ // QuerySuspenseWrapper 是 SuspenseWrapper 的别名,语义更清晰
2122
+ function DataPage() {
2123
+ return (
2124
+ <QuerySuspenseWrapper
2125
+ fallback={<DataSkeleton />}
2126
+ errorFallback={(error, reset) => (
2127
+ <ErrorDisplay error={error} onRetry={reset} />
2128
+ )}
2129
+ >
2130
+ <DataComponent />
2131
+ </QuerySuspenseWrapper>
2132
+ )
2133
+ }
2134
+ ```
2135
+
2136
+ **注意**:`QuerySuspenseWrapper` 和 `SuspenseWrapper` 功能完全相同,只是名称不同。使用 `QuerySuspenseWrapper` 可以让代码语义更清晰,表明这是用于查询的 Suspense 包装器。
2137
+
2138
+ ### 11.4 QueryErrorBoundary
2139
+
2140
+ 专门为查询设计的错误边界,集成了 React Query 的错误重置:
2141
+
2142
+ ```tsx
2143
+ import { QueryErrorBoundary } from '@qiaopeng/tanstack-query-plus/components'
2144
+
2145
+ function DataSection() {
2146
+ return (
2147
+ <QueryErrorBoundary
2148
+ fallback={(error, reset) => (
2149
+ <div>
2150
+ <p>查询失败: {error.message}</p>
2151
+ <button onClick={reset}>重新加载</button>
2152
+ </div>
2153
+ )}
2154
+ resetKeys={['data-key']}
2155
+ >
2156
+ <Suspense fallback={<Loading />}>
2157
+ <DataComponent />
2158
+ </Suspense>
2159
+ </QueryErrorBoundary>
2160
+ )
2161
+ }
2162
+ ```
2163
+
2164
+ ### 11.5 Loading 组件库
2165
+
2166
+ 本库提供了多种预设的 Loading 组件:
2167
+
2168
+ ```tsx
2169
+ import {
2170
+ DefaultLoadingFallback, // 默认加载指示器
2171
+ SmallLoadingIndicator, // 小型加载指示器
2172
+ FullScreenLoading, // 全屏加载
2173
+ TextSkeletonFallback, // 文本骨架屏
2174
+ CardSkeletonFallback, // 卡片骨架屏
2175
+ ListSkeletonFallback, // 列表骨架屏
2176
+ PageSkeletonFallback, // 页面骨架屏
2177
+ } from '@qiaopeng/tanstack-query-plus/components'
2178
+
2179
+ // 使用示例
2180
+ <SuspenseWrapper fallback={<DefaultLoadingFallback />}>
2181
+ <Content />
2182
+ </SuspenseWrapper>
2183
+
2184
+ <SuspenseWrapper fallback={<ListSkeletonFallback items={5} />}>
2185
+ <UserList />
2186
+ </SuspenseWrapper>
2187
+
2188
+ <SuspenseWrapper fallback={<CardSkeletonFallback />}>
2189
+ <ProductCard />
2190
+ </SuspenseWrapper>
2191
+
2192
+ // 小型加载指示器(用于按钮等)
2193
+ <SmallLoadingIndicator size="sm" /> // sm | md | lg
2194
+
2195
+ // 全屏加载(用于页面切换)
2196
+ <FullScreenLoading message="正在加载页面..." />
2197
+
2198
+ // 文本骨架屏
2199
+ <TextSkeletonFallback lines={3} />
2200
+ ```
2201
+
2202
+ ### 11.6 Suspense 无限查询
2203
+
2204
+ ```tsx
2205
+ import { useEnhancedSuspenseInfiniteQuery } from '@qiaopeng/tanstack-query-plus/hooks'
2206
+
2207
+ function PostList() {
2208
+ const { data, fetchNextPage, hasNextPage } = useEnhancedSuspenseInfiniteQuery({
2209
+ queryKey: ['posts'],
2210
+ queryFn: ({ pageParam }) => fetchPosts(pageParam),
2211
+ initialPageParam: 0,
2212
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
2213
+ })
2214
+
2215
+ return (
2216
+ <div>
2217
+ {data.pages.flatMap(page => page.items).map(post => (
2218
+ <PostCard key={post.id} post={post} />
2219
+ ))}
2220
+ {hasNextPage && (
2221
+ <button onClick={() => fetchNextPage()}>加载更多</button>
2222
+ )}
2223
+ </div>
2224
+ )
2225
+ }
2226
+
2227
+ // 使用
2228
+ <SuspenseWrapper fallback={<PostListSkeleton />}>
2229
+ <PostList />
2230
+ </SuspenseWrapper>
2231
+ ```
2232
+
2233
+ ### 11.7 创建可复用的 Suspense 查询
2234
+
2235
+ 使用工厂函数创建可复用的 Suspense 查询:
2236
+
2237
+ ```tsx
2238
+ import { createSuspenseQuery } from '@qiaopeng/tanstack-query-plus/hooks'
2239
+
2240
+ // 创建一个可复用的用户查询 hook
2241
+ // 参数1: queryKey 生成函数,接收变量返回 queryKey
2242
+ // 参数2: queryFn,接收 QueryFunctionContext(包含 queryKey, signal 等)
2243
+ // 参数3: 可选的默认配置
2244
+ const useUserSuspense = createSuspenseQuery(
2245
+ (userId: string) => ['user', userId],
2246
+ async (context) => {
2247
+ // context.queryKey 是 ['user', userId]
2248
+ // context.signal 可用于取消请求
2249
+ const [, userId] = context.queryKey
2250
+ return fetchUser(userId as string)
2251
+ },
2252
+ { staleTime: 30000 }
2253
+ )
2254
+
2255
+ // 使用:传入变量,返回 Suspense 查询结果
2256
+ function UserProfile({ userId }) {
2257
+ const { data } = useUserSuspense(userId)
2258
+ return <div>{data.name}</div>
2259
+ }
2260
+ ```
2261
+
2262
+ ### 11.8 嵌套 Suspense
2263
+
2264
+ 对于复杂页面,可以使用嵌套的 Suspense 来实现渐进式加载:
2265
+
2266
+ ```tsx
2267
+ function UserDashboard({ userId }) {
2268
+ return (
2269
+ <div>
2270
+ {/* 用户信息先加载 */}
2271
+ <SuspenseWrapper fallback={<UserHeaderSkeleton />}>
2272
+ <UserHeader userId={userId} />
2273
+ </SuspenseWrapper>
2274
+
2275
+ <div className="grid grid-cols-2 gap-4">
2276
+ {/* 文章列表独立加载 */}
2277
+ <SuspenseWrapper fallback={<PostListSkeleton />}>
2278
+ <UserPosts userId={userId} />
2279
+ </SuspenseWrapper>
2280
+
2281
+ {/* 统计信息独立加载 */}
2282
+ <SuspenseWrapper fallback={<StatsSkeleton />}>
2283
+ <UserStats userId={userId} />
2284
+ </SuspenseWrapper>
2285
+ </div>
2286
+ </div>
2287
+ )
2288
+ }
2289
+ ```
2290
+
2291
+ 这样,各个区块可以独立加载,用户能更快看到部分内容。
2292
+
2293
+ ### 11.9 Suspense 最佳实践
2294
+
2295
+ 1. **合理划分 Suspense 边界**:不要把整个页面包在一个 Suspense 里
2296
+ 2. **使用骨架屏**:比简单的 "加载中..." 体验更好
2297
+ 3. **处理错误**:始终配合 ErrorBoundary 使用
2298
+ 4. **设置 resetKeys**:确保参数变化时能正确重置状态
2299
+ 5. **考虑 SSR**:Suspense 在服务端渲染时有特殊行为
2300
+
2301
+ 现在你已经掌握了 Suspense 模式。接下来,让我们学习如何实现离线支持和数据持久化。
2302
+
2303
+ ---
2304
+
2305
+ ## 12. 第十步:离线支持与持久化
2306
+
2307
+
2308
+ 现代 Web 应用需要在网络不稳定甚至离线时也能正常工作。本库提供了完整的离线支持和数据持久化功能。
2309
+
2310
+ ### 12.1 启用持久化
2311
+
2312
+ 在第 3 章我们已经介绍了如何启用持久化:
2313
+
2314
+ ```tsx
2315
+ <PersistQueryClientProvider
2316
+ client={queryClient}
2317
+ enablePersistence // 启用 localStorage 持久化
2318
+ enableOfflineSupport // 启用离线状态监听
2319
+ >
2320
+ <App />
2321
+ </PersistQueryClientProvider>
2322
+ ```
2323
+
2324
+ 启用后:
2325
+ - 查询缓存会自动保存到 localStorage
2326
+ - 页面刷新后自动恢复
2327
+ - 网络状态变化会自动处理
2328
+
2329
+ ### 12.2 监听网络状态
2330
+
2331
+ 使用 `usePersistenceStatus` hook 可以方便地监听网络状态:
2332
+
2333
+ ```tsx
2334
+ import { usePersistenceStatus } from '@qiaopeng/tanstack-query-plus'
2335
+
2336
+ function NetworkIndicator() {
2337
+ const { isOnline, isOffline } = usePersistenceStatus()
2338
+
2339
+ return (
2340
+ <div className={`network-status ${isOffline ? 'offline' : 'online'}`}>
2341
+ {isOffline ? (
2342
+ <span>📴 离线模式 - 数据可能不是最新的</span>
2343
+ ) : (
2344
+ <span>🌐 在线</span>
2345
+ )}
2346
+ </div>
2347
+ )
2348
+ }
2349
+ ```
2350
+
2351
+ **底层 API**:如果你需要更细粒度的控制,也可以直接使用底层 API:
2352
+
2353
+ ```tsx
2354
+ import { useState, useEffect } from 'react'
2355
+ import { isOnline, subscribeToOnlineStatus } from '@qiaopeng/tanstack-query-plus/features'
2356
+
2357
+ function NetworkIndicator() {
2358
+ const [online, setOnline] = useState(isOnline())
2359
+
2360
+ useEffect(() => {
2361
+ const unsubscribe = subscribeToOnlineStatus(setOnline)
2362
+ return unsubscribe
2363
+ }, [])
2364
+
2365
+ return <div>{online ? '在线' : '离线'}</div>
2366
+ }
2367
+ ```
2368
+
2369
+ ### 12.3 手动管理持久化
2370
+
2371
+ 使用 `usePersistenceManager` hook 可以方便地管理缓存:
2372
+
2373
+ ```tsx
2374
+ import { usePersistenceManager } from '@qiaopeng/tanstack-query-plus'
2375
+
2376
+ function SettingsPage() {
2377
+ const { clearCache, getOnlineStatus } = usePersistenceManager()
2378
+
2379
+ const handleClearCache = () => {
2380
+ clearCache() // 清除默认缓存
2381
+ // 或指定 key: clearCache('my-cache-key')
2382
+ alert('缓存已清除')
2383
+ }
2384
+
2385
+ return (
2386
+ <div>
2387
+ <p>网络状态: {getOnlineStatus() ? '在线' : '离线'}</p>
2388
+ <button onClick={handleClearCache}>清除缓存</button>
2389
+ </div>
2390
+ )
2391
+ }
2392
+ ```
2393
+
2394
+ **底层 API**:也可以直接使用底层函数:
2395
+
2396
+ ```tsx
2397
+ import { clearCache, isOnline } from '@qiaopeng/tanstack-query-plus/features'
2398
+
2399
+ function SettingsPage() {
2400
+ const handleClearCache = () => {
2401
+ clearCache() // 清除默认缓存
2402
+ alert('缓存已清除')
2403
+ }
2404
+
2405
+ return (
2406
+ <div>
2407
+ <p>网络状态: {isOnline() ? '在线' : '离线'}</p>
2408
+ <button onClick={handleClearCache}>清除缓存</button>
2409
+ </div>
2410
+ )
2411
+ }
2412
+ ```
2413
+
2414
+ ### 12.4 离线功能 API
2415
+
2416
+ 本库提供了丰富的离线功能 API:
2417
+
2418
+ ```tsx
2419
+ import {
2420
+ isOnline,
2421
+ subscribeToOnlineStatus,
2422
+ clearCache,
2423
+ clearExpiredCache,
2424
+ checkStorageSize,
2425
+ getStorageStats,
2426
+ } from '@qiaopeng/tanstack-query-plus/features'
2427
+
2428
+ // 检查网络状态
2429
+ const online = isOnline()
2430
+
2431
+ // 订阅网络状态变化
2432
+ const unsubscribe = subscribeToOnlineStatus((online) => {
2433
+ console.log('网络状态:', online ? '在线' : '离线')
2434
+ if (online) {
2435
+ // 网络恢复,可以同步数据
2436
+ syncPendingChanges()
2437
+ }
2438
+ })
2439
+
2440
+ // 清除缓存
2441
+ clearCache() // 清除所有缓存
2442
+ clearCache('my-cache-key') // 清除指定缓存
2443
+
2444
+ // 清除过期缓存
2445
+ clearExpiredCache('tanstack-query-cache', 24 * 60 * 60 * 1000) // 清除超过 24 小时的缓存
2446
+
2447
+ // 检查存储大小
2448
+ const sizeInfo = checkStorageSize()
2449
+ console.log(`缓存大小: ${sizeInfo.sizeInMB}MB`)
2450
+ if (sizeInfo.shouldMigrate) {
2451
+ console.log('建议迁移到 IndexedDB')
2452
+ }
2453
+
2454
+ // 获取存储统计
2455
+ const stats = getStorageStats()
2456
+ console.log({
2457
+ exists: stats.exists,
2458
+ age: stats.age, // 缓存年龄(毫秒)
2459
+ queriesCount: stats.queriesCount,
2460
+ mutationsCount: stats.mutationsCount,
2461
+ sizeInfo: stats.sizeInfo,
2462
+ })
2463
+ ```
2464
+
2465
+ ### 12.5 离线队列管理器
2466
+
2467
+ 对于需要在离线时也能操作的应用,可以使用离线队列管理器:
2468
+
2469
+ ```tsx
2470
+ import { createOfflineQueueManager, mutationRegistry } from '@qiaopeng/tanstack-query-plus/features'
2471
+
2472
+ // 创建队列管理器
2473
+ const queueManager = createOfflineQueueManager({
2474
+ maxSize: 100, // 最大队列大小
2475
+ autoExecuteInterval: 5000, // 自动执行间隔(毫秒)
2476
+ executeOnReconnect: true, // 网络恢复时自动执行
2477
+ operationTimeout: 30000, // 操作超时时间
2478
+ concurrency: 3, // 并发执行数
2479
+ })
2480
+
2481
+ // 注册 mutation 函数(用于恢复队列时执行)
2482
+ mutationRegistry.register('updateUser', () => updateUserAPI(data))
2483
+ mutationRegistry.register('createPost', () => createPostAPI(data))
2484
+
2485
+ // 添加操作到队列
2486
+ async function handleUpdateUser(userData) {
2487
+ if (!isOnline()) {
2488
+ // 离线时添加到队列
2489
+ await queueManager.add({
2490
+ mutationKey: ['updateUser'],
2491
+ mutationFn: () => updateUserAPI(userData),
2492
+ priority: 1, // 优先级(数字越大越优先)
2493
+ })
2494
+ toast.info('已保存到离线队列,网络恢复后将自动同步')
2495
+ } else {
2496
+ // 在线时直接执行
2497
+ await updateUserAPI(userData)
2498
+ }
2499
+ }
2500
+
2501
+ // 获取队列状态
2502
+ const state = queueManager.getState()
2503
+ console.log({
2504
+ isOffline: state.isOffline,
2505
+ queuedOperations: state.queuedOperations,
2506
+ failedQueries: state.failedQueries,
2507
+ isRecovering: state.isRecovering,
2508
+ })
2509
+
2510
+ // 手动执行队列
2511
+ const result = await queueManager.execute()
2512
+ console.log(`成功: ${result.success}, 失败: ${result.failed}, 跳过: ${result.skipped}`)
2513
+
2514
+ // 获取队列中的操作
2515
+ const operations = queueManager.getOperations()
2516
+
2517
+ // 清空队列
2518
+ await queueManager.clear()
2519
+
2520
+ // 销毁管理器(清理定时器和监听器)
2521
+ queueManager.destroy()
2522
+ ```
2523
+
2524
+ ### 12.6 完整的离线应用示例
2525
+
2526
+ ```tsx
2527
+ import { useState, useEffect } from 'react'
2528
+ import { createOfflineQueueManager } from '@qiaopeng/tanstack-query-plus/features'
2529
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
2530
+ import { useQueryClient, usePersistenceStatus } from '@qiaopeng/tanstack-query-plus'
2531
+
2532
+ // 创建全局队列管理器
2533
+ const offlineQueue = createOfflineQueueManager({
2534
+ executeOnReconnect: true,
2535
+ autoExecuteInterval: 10000,
2536
+ })
2537
+
2538
+ function TodoApp() {
2539
+ const queryClient = useQueryClient()
2540
+ const { isOnline: networkStatus } = usePersistenceStatus() // 使用 hook 监听网络状态
2541
+ const [pendingCount, setPendingCount] = useState(0)
2542
+
2543
+ // 网络状态变化时显示提示
2544
+ useEffect(() => {
2545
+ if (networkStatus) {
2546
+ toast.success('网络已恢复,正在同步数据...')
2547
+ } else {
2548
+ toast.warning('网络已断开,操作将在恢复后同步')
2549
+ }
2550
+ }, [networkStatus])
2551
+
2552
+ // 更新待处理数量
2553
+ useEffect(() => {
2554
+ const interval = setInterval(() => {
2555
+ setPendingCount(offlineQueue.getState().queuedOperations)
2556
+ }, 1000)
2557
+ return () => clearInterval(interval)
2558
+ }, [])
2559
+
2560
+ // 查询 todos(离线时使用缓存)
2561
+ const { data: todos } = useEnhancedQuery({
2562
+ queryKey: ['todos'],
2563
+ queryFn: fetchTodos,
2564
+ staleTime: 60000,
2565
+ })
2566
+
2567
+ // 添加 todo
2568
+ const addTodo = async (title) => {
2569
+ const todoData = { title, done: false, id: `temp-${Date.now()}` }
2570
+
2571
+ if (!networkStatus) {
2572
+ // 离线:添加到队列
2573
+ await offlineQueue.add({
2574
+ mutationKey: ['addTodo'],
2575
+ mutationFn: () => api.createTodo(todoData),
2576
+ priority: 1,
2577
+ })
2578
+ // 乐观更新本地缓存
2579
+ queryClient.setQueryData(['todos'], (old) => [todoData, ...(old || [])])
2580
+ toast.info('已添加到离线队列')
2581
+ } else {
2582
+ // 在线:直接执行
2583
+ await api.createTodo(todoData)
2584
+ queryClient.invalidateQueries({ queryKey: ['todos'] })
2585
+ }
2586
+ }
2587
+
2588
+ return (
2589
+ <div>
2590
+ {/* 网络状态指示器 */}
2591
+ <div className={`status-bar ${networkStatus ? 'online' : 'offline'}`}>
2592
+ {networkStatus ? '🌐 在线' : '📴 离线'}
2593
+ {pendingCount > 0 && (
2594
+ <span className="ml-2">
2595
+ ({pendingCount} 个操作待同步)
2596
+ </span>
2597
+ )}
2598
+ </div>
2599
+
2600
+ {/* Todo 列表 */}
2601
+ <TodoList todos={todos} onAdd={addTodo} />
2602
+ </div>
2603
+ )
2604
+ }
2605
+ ```
2606
+
2607
+ ### 12.7 存储迁移
2608
+
2609
+ 当缓存数据变大时,可以迁移到 IndexedDB:
2610
+
2611
+ ```tsx
2612
+ import { migrateToIndexedDB, checkStorageSize } from '@qiaopeng/tanstack-query-plus/features'
2613
+
2614
+ async function checkAndMigrate() {
2615
+ const sizeInfo = checkStorageSize()
2616
+
2617
+ if (sizeInfo.shouldMigrate) {
2618
+ console.log(`缓存大小 ${sizeInfo.sizeInMB}MB,建议迁移到 IndexedDB`)
2619
+
2620
+ // 假设你有一个 IndexedDB 存储实现
2621
+ const success = await migrateToIndexedDB(
2622
+ 'tanstack-query-cache', // localStorage key
2623
+ 'tanstack-query-cache', // IndexedDB key
2624
+ indexedDBStorage // IndexedDB 存储实例
2625
+ )
2626
+
2627
+ if (success) {
2628
+ console.log('迁移成功')
2629
+ }
2630
+ }
2631
+ }
2632
+ ```
2633
+
2634
+ ### 12.8 离线最佳实践
2635
+
2636
+ 1. **合理设置 staleTime**:离线时用户看到的是缓存数据,设置合理的 staleTime 确保数据不会太旧
2637
+ 2. **提供视觉反馈**:让用户知道当前是离线状态
2638
+ 3. **队列优先级**:重要操作设置更高优先级
2639
+ 4. **冲突处理**:考虑离线期间数据可能被其他人修改的情况
2640
+ 5. **定期清理**:清除过期的缓存数据
2641
+
2642
+ 现在你已经掌握了离线支持。接下来,让我们学习焦点管理,它可以优化用户切换标签页时的体验。
2643
+
2644
+ ---
2645
+
2646
+ ## 13. 第十一步:焦点管理
2647
+
2648
+
2649
+ 当用户切换浏览器标签页或窗口时,TanStack Query 默认会在窗口重新获得焦点时刷新数据。本库提供了更精细的焦点管理功能。
2650
+
2651
+ ### 13.1 获取焦点状态
2652
+
2653
+ ```tsx
2654
+ import { useFocusState, usePageVisibility } from '@qiaopeng/tanstack-query-plus/hooks'
2655
+
2656
+ function FocusIndicator() {
2657
+ const isFocused = useFocusState() // 窗口是否获得焦点
2658
+ const isVisible = usePageVisibility() // 页面是否可见
2659
+
2660
+ return (
2661
+ <div>
2662
+ <p>窗口焦点: {isFocused ? '是' : '否'}</p>
2663
+ <p>页面可见: {isVisible ? '是' : '否'}</p>
2664
+ </div>
2665
+ )
2666
+ }
2667
+ ```
2668
+
2669
+ ### 13.2 焦点恢复时刷新指定查询
2670
+
2671
+ 默认情况下,所有查询都会在窗口聚焦时刷新。但有时你只想刷新特定的查询:
2672
+
2673
+ ```tsx
2674
+ import { useFocusRefetch } from '@qiaopeng/tanstack-query-plus/hooks'
2675
+
2676
+ function Dashboard() {
2677
+ // 只在焦点恢复时刷新这些查询
2678
+ useFocusRefetch({
2679
+ queryKeys: [
2680
+ ['dashboard', 'stats'],
2681
+ ['notifications', 'unread'],
2682
+ ],
2683
+ minInterval: 5000, // 最小刷新间隔(避免频繁切换时过度刷新)
2684
+ enabled: true,
2685
+ })
2686
+
2687
+ // ...
2688
+ }
2689
+ ```
2690
+
2691
+ ### 13.3 焦点恢复时执行回调
2692
+
2693
+ ```tsx
2694
+ import { useFocusCallback } from '@qiaopeng/tanstack-query-plus/hooks'
2695
+
2696
+ function AnalyticsTracker() {
2697
+ // 焦点恢复时记录分析事件
2698
+ useFocusCallback(() => {
2699
+ analytics.track('page_focus', {
2700
+ page: window.location.pathname,
2701
+ timestamp: Date.now(),
2702
+ })
2703
+ }, {
2704
+ minInterval: 10000, // 最小间隔 10 秒
2705
+ enabled: true,
2706
+ })
2707
+
2708
+ return null
2709
+ }
2710
+ ```
2711
+
2712
+ ### 13.4 条件性焦点刷新
2713
+
2714
+ 只在满足条件时刷新:
2715
+
2716
+ ```tsx
2717
+ import { useConditionalFocusRefetch } from '@qiaopeng/tanstack-query-plus/hooks'
2718
+
2719
+ function ChatRoom({ roomId, isActive }) {
2720
+ // 只有当聊天室处于活动状态时,焦点恢复才刷新消息
2721
+ useConditionalFocusRefetch(
2722
+ ['messages', roomId],
2723
+ () => isActive, // 条件函数
2724
+ { minInterval: 3000 }
2725
+ )
2726
+
2727
+ // ...
2728
+ }
2729
+ ```
2730
+
2731
+ ### 13.5 暂停焦点管理
2732
+
2733
+ 在某些场景下(如模态框打开时),你可能想暂停焦点刷新:
2734
+
2735
+ ```tsx
2736
+ import { usePauseFocus } from '@qiaopeng/tanstack-query-plus/hooks'
2737
+
2738
+ function Modal({ isOpen, children }) {
2739
+ // 模态框打开时暂停焦点管理
2740
+ usePauseFocus({ pauseWhen: isOpen })
2741
+
2742
+ return isOpen ? (
2743
+ <div className="modal">
2744
+ {children}
2745
+ </div>
2746
+ ) : null
2747
+ }
2748
+
2749
+ // 或者手动控制
2750
+ function VideoPlayer() {
2751
+ const { pause, resume } = usePauseFocus()
2752
+
2753
+ const handlePlay = () => {
2754
+ pause() // 播放时暂停焦点刷新
2755
+ }
2756
+
2757
+ const handlePause = () => {
2758
+ resume() // 暂停时恢复焦点刷新
2759
+ }
2760
+
2761
+ return (
2762
+ <video onPlay={handlePlay} onPause={handlePause}>
2763
+ {/* ... */}
2764
+ </video>
2765
+ )
2766
+ }
2767
+ ```
2768
+
2769
+ ### 13.6 智能焦点管理器
2770
+
2771
+ 获取焦点管理的统计信息:
2772
+
2773
+ ```tsx
2774
+ import { useSmartFocusManager } from '@qiaopeng/tanstack-query-plus/hooks'
2775
+
2776
+ function FocusDebugPanel() {
2777
+ const { pause, resume, getStats, stats } = useSmartFocusManager()
2778
+
2779
+ return (
2780
+ <div className="debug-panel">
2781
+ <h3>焦点管理统计</h3>
2782
+ <pre>{JSON.stringify(stats, null, 2)}</pre>
2783
+
2784
+ <div className="space-x-2">
2785
+ <button onClick={pause}>暂停</button>
2786
+ <button onClick={resume}>恢复</button>
2787
+ <button onClick={() => console.log(getStats())}>
2788
+ 打印统计
2789
+ </button>
2790
+ </div>
2791
+ </div>
2792
+ )
2793
+ }
2794
+ ```
2795
+
2796
+ ### 13.7 焦点管理最佳实践
2797
+
2798
+ 1. **设置 minInterval**:避免用户频繁切换标签页时过度刷新
2799
+ 2. **选择性刷新**:不是所有数据都需要在焦点恢复时刷新
2800
+ 3. **考虑用户体验**:某些场景(如视频播放、表单填写)应该暂停焦点刷新
2801
+ 4. **结合 staleTime**:如果数据还新鲜,焦点刷新也不会发起请求
2802
+
2803
+ 现在你已经掌握了焦点管理。最后,让我们学习一些实用的工具函数和选择器。
2804
+
2805
+ ---
2806
+
2807
+ ## 14. 第十二步:工具函数与选择器
2808
+
2809
+
2810
+ 本库提供了丰富的工具函数,帮助你更高效地处理数据。
2811
+
2812
+ ### 14.1 选择器(Selectors)
2813
+
2814
+ 选择器用于 `select` 选项,可以在数据返回后进行转换。注意:大部分选择器是高阶函数,需要先调用生成实际的选择器函数。
2815
+
2816
+ ```tsx
2817
+ import { selectors } from '@qiaopeng/tanstack-query-plus/utils'
2818
+
2819
+ // 按 ID 选择单个项(高阶函数,先传 ID 生成选择器)
2820
+ const { data: user } = useQuery({
2821
+ queryKey: ['users'],
2822
+ queryFn: fetchUsers,
2823
+ select: selectors.byId('user-123'), // 返回 (data) => data.find(...)
2824
+ })
2825
+
2826
+ // 按条件筛选(高阶函数)
2827
+ const { data: activeUsers } = useQuery({
2828
+ queryKey: ['users'],
2829
+ queryFn: fetchUsers,
2830
+ select: selectors.where(user => user.isActive), // 返回 (data) => data.filter(...)
2831
+ })
2832
+
2833
+ // 映射转换(高阶函数)
2834
+ const { data: userNames } = useQuery({
2835
+ queryKey: ['users'],
2836
+ queryFn: fetchUsers,
2837
+ select: selectors.map(user => user.name), // 返回 (data) => data.map(...)
2838
+ })
2839
+
2840
+ // 获取第一个/最后一个(直接是选择器函数,不是高阶函数)
2841
+ const { data: firstUser } = useQuery({
2842
+ queryKey: ['users'],
2843
+ queryFn: fetchUsers,
2844
+ select: selectors.first, // 直接传入,不需要调用
2845
+ })
2846
+
2847
+ const { data: lastUser } = useQuery({
2848
+ queryKey: ['users'],
2849
+ queryFn: fetchUsers,
2850
+ select: selectors.last, // 直接传入
2851
+ })
2852
+
2853
+ // 计数(直接是选择器函数)
2854
+ const { data: userCount } = useQuery({
2855
+ queryKey: ['users'],
2856
+ queryFn: fetchUsers,
2857
+ select: selectors.count, // 直接传入
2858
+ })
2859
+
2860
+ // 选择单个对象的特定字段(高阶函数,用于单个对象而非数组)
2861
+ const { data: userName } = useQuery({
2862
+ queryKey: ['user', userId],
2863
+ queryFn: () => fetchUser(userId),
2864
+ select: selectors.field('name'), // 返回 (data) => data?.name
2865
+ })
2866
+
2867
+ // 选择单个对象的多个字段(高阶函数,用于单个对象)
2868
+ const { data: userBasicInfo } = useQuery({
2869
+ queryKey: ['user', userId],
2870
+ queryFn: () => fetchUser(userId),
2871
+ select: selectors.fields(['id', 'name', 'email']), // 返回 Pick<User, 'id'|'name'|'email'>
2872
+ })
2873
+ ```
2874
+
2875
+ ### 14.2 组合选择器
2876
+
2877
+ 选择器可以组合使用:
2878
+
2879
+ ```tsx
2880
+ import { selectors } from '@qiaopeng/tanstack-query-plus/utils'
2881
+
2882
+ // 先筛选活跃用户,再获取他们的名字
2883
+ const { data: activeUserNames } = useQuery({
2884
+ queryKey: ['users'],
2885
+ queryFn: fetchUsers,
2886
+ select: selectors.compose(
2887
+ selectors.where(u => u.isActive),
2888
+ selectors.map(u => u.name)
2889
+ ),
2890
+ })
2891
+
2892
+ // 获取管理员的邮箱
2893
+ const { data: adminEmails } = useQuery({
2894
+ queryKey: ['users'],
2895
+ queryFn: fetchUsers,
2896
+ select: selectors.compose(
2897
+ selectors.where(u => u.role === 'admin'),
2898
+ selectors.field('email')
2899
+ ),
2900
+ })
2901
+ ```
2902
+
2903
+ ### 14.3 独立使用选择器函数
2904
+
2905
+ 选择器也可以独立使用。注意:这些函数大多是高阶函数,需要先传入参数生成选择器,再传入数据:
2906
+
2907
+ ```tsx
2908
+ import {
2909
+ selectById,
2910
+ selectWhere,
2911
+ selectMap,
2912
+ selectFirst,
2913
+ selectCount,
2914
+ compose
2915
+ } from '@qiaopeng/tanstack-query-plus/utils'
2916
+
2917
+ const users = [
2918
+ { id: '1', name: 'Alice', isActive: true },
2919
+ { id: '2', name: 'Bob', isActive: false },
2920
+ { id: '3', name: 'Charlie', isActive: true },
2921
+ ]
2922
+
2923
+ // 按 ID 查找(高阶函数:先传 ID,返回选择器函数,再传数据)
2924
+ const userSelector = selectById('2') // 返回 (data) => data.find(...)
2925
+ const user = userSelector(users) // { id: '2', name: 'Bob', ... }
2926
+ // 或者链式调用
2927
+ const user2 = selectById('2')(users)
2928
+
2929
+ // 筛选(高阶函数)
2930
+ const activeUsers = selectWhere(u => u.isActive)(users) // [Alice, Charlie]
2931
+
2932
+ // 映射(高阶函数)
2933
+ const names = selectMap(u => u.name)(users) // ['Alice', 'Bob', 'Charlie']
2934
+
2935
+ // 第一个(直接是选择器函数,不是高阶函数)
2936
+ const first = selectFirst(users) // Alice
2937
+
2938
+ // 计数(直接是选择器函数)
2939
+ const count = selectCount(users) // 3
2940
+
2941
+ // 组合(compose 接收两个选择器函数,返回组合后的选择器)
2942
+ const activeNamesSelector = compose(
2943
+ selectWhere(u => u.isActive), // 第一步:筛选活跃用户
2944
+ selectMap(u => u.name) // 第二步:提取名字
2945
+ )
2946
+ const activeNames = activeNamesSelector(users) // ['Alice', 'Charlie']
2947
+ ```
2948
+
2949
+ ### 14.4 列表更新工具
2950
+
2951
+ 用于乐观更新的列表操作:
2952
+
2953
+ ```tsx
2954
+ import {
2955
+ listUpdater,
2956
+ batchUpdateItems,
2957
+ batchRemoveItems,
2958
+ reorderItems
2959
+ } from '@qiaopeng/tanstack-query-plus/utils'
2960
+
2961
+ const todos = [
2962
+ { id: '1', title: 'Task 1', done: false },
2963
+ { id: '2', title: 'Task 2', done: false },
2964
+ { id: '3', title: 'Task 3', done: true },
2965
+ ]
2966
+
2967
+ // 添加项(到头部)
2968
+ const withNew = listUpdater.add(todos, { id: '4', title: 'Task 4', done: false })
2969
+
2970
+ // 更新项
2971
+ const updated = listUpdater.update(todos, { id: '2', title: 'Updated Task 2', done: true })
2972
+
2973
+ // 移除项
2974
+ const removed = listUpdater.remove(todos, '2')
2975
+
2976
+ // 批量更新
2977
+ const batchUpdated = batchUpdateItems(todos, [
2978
+ { id: '1', done: true },
2979
+ { id: '2', done: true },
2980
+ ])
2981
+
2982
+ // 批量移除
2983
+ const batchRemoved = batchRemoveItems(todos, ['1', '3'])
2984
+
2985
+ // 重新排序(将索引 0 的项移到索引 2)
2986
+ const reordered = reorderItems(todos, 0, 2)
2987
+ ```
2988
+
2989
+ ### 14.5 创建乐观更新配置
2990
+
2991
+ 快速创建常用的乐观更新配置:
2992
+
2993
+ ```tsx
2994
+ import {
2995
+ createAddItemConfig,
2996
+ createUpdateItemConfig,
2997
+ createRemoveItemConfig
2998
+ } from '@qiaopeng/tanstack-query-plus/utils'
2999
+
3000
+ // 添加配置
3001
+ const addConfig = createAddItemConfig(['todos'], { addToTop: true })
3002
+ // 返回: { queryKey: ['todos'], updater: (old, newItem) => [newItem, ...old] }
3003
+
3004
+ // 更新配置
3005
+ const updateConfig = createUpdateItemConfig(['todos'])
3006
+ // 返回: { queryKey: ['todos'], updater: (old, updated) => old.map(...) }
3007
+
3008
+ // 删除配置
3009
+ const removeConfig = createRemoveItemConfig(['todos'])
3010
+ // 返回: { queryKey: ['todos'], updater: (old, id) => old.filter(...) }
3011
+
3012
+ // 在 mutation 中使用
3013
+ const addMutation = useMutation({
3014
+ mutationFn: createTodo,
3015
+ optimistic: addConfig,
3016
+ })
3017
+ ```
3018
+
3019
+ ### 14.6 Query Key 工具
3020
+
3021
+ ```tsx
3022
+ import {
3023
+ createQueryKeyFactory,
3024
+ createSimpleQueryKeyFactory,
3025
+ isQueryKeyEqual,
3026
+ extractParamsFromKey,
3027
+ normalizeQueryParams
3028
+ } from '@qiaopeng/tanstack-query-plus/utils'
3029
+
3030
+ // 创建 key 工厂(使用 namespace 配置)
3031
+ const todoKeys = createQueryKeyFactory({
3032
+ namespace: 'todos',
3033
+ normalizeConfig: {
3034
+ required: ['page'],
3035
+ defaults: { page: 1 },
3036
+ sortKeys: true,
3037
+ removeEmpty: true,
3038
+ }
3039
+ })
3040
+
3041
+ todoKeys.all() // ['todos']
3042
+ todoKeys.lists() // ['todos', 'list']
3043
+ todoKeys.list({ page: 1 }) // ['todos', 'list', { page: 1 }]
3044
+ todoKeys.details() // ['todos', 'detail']
3045
+ todoKeys.detail('123') // ['todos', 'detail', '123']
3046
+ todoKeys.custom('search', 'abc') // ['todos', 'custom', 'search', 'abc']
3047
+
3048
+ // 简单 key 工厂
3049
+ const simpleKeys = createSimpleQueryKeyFactory('products')
3050
+ simpleKeys.all() // ['products']
3051
+ simpleKeys.lists() // ['products', 'list']
3052
+ simpleKeys.detail('abc') // ['products', 'detail', 'abc']
3053
+
3054
+ // 比较 key
3055
+ const equal = isQueryKeyEqual(
3056
+ ['todos', 'list', { page: 1 }],
3057
+ ['todos', 'list', { page: 1 }]
3058
+ ) // true
3059
+
3060
+ // 从 key 中提取参数(获取最后一个对象元素)
3061
+ const params = extractParamsFromKey(['todos', 'list', { page: 1, filter: 'active' }])
3062
+ // { page: 1, filter: 'active' }
3063
+
3064
+ // 规范化查询参数(排序、移除空值)
3065
+ const normalized = normalizeQueryParams(
3066
+ { page: 1, search: '', filter: null, sort: 'name' },
3067
+ { removeEmpty: true, sortKeys: true }
3068
+ ) // { page: 1, sort: 'name' }
3069
+ ```
3070
+
3071
+ ### 14.7 网络工具
3072
+
3073
+ ```tsx
3074
+ import {
3075
+ isSlowNetwork,
3076
+ isFastNetwork,
3077
+ getNetworkSpeed,
3078
+ getNetworkInfo
3079
+ } from '@qiaopeng/tanstack-query-plus/utils'
3080
+
3081
+ // 检查网络速度
3082
+ if (isSlowNetwork()) {
3083
+ // 慢网络,减少预取
3084
+ console.log('慢网络,禁用预取')
3085
+ }
3086
+
3087
+ if (isFastNetwork()) {
3088
+ // 快网络,可以预取更多
3089
+ console.log('快网络,启用激进预取')
3090
+ }
3091
+
3092
+ // 获取网络速度(更细粒度)
3093
+ const speed = getNetworkSpeed() // 'fast' | 'medium' | 'slow' | 'unknown'
3094
+
3095
+ // 获取详细网络信息
3096
+ const info = getNetworkInfo()
3097
+ // {
3098
+ // effectiveType: '4g',
3099
+ // saveData: false,
3100
+ // downlink: 10,
3101
+ // rtt: 50
3102
+ // }
3103
+ ```
3104
+
3105
+ ### 14.8 存储工具
3106
+
3107
+ ```tsx
3108
+ import {
3109
+ isStorageAvailable,
3110
+ getStorageUsage,
3111
+ deepClone,
3112
+ formatBytes
3113
+ } from '@qiaopeng/tanstack-query-plus/utils'
3114
+ import { StorageType } from '@qiaopeng/tanstack-query-plus/types'
3115
+
3116
+ // 检查存储是否可用(需要传入 StorageType)
3117
+ if (isStorageAvailable(StorageType.LOCAL)) {
3118
+ console.log('localStorage 可用')
3119
+ }
3120
+
3121
+ if (isStorageAvailable(StorageType.SESSION)) {
3122
+ console.log('sessionStorage 可用')
3123
+ }
3124
+
3125
+ // 获取存储使用情况(需要传入 StorageType)
3126
+ const usage = getStorageUsage(StorageType.LOCAL)
3127
+ console.log(`已使用: ${formatBytes(usage.used)}`)
3128
+ console.log(`总容量: ${formatBytes(usage.total)}`)
3129
+ console.log(`使用率: ${(usage.usage * 100).toFixed(2)}%`)
3130
+ console.log(`是否可用: ${usage.available}`)
3131
+
3132
+ // 深拷贝(用于乐观更新时保存原始数据)
3133
+ const original = { nested: { value: 1 } }
3134
+ const cloned = deepClone(original)
3135
+ cloned.nested.value = 2
3136
+ console.log(original.nested.value) // 1(原始数据不变)
3137
+ ```
3138
+
3139
+ ### 14.9 字段映射工具
3140
+
3141
+ ```tsx
3142
+ import {
3143
+ createOptimisticBase,
3144
+ createTempId
3145
+ } from '@qiaopeng/tanstack-query-plus/utils'
3146
+
3147
+ // 创建临时 ID(用于乐观更新时生成临时标识)
3148
+ const tempId = createTempId() // 'temp-1234567890-abc123'
3149
+ const tempId2 = createTempId('item') // 'item-1234567890-xyz789'
3150
+
3151
+ // 创建乐观更新的基础数据(包含常用的时间戳字段)
3152
+ const optimisticBase = createOptimisticBase({
3153
+ title: '新任务',
3154
+ done: false,
3155
+ })
3156
+ // 返回:
3157
+ // {
3158
+ // createTime: '2024-01-01T00:00:00.000Z',
3159
+ // updateTime: '2024-01-01T00:00:00.000Z',
3160
+ // createUser: '',
3161
+ // updateUser: '',
3162
+ // deleteStatus: 0,
3163
+ // title: '新任务',
3164
+ // done: false
3165
+ // }
3166
+
3167
+ // 结合 createTempId 使用
3168
+ const newTodo = {
3169
+ id: createTempId(),
3170
+ ...createOptimisticBase({ title: '新任务', done: false })
3171
+ }
3172
+ ```
3173
+
3174
+ **注意**:`createFieldEnricher` 是一个高级函数,用于根据配置数据丰富查询结果中的字段(如将 ID 映射为名称),需要配合 QueryClient 使用,适用于特定的业务场景。
3175
+
3176
+ ### 14.10 保持上一次数据
3177
+
3178
+ 在数据刷新时保持显示上一次的数据:
3179
+
3180
+ ```tsx
3181
+ import { keepPreviousData } from '@qiaopeng/tanstack-query-plus/utils'
3182
+
3183
+ function SearchResults({ query }) {
3184
+ const { data, isPlaceholderData } = useQuery({
3185
+ queryKey: ['search', query],
3186
+ queryFn: () => search(query),
3187
+ placeholderData: keepPreviousData, // 保持上一次的搜索结果
3188
+ })
3189
+
3190
+ return (
3191
+ <div className={isPlaceholderData ? 'opacity-50' : ''}>
3192
+ {data?.map(result => (
3193
+ <SearchResult key={result.id} result={result} />
3194
+ ))}
3195
+ </div>
3196
+ )
3197
+ }
3198
+ ```
3199
+
3200
+ 现在你已经掌握了所有核心功能!最后,让我们看看一些最佳实践和常见问题。
3201
+
3202
+ ---
3203
+
3204
+ ## 15. 最佳实践与常见问题
3205
+
3206
+ ### 导入路径速查表
3207
+
3208
+ | 路径 | 内容 | 说明 |
3209
+ |------|------|------|
3210
+ | `@qiaopeng/tanstack-query-plus` | 主入口:组件、核心配置、hooks、类型、工具函数、持久化 hooks | 推荐使用 |
3211
+ | `@qiaopeng/tanstack-query-plus/core` | 配置、Key 工厂、环境变量、焦点管理 | 按需导入 |
3212
+ | `@qiaopeng/tanstack-query-plus/core/devtools` | DevTools 配置和组件 | 需安装 @tanstack/react-query-devtools |
3213
+ | `@qiaopeng/tanstack-query-plus/hooks` | 所有增强 Hooks(查询、mutation、预取、批量等) | 按需导入 |
3214
+ | `@qiaopeng/tanstack-query-plus/hooks/inview` | useInViewPrefetch | 需安装 react-intersection-observer |
3215
+ | `@qiaopeng/tanstack-query-plus/components` | React 组件(SuspenseWrapper、QuerySuspenseWrapper、Loading 等) | 按需导入 |
3216
+ | `@qiaopeng/tanstack-query-plus/features` | 离线队列、持久化底层 API | 高级用法 |
3217
+ | `@qiaopeng/tanstack-query-plus/utils` | 工具函数(选择器、列表更新器、网络检测等) | 按需导入 |
3218
+ | `@qiaopeng/tanstack-query-plus/types` | TypeScript 类型定义 | 类型导入 |
3219
+ | `@qiaopeng/tanstack-query-plus/react-query` | TanStack Query 原生 API 再导出 | 需要原生 API 时使用 |
3220
+
3221
+ **主入口导出的内容**:
3222
+ - ✅ `QueryClient`, `QueryClientProvider`, `useQueryClient`, `skipToken`, `useIsMutating`
3223
+ - ✅ `PersistQueryClientProvider`, `usePersistenceStatus`, `usePersistenceManager`
3224
+ - ✅ 所有增强 hooks(`useEnhancedQuery`, `useMutation`, `useEnhancedInfiniteQuery` 等)
3225
+ - ✅ 所有组件(`SuspenseWrapper`, `QuerySuspenseWrapper`, Loading 组件等)
3226
+ - ✅ 所有工具函数和选择器
3227
+ - ✅ 所有类型定义
3228
+
3229
+ **提示**:
3230
+ - 大部分情况下,从主入口 `@qiaopeng/tanstack-query-plus` 导入即可
3231
+ - 如果需要 TanStack Query 的原生 `useQuery`(而非增强版),从 `@tanstack/react-query` 导入
3232
+ - 子路径导入可以实现更好的 tree-shaking
3233
+
3234
+ ### 15.1 项目结构建议
3235
+
3236
+ ```
3237
+ src/
3238
+ ├── api/ # API 请求函数
3239
+ │ ├── users.ts
3240
+ │ ├── posts.ts
3241
+ │ └── index.ts
3242
+ ├── queries/ # 查询相关
3243
+ │ ├── keys.ts # Query Key 工厂
3244
+ │ ├── users.ts # 用户相关查询 hooks
3245
+ │ ├── posts.ts # 文章相关查询 hooks
3246
+ │ └── index.ts
3247
+ ├── mutations/ # Mutation 相关
3248
+ │ ├── users.ts
3249
+ │ ├── posts.ts
3250
+ │ └── index.ts
3251
+ ├── components/
3252
+ ├── pages/
3253
+ └── App.tsx
3254
+ ```
3255
+
3256
+ ### 15.2 封装自定义 Hooks
3257
+
3258
+ 将查询逻辑封装成自定义 hooks:
3259
+
3260
+ ```tsx
3261
+ // queries/users.ts
3262
+ import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
3263
+ import { queryKeys } from './keys'
3264
+ import { fetchUser, fetchUsers } from '@/api/users'
3265
+
3266
+ export function useUser(userId: string) {
3267
+ return useEnhancedQuery({
3268
+ queryKey: queryKeys.user(userId),
3269
+ queryFn: () => fetchUser(userId),
3270
+ enabled: !!userId,
3271
+ trackPerformance: true,
3272
+ })
3273
+ }
3274
+
3275
+ export function useUsers(filters?: UserFilters) {
3276
+ return useEnhancedQuery({
3277
+ queryKey: queryKeys.users(filters),
3278
+ queryFn: () => fetchUsers(filters),
3279
+ })
3280
+ }
3281
+
3282
+ // 使用
3283
+ function UserProfile({ userId }) {
3284
+ const { data: user, isLoading } = useUser(userId)
3285
+ // ...
3286
+ }
3287
+ ```
3288
+
3289
+ ### 15.3 配置最佳实践
3290
+
3291
+ ```tsx
3292
+ // config/queryClient.ts
3293
+ import { QueryClient } from '@qiaopeng/tanstack-query-plus'
3294
+ import { getConfigByEnvironment, ensureBestPractices } from '@qiaopeng/tanstack-query-plus/core'
3295
+
3296
+ const baseConfig = getConfigByEnvironment(process.env.NODE_ENV)
3297
+
3298
+ // 确保配置符合最佳实践
3299
+ const config = ensureBestPractices({
3300
+ ...baseConfig,
3301
+ queries: {
3302
+ ...baseConfig.queries,
3303
+ // 自定义覆盖
3304
+ staleTime: 60000, // 1 分钟
3305
+ },
3306
+ })
3307
+
3308
+ export const queryClient = new QueryClient({
3309
+ defaultOptions: config,
3310
+ })
3311
+ ```
3312
+
3313
+ ### 15.4 错误处理最佳实践
3314
+
3315
+ ```tsx
3316
+ // 全局错误处理
3317
+ const queryClient = new QueryClient({
3318
+ defaultOptions: {
3319
+ queries: {
3320
+ ...GLOBAL_QUERY_CONFIG.queries,
3321
+ // 全局错误处理
3322
+ onError: (error) => {
3323
+ if (error.status === 401) {
3324
+ // 未授权,跳转登录
3325
+ window.location.href = '/login'
3326
+ } else if (error.status >= 500) {
3327
+ // 服务器错误,显示通知
3328
+ toast.error('服务器错误,请稍后重试')
3329
+ }
3330
+ },
3331
+ },
3332
+ mutations: {
3333
+ ...GLOBAL_QUERY_CONFIG.mutations,
3334
+ onError: (error) => {
3335
+ toast.error(error.message || '操作失败')
3336
+ },
3337
+ },
3338
+ },
3339
+ })
3340
+ ```
3341
+
3342
+ ### 15.5 TypeScript 类型最佳实践
3343
+
3344
+ ```tsx
3345
+ import type {
3346
+ EnhancedQueryOptions,
3347
+ EnhancedQueryResult,
3348
+ MutationOptions
3349
+ } from '@qiaopeng/tanstack-query-plus/types'
3350
+
3351
+ // 定义 API 响应类型
3352
+ interface User {
3353
+ id: string
3354
+ name: string
3355
+ email: string
3356
+ }
3357
+
3358
+ interface ApiError {
3359
+ message: string
3360
+ code: string
3361
+ }
3362
+
3363
+ // 类型安全的查询
3364
+ function useUser(userId: string): EnhancedQueryResult<User, ApiError> {
3365
+ return useEnhancedQuery<User, ApiError>({
3366
+ queryKey: ['user', userId],
3367
+ queryFn: () => fetchUser(userId),
3368
+ })
3369
+ }
3370
+
3371
+ // 类型安全的 mutation
3372
+ function useUpdateUser() {
3373
+ return useMutation<User, ApiError, Partial<User>>({
3374
+ mutationFn: (data) => updateUser(data),
3375
+ optimistic: {
3376
+ queryKey: ['user', data.id],
3377
+ updater: (old, newData) => ({ ...old, ...newData }),
3378
+ },
3379
+ })
3380
+ }
3381
+ ```
3382
+
3383
+ ### 15.6 常见问题解答
3384
+
3385
+ #### Q: DevTools 报错 "Module not found"
3386
+
3387
+ DevTools 是可选依赖,需要单独安装:
3388
+
3389
+ ```bash
3390
+ npm install @tanstack/react-query-devtools
3391
+ ```
3392
+
3393
+ 然后从子路径导入:
3394
+
3395
+ ```tsx
3396
+ import { ReactQueryDevtools } from '@qiaopeng/tanstack-query-plus/core/devtools'
3397
+ ```
3398
+
3399
+ #### Q: useInViewPrefetch 报错
3400
+
3401
+ 需要安装 `react-intersection-observer`:
3402
+
3403
+ ```bash
3404
+ npm install react-intersection-observer
3405
+ ```
3406
+
3407
+ 然后从子路径导入:
3408
+
3409
+ ```tsx
3410
+ import { useInViewPrefetch } from '@qiaopeng/tanstack-query-plus/hooks/inview'
3411
+ ```
3412
+
3413
+ #### Q: SSR 环境下持久化不工作
3414
+
3415
+ `PersistQueryClientProvider` 在服务端会自动降级为普通 Provider,这是预期行为。所有浏览器 API 调用都有环境检测守卫。
3416
+
3417
+ #### Q: 如何禁用开发环境的错误日志
3418
+
3419
+ ```tsx
3420
+ useEnhancedQuery({
3421
+ queryKey: ['data'],
3422
+ queryFn: fetchData,
3423
+ logErrors: false,
3424
+ })
3425
+ ```
3426
+
3427
+ #### Q: 乐观更新失败后数据不一致
3428
+
3429
+ 确保你的 `updater` 函数是纯函数,不要直接修改 `oldData`:
3430
+
3431
+ ```tsx
3432
+ // ❌ 错误
3433
+ updater: (oldData, newItem) => {
3434
+ oldData.push(newItem) // 直接修改
3435
+ return oldData
3436
+ }
3437
+
3438
+ // ✅ 正确
3439
+ updater: (oldData, newItem) => {
3440
+ return [...oldData, newItem] // 返回新数组
3441
+ }
3442
+ ```
3443
+
3444
+ #### Q: 如何在测试中使用
3445
+
3446
+ ```tsx
3447
+ import { getConfigByEnvironment } from '@qiaopeng/tanstack-query-plus/core'
3448
+
3449
+ const testConfig = getConfigByEnvironment('test')
3450
+ // 测试配置:retry: 0, staleTime: 0, refetchOnWindowFocus: false
3451
+
3452
+ const queryClient = new QueryClient({ defaultOptions: testConfig })
3453
+
3454
+ // 在测试中
3455
+ render(
3456
+ <QueryClientProvider client={queryClient}>
3457
+ <ComponentToTest />
3458
+ </QueryClientProvider>
3459
+ )
3460
+ ```
3461
+
3462
+ #### Q: 缓存数据太大怎么办
3463
+
3464
+ 1. 检查缓存大小:
3465
+ ```tsx
3466
+ const stats = getStorageStats()
3467
+ console.log(`缓存大小: ${stats.sizeInfo.sizeInMB}MB`)
3468
+ ```
3469
+
3470
+ 2. 定期清理过期缓存:
3471
+ ```tsx
3472
+ clearExpiredCache('tanstack-query-cache', 24 * 60 * 60 * 1000)
3473
+ ```
3474
+
3475
+ 3. 考虑迁移到 IndexedDB
3476
+
3477
+ #### Q: 如何调试查询问题
3478
+
3479
+ 1. 使用 DevTools 查看查询状态
3480
+ 2. 启用性能追踪:
3481
+ ```tsx
3482
+ useEnhancedQuery({
3483
+ queryKey: ['data'],
3484
+ queryFn: fetchData,
3485
+ trackPerformance: true,
3486
+ logErrors: true,
3487
+ })
3488
+ ```
3489
+ 3. 检查 queryKey 是否正确(使用 key 工厂避免拼写错误)
3490
+
3491
+ ### 15.7 性能优化建议
3492
+
3493
+ 1. **合理设置 staleTime**:避免不必要的重复请求
3494
+ 2. **使用 select**:只选择需要的数据,减少重渲染
3495
+ 3. **使用预取**:提前获取用户可能需要的数据
3496
+ 4. **批量查询**:使用 `useEnhancedQueries` 而不是多个独立查询
3497
+ 5. **懒加载**:结合 Suspense 和代码分割
3498
+ 6. **避免过度乐观更新**:只在必要时使用
3499
+
3500
+ ### 15.8 安全建议
3501
+
3502
+ 1. **不要在 queryKey 中包含敏感信息**:queryKey 可能被记录或暴露
3503
+ 2. **验证服务端响应**:不要盲目信任 API 返回的数据
3504
+ 3. **处理认证过期**:在全局错误处理中处理 401 错误
3505
+ 4. **清理敏感缓存**:用户登出时清除缓存
3506
+
3507
+ ---
3508
+
3509
+ ## 总结
3510
+
3511
+ 恭喜你完成了本教程!现在你已经掌握了 `@qiaopeng/tanstack-query-plus` 的所有核心功能:
3512
+
3513
+ 1. ✅ 配置 Provider 和最佳实践
3514
+ 2. ✅ 基础查询和增强查询
3515
+ 3. ✅ Query Key 管理
3516
+ 4. ✅ 数据变更和乐观更新
3517
+ 5. ✅ 无限滚动和分页
3518
+ 6. ✅ 批量查询和仪表盘
3519
+ 7. ✅ 智能预取策略
3520
+ 8. ✅ Suspense 模式
3521
+ 9. ✅ 离线支持和持久化
3522
+ 10. ✅ 焦点管理
3523
+ 11. ✅ 工具函数和选择器
3524
+
3525
+ ### 下一步
3526
+
3527
+ - 查看 [GitHub 仓库](https://github.com/qiaopengg/qiaopeng-tanstack-query-plus) 获取最新更新
3528
+ - 阅读 [TanStack Query 官方文档](https://tanstack.com/query/latest) 了解更多底层概念
3529
+ - 在 [Issues](https://github.com/qiaopengg/qiaopeng-tanstack-query-plus/issues) 中提问或反馈
622
3530
 
623
- - 错误类型默认对齐 v5:`DefaultError`
624
- - 配置的默认值遵循最佳实践,必要时用 `ensureBestPractices` 自动纠正
625
- - 可选依赖通过子路径导出按需使用,避免未安装造成解析失败
3531
+ 祝你编码愉快!🚀