You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React-Query:Mutation后多视图多查询键更新方案咨询

React-Query 中处理媒体重命名后多查询更新的最优方案

背景

我正在开发一个「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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 11:47:02