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

