React Query + REST API 最佳实践

本文面向:想用 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; };
}, []);

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

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

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'] });
    },
  });
}

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

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

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

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 返回三个关键状态字段:isLoadingisErrordata。页面组件根据这些字段渲染不同内容。

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,用 getNextPageParamfetchNextPage 实现自动加载下一页。但 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 的数据层架构可以归纳为三层:

  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...

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

相关推荐
星浩AI1 小时前
项目实战:合同智能审批 · LangGraph + HITL 人机协同方案 [有源码]
后端·langchain·agent
JavaGuide1 小时前
Codex 接入第三方模型 DeepSeek、GLM、Kimi 教程:CC-Switch 和 Codex++ 两种方案对比
后端·ai编程
ZengLiangYi1 小时前
Fastify 加 Electron:把 Web 服务嵌进桌面应用
前端·javascript·后端
李白你好2 小时前
页面资产梳理 · 技术指纹识别 · Spring 端点探测
java·后端·spring
用户1753721240332 小时前
02《面向对象设计原则:SOLID原则实战解析》
后端
胡萝卜术2 小时前
从零搭建生成式AI项目:OpenAI + Node.js 环境配置与密钥安全实践
前端·javascript·面试
柒和远方2 小时前
每日一学V012: 从 Python 到 Node.js:一个 AI Native 开发者的 JavaScript 调用 LLM 实战
javascript·node.js·api
我是一颗柠檬2 小时前
【Java后端技术亮点】热Key探测与本地缓存二级防护:Redis热点问题的终极解决方案
java·redis·后端·缓存·中间件
STDD2 小时前
Farming Simulator 25(模拟农场 25) Linux 专服搭建完全指南
linux·运维·javascript