React Query + REST API 最佳實踐
為什么選 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; };
}, []);
這段代碼有三個明顯的問題:
- 緩存缺失 -- 切換頁面再切回來,數(shù)據(jù)重新請求,白屏閃爍。
- 狀態(tài)同步困難 -- 多個組件用同一份數(shù)據(jù)時,需要手動傳遞或用全局狀態(tài)管理。
- 競態(tài)風(fēng)險 -- 快速切換頁面,舊請求可能覆蓋新請求的結(jié)果。
React Query 解決了以上所有問題,同時提供了后臺刷新、樂觀更新、分頁、輪詢等開箱即用的能力。
QueryClient 配置
ChatCrystal 在應(yīng)用入口 App.tsx 中創(chuàng)建 QueryClient,并通過 QueryClientProvider 注入整個應(yīng)用:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 秒內(nèi)認(rèn)為數(shù)據(jù)是新鮮的,不重新請求
retry: 1, // 請求失敗只重試 1 次
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
{/* 路由、主題等組件 */}
</QueryClientProvider>
);
}
幾個關(guān)鍵參數(shù)的含義:
| 參數(shù) | 默認(rèn)值 | 說明 |
|---|---|---|
| staleTime | 0 | 數(shù)據(jù)被視為"過期"的時間。設(shè)為 30 秒意味著 30 秒內(nèi)重新掛載組件不會觸發(fā)網(wǎng)絡(luò)請求 |
| retry | 3 | 失敗后的重試次數(shù)。API 通常重試 1 次就夠了 |
| gcTime (v5) | 5 分鐘 | 數(shù)據(jù)不再被任何組件使用后,在緩存中保留的時間 |
ChatCrystal 把 staleTime 設(shè)為 30 秒,這對內(nèi)部工具類應(yīng)用很合理 -- 數(shù)據(jù)不需要實時,但也不能太舊。
API 層設(shè)計
ChatCrystal 在 lib/api.ts 中封裝了一個通用的請求函數(shù):
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;
}
這里的約定是:后端統(tǒng)一返回 { success: boolean, data: T, error?: string } 的格式,request 函數(shù)負(fù)責(zé)解包。如果有同學(xué)在設(shè)計自己的 API 層,建議也采用類似的統(tǒng)一響應(yīng)格式。
開發(fā)環(huán)境下的跨域問題由 Vite 的代理解決,不需要在前端處理 CORS:
// client/vite.config.ts
server: {
port: 13721,
proxy: {
'/api': 'http://localhost:3721',
},
},
所有 /api 開頭的請求會被轉(zhuǎn)發(fā)到后端的 3721 端口。
自定義 Hook -- 將查詢邏輯封裝為可復(fù)用單元
ChatCrystal 不在頁面組件中直接寫 useQuery,而是把每個查詢封裝成自定義 Hook。這有三個好處:頁面組件更簡潔、查詢邏輯可復(fù)用、queryKey 管理集中化。
// hooks/use-notes.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
// 列表查詢 -- 帶篩選和分頁參數(shù)
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。當(dāng)路由參數(shù)還沒準(zhǔn)備好時(比如 id 還是 undefined),查詢不會執(zhí)行,避免無意義的請求。
queryKey 的設(shè)計原則
React Query 用 queryKey 做緩存的鍵。設(shè)計原則是:
- 列表和詳情用不同的 key:['notes', params] 和 ['note', id] 是兩個獨立的緩存條目。
- 參數(shù)變化自動觸發(fā)重新請求:useNotes({ tag: 'react' }) 和 useNotes({ tag: 'vue' }) 各自獨立緩存。
- 數(shù)組中的對象會被深度比較:所以 params 對象中的字段順序不影響緩存匹配。
useMutation -- 寫操作與緩存失效
查詢是讀,mutation 是寫。寫操作完成后,緩存中的舊數(shù)據(jù)需要更新。ChatCrystal 的做法是:在 onSuccess 中批量失效相關(guān) 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) => {
// 刪除一條筆記后,需要刷新所有相關(guān)數(shù)據(jù)
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 不會立即重新請求。它只是把匹配的緩存標(biāo)記為"過期",下次組件掛載時才會重新獲取。如果當(dāng)前有組件正在使用這些數(shù)據(jù),React Query 會立即在后臺發(fā)起一次刷新。
典型的 mutation 流程
以"總結(jié)對話"為例,這是一個異步操作,涉及隊列系統(tǒng):
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發(fā)送 POST 請求,將任務(wù)加入隊列。onSuccess失效隊列狀態(tài)的緩存,StatusBar 開始輪詢進度。- 隊列完成后,相關(guān)頁面的數(shù)據(jù)會在下次訪問時自動刷新。
輪詢 -- 處理異步任務(wù)狀態(tài)
ChatCrystal 有一個任務(wù)隊列系統(tǒng),需要實時顯示進度。React Query 的 refetchInterval 可以實現(xiàn)智能輪詢:
export function useQueueTasks() {
return useQuery({
queryKey: ['queue-status'],
queryFn: () => api.getQueueStatus(),
refetchInterval: (query) => {
const data = query.state.data as QueueSnapshot | undefined;
// 有活躍任務(wù)時 2 秒輪詢一次,否則 10 秒一次
return data && data.active > 0 ? 2000 : 10000;
},
});
}
refetchInterval 支持傳入函數(shù),可以根據(jù)當(dāng)前數(shù)據(jù)動態(tài)調(diào)整輪詢頻率。這個技巧很實用 -- 任務(wù)進行中時高頻輪詢以獲取實時進度,空閑時降低頻率以節(jié)省資源。
Dashboard 頁面也用了一個簡單的 30 秒輪詢來保持狀態(tài)數(shù)據(jù)新鮮(這個 hook 定義在 hooks/use-conversations.ts 中):
export function useStatus() {
return useQuery({
queryKey: ['status'],
queryFn: () => api.getStatus(),
refetchInterval: 30_000,
});
}
加載與錯誤狀態(tài)處理
React Query 的 useQuery 返回三個關(guān)鍵狀態(tài)字段:isLoading、isError、data。頁面組件根據(jù)這些字段渲染不同內(nèi)容。
// 頁面組件中的典型模式
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 的按鈕也需要根據(jù) isPending 狀態(tài)禁用,防止重復(fù)提交:
<button
onClick={handleSearch}
disabled={search.isPending || !query.trim()}
>
{search.isPending ? <Loader2 className="animate-spin" /> : '搜索'}
</button>
分頁實現(xiàn)
ChatCrystal 使用傳統(tǒng)的偏移量分頁,每頁 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 } 的格式,前端根據(jù) total 計算總頁數(shù)。注意 offset 是 queryKey 的一部分,翻頁時 React Query 會自動為每一頁創(chuàng)建獨立的緩存條目 -- 這意味著回翻時不需要重新請求。
如果需要無限滾動(infinite scroll),React Query 還提供了 useInfiniteQuery,用 getNextPageParam 和 fetchNextPage 實現(xiàn)自動加載下一頁。但 ChatCrystal 的數(shù)據(jù)量不大,傳統(tǒng)分頁更合適。
樂觀更新
React Query 支持樂觀更新(Optimistic Update),在 mutation 發(fā)送請求之前就更新 UI,讓操作感覺更流暢。ChatCrystal 目前沒有使用這個模式,但這是一個值得了解的最佳實踐。
// 樂觀更新示例(非 ChatCrystal 代碼,僅為教學(xué))
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. 快照當(dāng)前數(shù)據(jù),以便回滾
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. 無論成功失敗,都重新獲取服務(wù)端數(shù)據(jù)
queryClient.invalidateQueries({ queryKey: ['note', updated.id] });
},
});
}
這個模式的要點是:onMutate 中做樂觀更新和快照,onError 中回滾,onSettled 中同步服務(wù)端真實數(shù)據(jù)。
懶加載路由與 Suspense
ChatCrystal 的路由使用了 React 的 lazy + Suspense,結(jié)合 React Router v7:
const NotesPage = lazy(() =>
import('@/pages/Notes.tsx').then(module => ({ default: module.Notes }))
);
// 路由定義
<Route path="/notes" element={
<RouteSuspense>
<NotesPage />
</RouteSuspense>
} />
頁面組件按需加載,首屏只加載當(dāng)前路由需要的代碼。RouteSuspense 包裹了統(tǒng)一的加載提示。
總結(jié)
ChatCrystal 的數(shù)據(jù)層架構(gòu)可以歸納為三層:
- API 層(
lib/api.ts)-- 封裝fetch,統(tǒng)一錯誤處理和響應(yīng)格式。 - Hook 層(
hooks/目錄)-- 每個業(yè)務(wù)域一個文件,用useQuery/useMutation封裝讀寫操作,管理 queryKey 和緩存失效策略。 - 頁面層(
pages/目錄)-- 只關(guān)心 UI 渲染,從 Hook 獲取數(shù)據(jù)和狀態(tài)。
這種分層的好處是:頁面組件不關(guān)心數(shù)據(jù)從哪里來,Hook 不關(guān)心數(shù)據(jù)怎么展示。當(dāng)后端 API 變化時,只需要修改 api.ts;當(dāng)緩存策略需要調(diào)整時,只需要修改對應(yīng)的 Hook。
如果你正在構(gòu)建一個需要頻繁與 REST API 交互的 React 應(yīng)用,React Query 是目前最好的選擇。它不只是一個數(shù)據(jù)獲取庫,更是一個完整的異步狀態(tài)管理方案。
到此這篇關(guān)于React Query + REST API 最佳實踐的文章就介紹到這了,更多相關(guān)React Query + REST API 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 前端使用 React Query 管理“服務(wù)器狀態(tài)”的方法
- React項目中不需要jQuery原因分析
- react 報錯Module build failed: BrowserslistError: Unknown browser query `dead`問題的解決方法
- 使用useMutation和React Query發(fā)布數(shù)據(jù)demo
- ReactQuery系列React?Query?實踐示例詳解
- ReactQuery系列之?dāng)?shù)據(jù)轉(zhuǎn)換示例詳解
- ReactQuery?渲染優(yōu)化示例詳解
- 如何用webpack4.0擼單頁/多頁腳手架 (jquery, react, vue, typescript)
- React中jquery引用的實現(xiàn)方法
相關(guān)文章
更強大的React 狀態(tài)管理庫Zustand使用詳解
這篇文章主要為大家介紹了更強大的React 狀態(tài)管理庫Zustand使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-10-10
React中的stopPropagation和preventDefault實踐記錄
本文詳細(xì)介紹了事件冒泡、捕獲與React合成事件體系下的表現(xiàn)區(qū)別,包括事件傳播階段、處理方式、事件池機制等核心概念,對React?stopPropagation和preventDefault相關(guān)知識感興趣的朋友跟隨小編一起看看吧2025-11-11
react實現(xiàn)頭部導(dǎo)航,選中狀態(tài)底部出現(xiàn)藍(lán)色條塊問題
這篇文章主要介紹了react實現(xiàn)頭部導(dǎo)航,選中狀態(tài)底部出現(xiàn)藍(lán)色條塊問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-11-11
antd?table動態(tài)修改表格高度的實現(xiàn)
本文主要介紹了antd?table動態(tài)修改表格高度的實現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-07-07

