欢迎光临
我们一直在努力

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

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

  • 缓存缺失 – 切换页面再切回来,数据重新请求,白屏闪烁。
  • 状态同步困难 – 多个组件用同一份数据时,需要手动传递或用全局状态管理。
  • 竞态风险 – 快速切换页面,旧请求可能覆盖新请求的结果。
  • 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'] });
    },
    });
    }

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

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

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

    // 页面组件中的典型模式
    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,用 getNextPageParam 和 fetchNextPage 实现自动加载下一页。但 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 的数据层架构可以归纳为三层:

  • 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/ChatCrystal

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

    赞(0)
    未经允许不得转载:171主机测评 » React Query + REST API 最佳实践
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址