React-Query:Mutation后多视图多查询键更新方案咨询
背景
我正在开发一个「Drive System」项目(简化版Google Drive克隆),用户可将媒体文件整理到文件夹和播放列表中。该系统包含以下页面:层级结构页面(类似Google Drive的「我的云端硬盘」)、最近创建媒体页、收藏媒体页以及与他人共享的媒体页。用户每次进入页面时,我都会请求获取最新数据,项目使用React-Query管理服务端数据。
问题
举个简单场景:假设用户要重命名一个媒体文件。我有一个MediaComponent负责处理媒体对象的所有操作,该组件会在系统的所有页面中渲染媒体对象。每个页面都有独立的查询键:
- 我的云端硬盘:
['folders id content'] - 最新媒体:
['medias recent'] - 收藏媒体:
['medias favorites'] - 与他人共享:
['medias shared-with-others']
每次用户重命名媒体后,我需要更新所有相关查询以获取最新数据。那么如何处理mutation响应,在不发起过多请求的前提下更新数据?
当前代码片段
媒体相关查询
export const mediaQueries = { all: () => ['medias'], latest: () => queryOptions({ // Latest Medias queryKey: [...mediaQueries.all(), 'recent'], queryFn: fetchLatestMedias }), favorites: () => queryOptions({ // Favourite Medias queryKey: [...mediaQueries.all(), 'favorites'], queryFn: fetchMediasFavorites }), sharedWithMe: () => queryOptions({ queryKey: [...mediaQueries.all(), 'shared-with-me'], queryFn: fetchMediasSharedWithMe }), sharedWithOthers: () => queryOptions({ // Shared With Others queryKey: [...mediaQueries.all(), 'shared-with-others'], queryFn: fetchMediasSharedWithOthers }) };
文件夹相关查询
export const folderQueries = { all: () => ['folders'], root: () => queryOptions({ queryKey: [...folderQueries.all(), 'detail', 'root'], queryFn: fetchRootFolder, staleTime: Number.POSITIVE_INFINITY }), content: (id?: string) => queryOptions({ // My Drive queryKey: [...folderQueries.all(), 'content', id], queryFn: () => fetchFolderContent(id!), enabled: !!id }), filter: (id ?: string, filters?: string|null) => queryOptions({ queryKey: [...folderQueries.all(), 'detail', id, filters], queryFn: () => fetchSearchInFolder(id!, filters!), enabled: id !== undefined && filters !== null }) };
更新媒体的Mutation
const useUpdateMedia = () => { const mutation = useMutation( { mutationFn: async ({ id, name } : DriveRenameData) => { const response = await patch<MediaHead>('/media/head', { data: { id, name } }); return response.data; }, onSuccess: (response) => { // What to do here ?? }, onError: () => { // Handle errors } }); return mutation; };
已尝试的方案
方案1:手动更新所有查询键
尝试过手动更新所有查询键,但由于每个查询的数据结构不同,实现难度大。而且未来新增页面和查询键时,需要修改所有相关mutation,扩展性差。
方案2:失效查询
用户重命名媒体时,发起mutation将新数据发送到服务端;mutation过程中通过UI实现乐观更新,实时显示新名称;mutation完成后,失效所有相关查询。
const useUpdateMedia = () => { const queryClient = useQueryClient(); const mutation = useMutation( { mutationFn: async ({ id, name } : DriveRenameData) => { const response = await patch<MediaHead>('/media/head', { data: { id, name } }); return response.data; }, onSettled: async (response) => { await queryClient.invalidateQueries({queryKey: mediaQueries.all()}); await queryClient.invalidateQueries({queryKey: folderQueries.all()}); }, onError: () => { // Handle errors } }); return mutation; };
MediaComponent中的乐观更新:
const MediaElement = (props: MediaElementProps) => { // Mutations const { mutate: updateMedia, isPending, variables } = useUpdateMediaMutation(); const mediaName = isPending ? variables.name : props.data.name; return <div className="MediaComponent" > {/** 媒体渲染 */} </div>; };
问题:仅更新名称就需要额外发起查询,用户再次进入页面时还会重新请求,冗余性高,尤其是成本较高的文件夹查询。
变体:不失效查询,依赖staleTime=0让用户再次进入时自动获取最新数据,但会先显示旧数据,直到新数据加载完成,可能影响体验。
方案3:使用全局状态管理器本地处理数据
将所有数据存储到全局状态中,组件从全局状态读取数据;每次查询或mutation时更新全局状态。
Mutation修改:
const useUpdateMedia = () => { const queryClient = useQueryClient(); const { updateMedias } = useMediathequeActions(); const mutation = useMutation( { mutationFn: async ({ id, name } : DriveRenameData) => { const response = await patch<MediaHead>('/media/head', { data: { id, name } }); return response.data; }, onSuccess: async (response) => { // 更新全局状态 updateMedias(response); }, onError: () => { // Handle errors } }); return mutation; };
Zustand全局状态:
import { create } from 'zustand'; import { MediathequeFolder } from '../types/folder.model'; import { MediathequeMedia } from '../types/media.model'; import { MediathequePlaylist } from '../types/playlist.model'; interface UseMediathequeStore { folders: MediathequeFolder[]; medias: MediathequeMedia[]; playlists: MediathequePlaylist[]; actions: { updateMedias: (...newMedias :MediathequeMedia[]) => void; updateFolders: (...newFolders :MediathequeFolder[]) => void; updatePlaylists: (...newPlaylists :MediathequePlaylist[]) => void; } } const useMediathequeStore = create<UseMediathequeStore>((set, get) => ({ folders: [], medias: [], playlists: [], actions: { updateMedias: (...newMedias: MediathequeMedia[]) => { const oldMedias = get().medias; newMedias.forEach((newMedia) => { const index = oldMedias.findIndex(m => m.id === newMedia.id); if (index === -1) { oldMedias.push(newMedia); } else { oldMedias[index] = { ...newMedia }; } }); set((state) => ({ medias: [...oldMedias] })); }, updateFolders: (...newFolders: MediathequeFolder[]) => { const oldFolders = get().folders; newFolders.forEach((newFolder) => { const index = oldFolders.findIndex(f => f.id === newFolder.id); if (index === -1) { oldFolders.push(newFolder); } else { oldFolders[index] = { ...newFolder }; } }); set((state) => ({ folders: [...oldFolders] })); }, updatePlaylists: (...newPlaylists: MediathequePlaylist[]) => { const oldPlaylists = get().playlists; newPlaylists.forEach((newPlaylist) => { const index = oldPlaylists.findIndex(p => p.id === newPlaylist.id); if (index === -1) { oldPlaylists.push(newPlaylist); } else { oldPlaylists[index] = { ...newPlaylist }; } }); set((state) => ({ playlists: [...oldPlaylists] })); } } })); // 原子选择器 export const useFolders = () => useMediathequeStore(state => state.folders); export const useMedias = () => useMediathequeStore(state => state.medias); export const usePlaylists = () => useMediathequeStore(state => state.playlists); // 操作方法 export const useMediathequeActions = () => useMediathequeStore(state => state.actions); export default useMediathequeStore;
加载文件夹内容并同步到全局状态:
import { useQuery } from '@tanstack/react-query'; import { folderQueries } from '../mediatheque.queries'; import { useEffect } from 'react'; import { useMediathequeActions } from '../../../store/mediatheque'; const useLoadFolderContent = (folderId ?: string) => { const query = useQuery(folderQueries.content(folderId)); const { data: currentFolder, isFetching } = query; const { updateMedias, updateFolders, updatePlaylists } = useMediathequeActions(); useEffect(() => { if (currentFolder && !isFetching) { updateMedias(...currentFolder.children.medias); updateFolders(...currentFolder.children.folders); updatePlaylists(...currentFolder.children.playlists); } }, [currentFolder, isFetching]); return query; }; export default useLoadFolderContent;
页面组件从全局状态筛选数据:
const MyDrivePage = (props: MyDrivePageProps) => { // 从URL获取文件夹ID const [searchParams, setSearchParams] = useSearchParams(); const folderId = searchParams.get('folder'); // 加载文件夹内容 const { isLoading } = useLoadFolderContent(folderQueries.content(folderId)); const allMedias = useMedias(); // 筛选当前文件夹下的媒体 const medias = allMedias.filter(m => m.parent.id === folderId); return <div className="MyDrive" > {/** 渲染文件夹 */} {/** 渲染媒体 */} <div className="Drive__container--medias"> {medias.length > 0 && medias.map((media) => { return <MediaComponent key={media.id} className="forceLandscape" data={media} />; }) } </div> {/** 渲染播放列表 */} </div>; };
问题:内存中可能累积大量数据,每次渲染需要从全局状态筛选数据,性能可能受影响。
最优方案建议
综合你的场景,推荐结合React-Query的setQueryData和乐观更新,配合部分查询失效的混合方案,平衡请求数量和开发复杂度:
1. 优化乐观更新+精准更新缓存
在mutation的onMutate阶段先做乐观更新,直接修改所有包含该媒体的查询缓存,避免后续失效查询的请求。
const useUpdateMedia = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, name }: DriveRenameData) => { const response = await patch<MediaHead>('/media/head', { data: { id, name } }); return response.data; }, // 乐观更新 onMutate: async ({ id, name }) => { // 取消所有正在进行的相关查询,避免竞态 await queryClient.cancelQueries({ queryKey: mediaQueries.all() }); await queryClient.cancelQueries({ queryKey: folderQueries.all() }); // 保存当前缓存快照,用于回滚 const previousMedias = queryClient.getQueriesData({ queryKey: mediaQueries.all() }); const previousFolders = queryClient.getQueriesData({ queryKey: folderQueries.all() }); // 更新所有媒体查询中的该媒体名称 queryClient.setQueriesData({ queryKey: mediaQueries.all() }, (oldData) => { if (!oldData) return oldData; // 根据不同查询的数据结构处理,比如数组或对象 if (Array.isArray(oldData)) { return oldData.map(item => item.id === id ? { ...item, name } : item); } // 处理文件夹查询中的媒体(比如文件夹内容里的media列表) if (oldData?.children?.medias) { return { ...oldData, children: { ...oldData.children, medias: oldData.children.medias.map(m => m.id === id ? { ...m, name } : m) } }; } return oldData; }); // 返回快照用于回滚 return { previousMedias, previousFolders }; }, // 失败时回滚缓存 onError: (err, variables, context) => { if (context?.previousMedias) { context.previousMedias.forEach(([key, data]) => { queryClient.setQueryData(key, data); }); } if (context?.previousFolders) { context.previousFolders.forEach(([key, data]) => { queryClient.setQueryData(key, data); }); } }, // 成功后用服务端返回数据更新缓存(确保一致性) onSuccess: (response) => { queryClient.setQueriesData({ queryKey: mediaQueries.all() }, (oldData) => { if (!oldData) return oldData; if (Array.isArray(oldData)) { return oldData.map(item => item.id === response.id ? response : item); } if (oldData?.children?.medias) { return { ...oldData, children: { ...oldData.children, medias: oldData.children.medias.map(m => m.id === response.id ? response : m) } }; } return oldData; }); }, // 仅对可能未覆盖到的查询做选择性失效(比如搜索筛选类查询) onSettled: () => { // 只失效筛选类查询,因为这类查询可能无法通过setQueryData精准更新 queryClient.invalidateQueries({ queryKey: [...folderQueries.all(), 'filter'] }); } }); };
2. 优化点说明
- 乐观更新:用户操作后立即更新UI,无需等待服务端响应,体验流畅。
- 精准缓存更新:通过
setQueriesData批量修改所有相关查询的缓存,避免全量失效查询带来的冗余请求。 - 选择性失效:仅对无法精准更新的查询(比如带复杂筛选条件的查询)做失效,减少不必要的请求。
- 回滚机制:mutation失败时恢复缓存,保证数据一致性。
3. 对比其他方案的优势
- 比方案1(手动更新所有查询键)更灵活,无需为每个新查询修改mutation。
- 比方案2(全量失效查询)减少大量冗余请求,尤其是文件夹这类成本高的查询。
- 比方案3(全局状态)更轻量,无需维护额外的全局状态,直接利用React-Query的缓存机制,避免内存累积和筛选性能问题。
额外建议
- 给查询键添加更清晰的结构,方便
setQueriesData时的匹配和处理。 - 对于文件夹查询,可以设置合理的
staleTime(比如5分钟),减少重复请求,同时保证数据不会太旧。 - 复杂筛选类查询可以单独处理失效,避免影响其他缓存。
内容的提问来源于stack exchange,提问作者NathanProDisplay

