@qiaopeng/tanstack-query-plus 0.1.5 → 0.2.0
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 +3384 -478
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,625 +1,3531 @@
|
|
|
1
|
-
# @qiaopeng/tanstack-query-plus
|
|
1
|
+
# @qiaopeng/tanstack-query-plus 完整使用教程
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 本教程将带你从零开始,循序渐进地学习如何使用 `@qiaopeng/tanstack-query-plus`。每个章节都会自然地引出下一个概念,帮助你建立完整的知识体系。
|
|
4
4
|
|
|
5
5
|
## 目录
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
12
|
-
6.
|
|
13
|
-
7.
|
|
14
|
-
8.
|
|
15
|
-
9.
|
|
16
|
-
10.
|
|
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
|
-
|
|
23
|
+
---
|
|
21
24
|
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
76
|
+
确保你的项目满足以下要求:
|
|
77
|
+
- Node.js >= 16
|
|
78
|
+
- React >= 18
|
|
79
|
+
- TypeScript(推荐,但非必需)
|
|
34
80
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
// 创建 QueryClient,使用预配置的最佳实践
|
|
100
|
+
const queryClient = new QueryClient({
|
|
101
|
+
defaultOptions: GLOBAL_QUERY_CONFIG
|
|
102
|
+
})
|
|
42
103
|
|
|
43
|
-
|
|
104
|
+
function App() {
|
|
44
105
|
return (
|
|
45
|
-
<PersistQueryClientProvider client={queryClient}
|
|
46
|
-
<
|
|
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
|
-
|
|
113
|
+
这段代码做了什么?
|
|
57
114
|
|
|
58
|
-
|
|
115
|
+
1. **创建 QueryClient**:使用 `GLOBAL_QUERY_CONFIG` 预配置,包含了经过优化的默认值
|
|
116
|
+
2. **包裹应用**:`PersistQueryClientProvider` 让所有子组件都能访问 QueryClient
|
|
59
117
|
|
|
60
|
-
|
|
61
|
-
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
|
|
62
|
-
import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
|
|
118
|
+
### 3.2 启用持久化和离线支持
|
|
63
119
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
167
|
+
这些值是经过实践验证的最佳实践,适合大多数应用场景。
|
|
168
|
+
|
|
169
|
+
### 3.4 根据环境选择配置
|
|
170
|
+
|
|
171
|
+
本库还提供了针对不同环境的预配置:
|
|
81
172
|
|
|
82
173
|
```tsx
|
|
83
|
-
import {
|
|
84
|
-
import { useQuery } from '@qiaopeng/tanstack-query-plus/react-query'
|
|
174
|
+
import { getConfigByEnvironment } from '@qiaopeng/tanstack-query-plus/core'
|
|
85
175
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
206
|
+
DevTools 可以让你:
|
|
207
|
+
- 查看所有查询的状态
|
|
208
|
+
- 手动触发 refetch
|
|
209
|
+
- 查看缓存数据
|
|
210
|
+
- 调试查询问题
|
|
211
|
+
|
|
212
|
+
现在 Provider 配置好了,让我们开始发起第一个查询!
|
|
98
213
|
|
|
99
|
-
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 4. 第二步:发起你的第一个查询
|
|
217
|
+
|
|
218
|
+
配置好 Provider 后,我们就可以在组件中使用查询了。
|
|
219
|
+
|
|
220
|
+
### 4.1 基础查询
|
|
221
|
+
|
|
222
|
+
最基本的查询可以使用 TanStack Query 原生的 `useQuery`,或者本库提供的增强版 `useEnhancedQuery`:
|
|
100
223
|
|
|
101
224
|
```tsx
|
|
102
|
-
|
|
103
|
-
import {
|
|
104
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
278
|
+
### 4.3 使用 skipToken 禁用查询
|
|
124
279
|
|
|
125
|
-
|
|
280
|
+
另一种禁用查询的方式是使用 `skipToken`:
|
|
126
281
|
|
|
127
282
|
```tsx
|
|
128
|
-
import {
|
|
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
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
290
|
+
|
|
291
|
+
// ...
|
|
141
292
|
}
|
|
142
293
|
```
|
|
143
294
|
|
|
144
|
-
|
|
295
|
+
**注意**:`skipToken` 也可以从 `@qiaopeng/tanstack-query-plus` 主包导入,或者从 `@tanstack/react-query` 导入。
|
|
145
296
|
|
|
146
|
-
|
|
297
|
+
`skipToken` 的好处是 TypeScript 类型推断更准确。
|
|
147
298
|
|
|
148
|
-
|
|
299
|
+
### 4.4 自定义缓存时间
|
|
300
|
+
|
|
301
|
+
你可以为特定查询设置不同的缓存策略:
|
|
149
302
|
|
|
150
303
|
```tsx
|
|
151
|
-
import {
|
|
152
|
-
import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
|
|
304
|
+
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
|
|
153
305
|
|
|
154
|
-
const
|
|
155
|
-
queryKey:
|
|
156
|
-
queryFn:
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
queryFn: async (page) => fetch(`/api/posts?page=${page}`).then(r => r.json())
|
|
169
|
-
})
|
|
316
|
+
- **staleTime**:数据被认为是"新鲜"的时间。在这段时间内,即使组件重新挂载,也不会重新请求。
|
|
317
|
+
- **gcTime**:数据在缓存中保留的时间。超过这个时间,数据会被垃圾回收。
|
|
170
318
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
361
|
+
### 5.2 启用性能追踪
|
|
188
362
|
|
|
189
|
-
|
|
363
|
+
要追踪查询性能,需要显式启用 `trackPerformance`:
|
|
190
364
|
|
|
191
365
|
```tsx
|
|
192
|
-
|
|
193
|
-
|
|
366
|
+
const { data, lastQueryDuration } = useEnhancedQuery({
|
|
367
|
+
queryKey: ['user', userId],
|
|
368
|
+
queryFn: () => fetchUser(userId),
|
|
369
|
+
trackPerformance: true, // 启用性能追踪
|
|
370
|
+
})
|
|
371
|
+
```
|
|
194
372
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
<
|
|
204
|
-
|
|
205
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 {
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
607
|
+
### 6.4 高级 Key 工具函数
|
|
252
608
|
|
|
253
|
-
|
|
609
|
+
本库还提供了一些高级的 Key 工具函数:
|
|
254
610
|
|
|
255
611
|
```tsx
|
|
256
|
-
import {
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
768
|
+
return (
|
|
769
|
+
<button onClick={() => mutation.mutate('新名字')}>
|
|
770
|
+
更新名字
|
|
771
|
+
</button>
|
|
772
|
+
)
|
|
267
773
|
}
|
|
268
774
|
```
|
|
269
775
|
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
845
|
+
// 使用
|
|
846
|
+
mutation.mutate({ id: '1', title: '新标题', priority: 'high' }) // 会乐观更新
|
|
847
|
+
mutation.mutate({ id: '2', title: '新标题', priority: 'low' }) // 不会乐观更新
|
|
284
848
|
```
|
|
285
849
|
|
|
286
|
-
|
|
287
|
-
- `ensureBestPractices(config)` 会自动修正不合理配置(如 `gcTime <= staleTime`)
|
|
850
|
+
### 7.6 列表操作的简化 Mutation
|
|
288
851
|
|
|
289
|
-
|
|
852
|
+
对于常见的列表 CRUD 操作,可以使用 `useListMutation`:
|
|
290
853
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
893
|
+
// ...
|
|
894
|
+
}
|
|
895
|
+
```
|
|
312
896
|
|
|
313
|
-
|
|
897
|
+
### 7.7 批量 Mutation
|
|
314
898
|
|
|
315
|
-
|
|
899
|
+
处理批量操作:
|
|
316
900
|
|
|
317
|
-
|
|
901
|
+
```tsx
|
|
902
|
+
import { useBatchMutation } from '@qiaopeng/tanstack-query-plus/hooks'
|
|
318
903
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
911
|
+
// 使用
|
|
912
|
+
batchMutation.mutate(['id1', 'id2', 'id3'])
|
|
913
|
+
```
|
|
329
914
|
|
|
330
|
-
|
|
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
|
-
|
|
917
|
+
本库还提供了一些工具函数来简化列表的乐观更新:
|
|
339
918
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
)
|
|
950
|
+
// 批量更新(每个更新对象必须包含 id)
|
|
951
|
+
const list4 = batchUpdateItems(items, [
|
|
952
|
+
{ id: '1', done: true },
|
|
953
|
+
{ id: '2', done: true }
|
|
954
|
+
])
|
|
359
955
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
): UseQueryOptions<TData, DefaultError, TData, QueryKey>
|
|
956
|
+
// 批量移除
|
|
957
|
+
const list5 = batchRemoveItems(items, ['1', '2', '3'])
|
|
363
958
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
): UseQueryOptions<TData, DefaultError, TSelected, QueryKey>
|
|
959
|
+
// 重新排序(将 fromIndex 位置的项移动到 toIndex)
|
|
960
|
+
const list6 = reorderItems(items, 0, 2) // 将第一项移到第三位
|
|
367
961
|
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
962
|
+
// 条件更新(满足条件的项才更新)
|
|
963
|
+
const list7 = conditionalUpdateItems(
|
|
964
|
+
items,
|
|
965
|
+
(item) => item.status === 'pending', // 条件
|
|
966
|
+
(item) => ({ status: 'completed' }) // 更新内容
|
|
967
|
+
)
|
|
968
|
+
```
|
|
372
969
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
)
|
|
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
|
-
<
|
|
614
|
-
|
|
615
|
-
<
|
|
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
|
-
|
|
624
|
-
- 配置的默认值遵循最佳实践,必要时用 `ensureBestPractices` 自动纠正
|
|
625
|
-
- 可选依赖通过子路径导出按需使用,避免未安装造成解析失败
|
|
3531
|
+
祝你编码愉快!🚀
|