如何基于react-query实现并发查询限制的自定义Hook?
解决方案:静态文档的并发获取与缓存管理
你的核心实现思路完全正确:先拉取文档元数据列表、再分批并发获取文档内容,结合staleTime: Infinity利用缓存避免重复请求,是处理静态未知数量文档场景的合理方案。以下是具体的落地实现细节:
一、核心思路验证
- 先获取元数据再拉取文档:必须先拿到文档的ID、类型等元数据,才能针对性发起内容请求,这个流程逻辑没问题。
staleTime: Infinity的使用:对于不会变更的静态文档,这个配置能让TanStack Query永久缓存请求结果,除非手动调用invalidateQueries,完全符合你的需求。- 自定义Hook控制并发:未知数量的请求如果一次性发起,会造成服务器压力或浏览器性能问题,通过自定义Hook做并发限制是必要的。
二、具体实现方案(基于TanStack Query)
1. 文档元数据列表查询
先实现获取元数据的Hook,同样设置staleTime: Infinity确保元数据也被永久缓存:
const useDocumentList = () => { return useQuery({ queryKey: ['document-list'], queryFn: async () => { // 替换为你的元数据请求接口 const res = await fetch('/api/documents/metadata'); return res.json(); }, staleTime: Infinity, }); };
2. 并发限制的文档内容获取Hook
这个Hook会自动过滤已缓存的文档,管理请求队列,控制同时进行的请求数量:
import { useState, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; const useConcurrentDocuments = (documentIds: string[], concurrency = 3) => { const queryClient = useQueryClient(); const [activeRequests, setActiveRequests] = useState<Set<string>>(new Set()); const [queuedIds, setQueuedIds] = useState<string[]>([]); const [fetchedDocs, setFetchedDocs] = useState<Record<string, Blob>>({}); // 初始化队列:排除已缓存的文档 useEffect(() => { const uncachedIds = documentIds.filter(id => !queryClient.getQueryData(['document', id])); setQueuedIds(uncachedIds); }, [documentIds, queryClient]); // 处理队列中的请求,控制并发数 useEffect(() => { if (queuedIds.length === 0) return; // 计算还能发起的请求数 const availableSlots = concurrency - activeRequests.size; if (availableSlots <= 0) return; // 取出队列中对应数量的文档ID const nextBatch = queuedIds.slice(0, availableSlots); nextBatch.forEach(docId => { setActiveRequests(prev => new Set([...prev, docId])); // 用fetchQuery发起请求并缓存 queryClient.fetchQuery({ queryKey: ['document', docId], queryFn: async () => { const res = await fetch(`/api/documents/${docId}`); return res.blob(); }, staleTime: Infinity, }) .then(blob => { setFetchedDocs(prev => ({ ...prev, [docId]: blob })); }) .catch(err => { console.error(`获取文档${docId}失败:`, err); }) .finally(() => { // 完成后从活跃集合和队列中移除 setActiveRequests(prev => { const newSet = new Set(prev); newSet.delete(docId); return newSet; }); setQueuedIds(prev => prev.filter(id => id !== docId)); }); }); }, [queuedIds, activeRequests, concurrency, queryClient]); // 合并缓存和已获取的文档 const allDocuments = useMemo(() => { return documentIds.reduce((acc, id) => { const cachedDoc = queryClient.getQueryData(['document', id]); return { ...acc, [id]: cachedDoc || fetchedDocs[id] }; }, {} as Record<string, Blob | undefined>); }, [documentIds, fetchedDocs, queryClient]); const isLoading = queuedIds.length > 0 || activeRequests.size > 0; return { documents: allDocuments, isLoading }; };
3. 页面组件中组合使用
将两个Hook结合,渲染不同类型的文档:
const DocumentGallery = () => { const { data: documentMetadata, isLoading: loadingMetadata } = useDocumentList(); const docIds = documentMetadata?.map(doc => doc.id) || []; const { documents, isLoading: loadingDocs } = useConcurrentDocuments(docIds, 3); if (loadingMetadata || loadingDocs) { return <div>加载中...</div>; } return ( <div className="document-grid"> {documentMetadata?.map(doc => { const blob = documents[doc.id]; if (!blob) return null; const objectUrl = URL.createObjectURL(blob); // 根据文档类型渲染 if (doc.mimeType.startsWith('image/')) { return ( <img key={doc.id} src={objectUrl} alt={doc.name} onLoad={() => URL.revokeObjectURL(objectUrl)} className="document-item" /> ); } else if (doc.mimeType === 'application/pdf') { return ( <iframe key={doc.id} src={objectUrl} title={doc.name} onLoad={() => URL.revokeObjectURL(objectUrl)} className="document-item pdf-viewer" /> ); } return ( <div key={doc.id} className="document-item"> {doc.name}(不支持的文件类型) </div> ); })} </div> ); };
三、关键注意事项
- 缓存复用:每次发起请求前先通过
queryClient.getQueryData检查缓存,确保已获取过的文档不会重复请求。 - 内存泄漏处理:使用
URL.createObjectURL生成的链接必须在组件卸载或资源加载完成后调用URL.revokeObjectURL释放内存。 - 错误隔离:单个文档请求失败不影响其他请求的执行,可根据需求添加重试逻辑(比如在
fetchQuery中配置retry参数)。 - 并发数调整:根据服务器承受能力和浏览器限制,合理设置
concurrency参数(一般3-5个较为合适)。
内容的提问来源于stack exchange,提问作者ToM
相关产品推荐
相关产品推荐

