React Query + REST API 最佳实践

本文面向:想用 React Query 构建清晰 REST API 数据层的 React 开发者。

预计阅读时间:12 分钟

最终效果:掌握 QueryClient 配置、自定义 Hook 封装、useMutation 缓存失效、智能轮询、懒加载路由的完整实践。

为什么选 React Query

在没有 React Query 之前,常见的做法是 useState + useEffect + fetch

复制代码
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; };
}, []);

这段代码有三个明显的问题:

  1. 缓存缺失 -- 切换页面再切回来,数据重新请求,白屏闪烁。
  2. 状态同步困难 -- 多个组件用同一份数据时,需要手动传递或用全局状态管理。
  3. 竞态风险 -- 快速切换页面,旧请求可能覆盖新请求的结果。

React Query 解决了以上所有问题,同时提供了后台刷新、乐观更新、分页、轮询等开箱即用的能力。

QueryClient 配置

ChatCrystal 在应用入口 App.tsx 中创建 QueryClient,并通过 QueryClientProvider 注入整个应用:

复制代码
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 中封装了一个通用的请求函数:

复制代码
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:

复制代码
// client/vite.config.ts
server: {
  port: 13721,
  proxy: {
    '/api': 'http://localhost:3721',
  },
},

所有 /api 开头的请求会被转发到后端的 3721 端口。

自定义 Hook -- 将查询逻辑封装为可复用单元

ChatCrystal 不在页面组件中直接写 useQuery,而是把每个查询封装成自定义 Hook。这有三个好处:页面组件更简洁、查询逻辑可复用、queryKey 管理集中化。

复制代码
// 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。

复制代码
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 流程

以"总结对话"为例,这是一个异步操作,涉及队列系统:

复制代码
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'] });
    },
  });
}

点击"生成摘要"按钮后:

  1. mutationFn 发送 POST 请求,将任务加入队列。
  2. onSuccess 失效队列状态的缓存,StatusBar 开始轮询进度。
  3. 队列完成后,相关页面的数据会在下次访问时自动刷新。

轮询 -- 处理异步任务状态

ChatCrystal 有一个任务队列系统,需要实时显示进度。React Query 的 refetchInterval 可以实现智能轮询:

复制代码
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 中):

复制代码
export function useStatus() {
  return useQuery({
    queryKey: ['status'],
    queryFn: () => api.getStatus(),
    refetchInterval: 30_000,
  });
}

加载与错误状态处理

React Query 的 useQuery 返回三个关键状态字段:isLoadingisErrordata。页面组件根据这些字段渲染不同内容。

复制代码
// 页面组件中的典型模式
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 在搜索页面展示了典型的错误处理:

复制代码
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 状态禁用,防止重复提交:

复制代码
<button
  onClick={handleSearch}
  disabled={search.isPending || !query.trim()}
>
  {search.isPending ? <Loader2 className="animate-spin" /> : '搜索'}
</button>

分页实现

ChatCrystal 使用传统的偏移量分页,每页 20 条:

复制代码
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,用 getNextPageParamfetchNextPage 实现自动加载下一页。但 ChatCrystal 的数据量不大,传统分页更合适。

乐观更新

React Query 支持乐观更新(Optimistic Update),在 mutation 发送请求之前就更新 UI,让操作感觉更流畅。ChatCrystal 目前没有使用这个模式,但这是一个值得了解的最佳实践。

复制代码
// 乐观更新示例(非 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:

复制代码
const NotesPage = lazy(() =>
  import('@/pages/Notes.tsx').then(module => ({ default: module.Notes }))
);

// 路由定义
<Route path="/notes" element={
  <RouteSuspense>
    <NotesPage />
  </RouteSuspense>
} />

页面组件按需加载,首屏只加载当前路由需要的代码。RouteSuspense 包裹了统一的加载提示。

总结

ChatCrystal 的数据层架构可以归纳为三层:

  1. API 层lib/api.ts)-- 封装 fetch,统一错误处理和响应格式。
  2. Hook 层hooks/ 目录)-- 每个业务域一个文件,用 useQuery / useMutation 封装读写操作,管理 queryKey 和缓存失效策略。
  3. 页面层pages/ 目录)-- 只关心 UI 渲染,从 Hook 获取数据和状态。

这种分层的好处是:页面组件不关心数据从哪里来,Hook 不关心数据怎么展示。当后端 API 变化时,只需要修改 api.ts;当缓存策略需要调整时,只需要修改对应的 Hook。

如果你正在构建一个需要频繁与 REST API 交互的 React 应用,React Query 是目前最好的选择。它不只是一个数据获取库,更是一个完整的异步状态管理方案。


项目地址:github.com/ZengLiangYi/ChatCrystal

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
大圣编程1 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang1 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆2 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜2 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞3 小时前
异步HttpModule的实现方式
java·服务器·前端
YFF菲菲兔4 小时前
其他 Hooks 解析
react.js
丹宇码农6 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_943782356 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
GV191rLvq6 小时前
基于Socket实现的最简单的Web服务器【ASP.NET原理分析】
服务器·前端·asp.net