本文面向:想用 React Query 构建清晰 REST API 数据层的 React 开发者。
预计阅读时间:12 分钟
最终效果:掌握 QueryClient 配置、自定义 Hook 封装、useMutation 缓存失效、智能轮询、懒加载路由的完整实践。
为什么选 React Query
在没有 React Query 之前,常见的做法是 useState + useEffect + fetch:
scss
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch('/api/notes')
.then(res => res.json())
.then(json => { if (!cancelled) setData(json.data); })
.catch(err => { if (!cancelled) setError(err); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
这段代码有三个明显的问题:
- 缓存缺失 -- 切换页面再切回来,数据重新请求,白屏闪烁。
- 状态同步困难 -- 多个组件用同一份数据时,需要手动传递或用全局状态管理。
- 竞态风险 -- 快速切换页面,旧请求可能覆盖新请求的结果。
React Query 解决了以上所有问题,同时提供了后台刷新、乐观更新、分页、轮询等开箱即用的能力。
QueryClient 配置
ChatCrystal 在应用入口 App.tsx 中创建 QueryClient,并通过 QueryClientProvider 注入整个应用:
javascript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 秒内认为数据是新鲜的,不重新请求
retry: 1, // 请求失败只重试 1 次
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
{/* 路由、主题等组件 */}
</QueryClientProvider>
);
}
几个关键参数的含义:
| 参数 | 默认值 | 说明 |
|---|---|---|
staleTime |
0 | 数据被视为"过期"的时间。设为 30 秒意味着 30 秒内重新挂载组件不会触发网络请求 |
retry |
3 | 失败后的重试次数。API 通常重试 1 次就够了 |
gcTime (v5) |
5 分钟 | 数据不再被任何组件使用后,在缓存中保留的时间 |
ChatCrystal 把 staleTime 设为 30 秒,这对内部工具类应用很合理 -- 数据不需要实时,但也不能太旧。
API 层设计
ChatCrystal 在 lib/api.ts 中封装了一个通用的请求函数:
typescript
const BASE = "/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
if (options?.body) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(`${BASE}${path}`, { headers, ...options });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
const json = await res.json();
if (!json.success) throw new Error(json.error || "Unknown error");
return json.data;
}
这里的约定是:后端统一返回 { success: boolean, data: T, error?: string } 的格式,request 函数负责解包。如果有同学在设计自己的 API 层,建议也采用类似的统一响应格式。
开发环境下的跨域问题由 Vite 的代理解决,不需要在前端处理 CORS:
css
// client/vite.config.ts
server: {
port: 13721,
proxy: {
'/api': 'http://localhost:3721',
},
},
所有 /api 开头的请求会被转发到后端的 3721 端口。
自定义 Hook -- 将查询逻辑封装为可复用单元
ChatCrystal 不在页面组件中直接写 useQuery,而是把每个查询封装成自定义 Hook。这有三个好处:页面组件更简洁、查询逻辑可复用、queryKey 管理集中化。
typescript
// hooks/use-notes.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
// 列表查询 -- 带筛选和分页参数
export function useNotes(params?: {
tag?: string;
search?: string;
offset?: number;
limit?: number;
}) {
return useQuery({
queryKey: ['notes', params],
queryFn: () => api.getNotes(params),
});
}
// 单条详情查询 -- 启用条件控制
export function useNote(id: number) {
return useQuery({
queryKey: ['note', id],
queryFn: () => api.getNote(id),
enabled: id > 0,
});
}
注意 useNote 中的 enabled: id > 0。当路由参数还没准备好时(比如 id 还是 undefined),查询不会执行,避免无意义的请求。
queryKey 的设计原则
React Query 用 queryKey 做缓存的键。设计原则是:
- 列表和详情用不同的 key :
['notes', params]和['note', id]是两个独立的缓存条目。 - 参数变化自动触发重新请求 :
useNotes({ tag: 'react' })和useNotes({ tag: 'vue' })各自独立缓存。 - 数组中的对象会被深度比较 :所以
params对象中的字段顺序不影响缓存匹配。
useMutation -- 写操作与缓存失效
查询是读,mutation 是写。写操作完成后,缓存中的旧数据需要更新。ChatCrystal 的做法是:在 onSuccess 中批量失效相关 query。
php
export function useDeleteNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason, comment }: {
id: number;
reason: ExperienceReviewReason;
comment?: string;
}) => api.deleteNote(id, { reason, comment, source: 'web' }),
onSuccess: (data, variables) => {
// 删除一条笔记后,需要刷新所有相关数据
queryClient.invalidateQueries({ queryKey: ['notes'] });
queryClient.invalidateQueries({ queryKey: ['note', variables.id] });
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['note-relations'] });
queryClient.invalidateQueries({ queryKey: ['relation-graph'] });
queryClient.invalidateQueries({ queryKey: ['conversations'] });
queryClient.invalidateQueries({ queryKey: ['conversation', data.conversationId] });
queryClient.invalidateQueries({ queryKey: ['status'] });
},
});
}
invalidateQueries 不会立即重新请求。它只是把匹配的缓存标记为"过期",下次组件挂载时才会重新获取。如果当前有组件正在使用这些数据,React Query 会立即在后台发起一次刷新。
典型的 mutation 流程
以"总结对话"为例,这是一个异步操作,涉及队列系统:
php
export function useSummarize() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (conversationId: string) => api.summarize(conversationId),
onSuccess: (_data, conversationId) => {
queryClient.invalidateQueries({ queryKey: ['queue-status'] });
queryClient.invalidateQueries({ queryKey: ['conversation', conversationId] });
queryClient.invalidateQueries({ queryKey: ['conversations'] });
queryClient.invalidateQueries({ queryKey: ['notes'] });
queryClient.invalidateQueries({ queryKey: ['status'] });
},
});
}
点击"生成摘要"按钮后:
mutationFn发送 POST 请求,将任务加入队列。onSuccess失效队列状态的缓存,StatusBar 开始轮询进度。- 队列完成后,相关页面的数据会在下次访问时自动刷新。
轮询 -- 处理异步任务状态
ChatCrystal 有一个任务队列系统,需要实时显示进度。React Query 的 refetchInterval 可以实现智能轮询:
javascript
export function useQueueTasks() {
return useQuery({
queryKey: ['queue-status'],
queryFn: () => api.getQueueStatus(),
refetchInterval: (query) => {
const data = query.state.data as QueueSnapshot | undefined;
// 有活跃任务时 2 秒轮询一次,否则 10 秒一次
return data && data.active > 0 ? 2000 : 10000;
},
});
}
refetchInterval 支持传入函数,可以根据当前数据动态调整轮询频率。这个技巧很实用 -- 任务进行中时高频轮询以获取实时进度,空闲时降低频率以节省资源。
Dashboard 页面也用了一个简单的 30 秒轮询来保持状态数据新鲜(这个 hook 定义在 hooks/use-conversations.ts 中):
javascript
export function useStatus() {
return useQuery({
queryKey: ['status'],
queryFn: () => api.getStatus(),
refetchInterval: 30_000,
});
}
加载与错误状态处理
React Query 的 useQuery 返回三个关键状态字段:isLoading、isError、data。页面组件根据这些字段渲染不同内容。
javascript
// 页面组件中的典型模式
export function Notes() {
const { data, isLoading } = useNotes({ /* params */ });
if (isLoading) {
return <p className="text-muted text-sm">加载中...</p>;
}
if (!data || data.items.length === 0) {
return <div className="text-center py-12">暂无笔记</div>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{data.items.map(note => (
<NoteCard key={note.id} note={note} />
))}
</div>
);
}
对于 mutation 的错误,ChatCrystal 在搜索页面展示了典型的错误处理:
typescript
const search = useMutation({
mutationFn: (q: string) => api.search(q, 10, expand),
});
// 在 JSX 中
{search.isError && (
<p className="text-error text-sm">{search.error.message}</p>
)}
mutation 的按钮也需要根据 isPending 状态禁用,防止重复提交:
ini
<button
onClick={handleSearch}
disabled={search.isPending || !query.trim()}
>
{search.isPending ? <Loader2 className="animate-spin" /> : '搜索'}
</button>
分页实现
ChatCrystal 使用传统的偏移量分页,每页 20 条:
javascript
export function Notes() {
const [page, setPage] = useState(0);
const limit = 20;
const { data, isLoading } = useNotes({
search: search || undefined,
tag: activeTag || undefined,
offset: page * limit,
limit,
});
// 翻页时重置到第一页
const handleSearch = (value: string) => {
setSearch(value);
setPage(0);
};
// 分页控件
{data && data.total > limit && (
<div className="flex items-center justify-center gap-4 mt-4">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>
上一页
</button>
<span>{page + 1} / {Math.ceil(data.total / limit)}</span>
<button disabled={(page + 1) * limit >= data.total} onClick={() => setPage(p => p + 1)}>
下一页
</button>
</div>
)}
}
后端返回 { items, total, offset, limit } 的格式,前端根据 total 计算总页数。注意 offset 是 queryKey 的一部分,翻页时 React Query 会自动为每一页创建独立的缓存条目 -- 这意味着回翻时不需要重新请求。
如果需要无限滚动(infinite scroll),React Query 还提供了 useInfiniteQuery,用 getNextPageParam 和 fetchNextPage 实现自动加载下一页。但 ChatCrystal 的数据量不大,传统分页更合适。
乐观更新
React Query 支持乐观更新(Optimistic Update),在 mutation 发送请求之前就更新 UI,让操作感觉更流畅。ChatCrystal 目前没有使用这个模式,但这是一个值得了解的最佳实践。
javascript
// 乐观更新示例(非 ChatCrystal 代码,仅为教学)
export function useUpdateNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updated: Note) => api.updateNote(updated),
onMutate: async (updated) => {
// 1. 取消正在进行的查询,避免覆盖我们的乐观更新
await queryClient.cancelQueries({ queryKey: ['note', updated.id] });
// 2. 快照当前数据,以便回滚
const previous = queryClient.getQueryData(['note', updated.id]);
// 3. 乐观更新缓存
queryClient.setQueryData(['note', updated.id], updated);
return { previous };
},
onError: (_err, updated, context) => {
// 4. 出错时回滚到快照
queryClient.setQueryData(['note', updated.id], context?.previous);
},
onSettled: (_data, _err, updated) => {
// 5. 无论成功失败,都重新获取服务端数据
queryClient.invalidateQueries({ queryKey: ['note', updated.id] });
},
});
}
这个模式的要点是:onMutate 中做乐观更新和快照,onError 中回滚,onSettled 中同步服务端真实数据。
懒加载路由与 Suspense
ChatCrystal 的路由使用了 React 的 lazy + Suspense,结合 React Router v7:
javascript
const NotesPage = lazy(() =>
import('@/pages/Notes.tsx').then(module => ({ default: module.Notes }))
);
// 路由定义
<Route path="/notes" element={
<RouteSuspense>
<NotesPage />
</RouteSuspense>
} />
页面组件按需加载,首屏只加载当前路由需要的代码。RouteSuspense 包裹了统一的加载提示。
总结
ChatCrystal 的数据层架构可以归纳为三层:
- API 层 (
lib/api.ts)-- 封装fetch,统一错误处理和响应格式。 - Hook 层 (
hooks/目录)-- 每个业务域一个文件,用useQuery/useMutation封装读写操作,管理 queryKey 和缓存失效策略。 - 页面层 (
pages/目录)-- 只关心 UI 渲染,从 Hook 获取数据和状态。
这种分层的好处是:页面组件不关心数据从哪里来,Hook 不关心数据怎么展示。当后端 API 变化时,只需要修改 api.ts;当缓存策略需要调整时,只需要修改对应的 Hook。
如果你正在构建一个需要频繁与 REST API 交互的 React 应用,React Query 是目前最好的选择。它不只是一个数据获取库,更是一个完整的异步状态管理方案。
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。