返回

解决 React Query 缓存难题:Refetch成功数据为何不变?

javascript

React Query 缓存疑云:明明 refetch 成功,数据为何纹丝不动?

写 React 应用时,管理服务端状态是个挺烦人的事儿。React Query(现在叫 TanStack Query)出来后,确实帮了大忙,什么缓存、后台更新、数据同步,处理得明明白白。但有时候,这家伙也会耍点小脾气,让你摸不着头脑。

就像碰到的这个怪事:调用 refetch 或者 invalidateQueries 后,Network 面板里清清楚楚看到请求发出去了,服务端也返回了最新的数据。可偏偏,组件里通过 useQuery 拿到的 data 就是不更新,还是旧的那份!更邪门的是,连 React Query DevTools 手动触发 invalidate,现象一模一样:请求成功,拿到新数据,但 DevTools 里展示的缓存数据,死活不变。

瞅瞅这情况:

API 返回了 10 条数据:
API Response Screenshot

React Query DevTools 里,触发了上面那个请求后,显示的还是 9 条:
React Query DevTools Screenshot

这可真是让人抓狂。明明网络层面数据都对了,为啥到了 React Query 这一层就“卡壳”了呢?

问题出在哪儿?

React Query 不更新缓存数据,即使 API 调用成功并返回新数据,通常不是 React Query 本身的 bug,而是我们使用姿势可能有点问题。几种常见的“捣鬼”情况:

  1. Query Key 没对上: 这是最常见也最容易犯的错。refetch 是针对特定 useQuery 实例的,通常没问题。但 invalidateQueries 或者手动在 DevTools 里 invalidate 时,你提供的 queryKey 必须和 useQuery hook 里用的那个 完全一致。哪怕只是动态部分(比如用户 ID、筛选条件)的一个微小差别,都会导致 invalidate 的是“另一个”查询,而不是你当前组件正在监听的那一个。
  2. 数据直接被修改(Direct Mutation): React Query 为了性能优化,大量使用了引用比较(referential equality checks)。如果你从缓存里拿到数据后(比如 queryClient.getQueryData(queryKey)),直接在这个数据上“动手动脚”(比如给数组 push 新元素,或者直接修改对象的属性),而不是创建一个新的、修改后的副本,React Query 可能就傻眼了。它比较新旧数据引用,发现是同一个对象/数组,就认为“没变化”,即使里面的内容已经被你改了。当下次 fetch 回来的新数据恰好(或者经过你的处理后)和你“手动修改后”的数据在内容上一致时,即使网络返回了新对象,React Query 在进行内部比较后,也可能因为觉得“内容没实质变化”而不触发更新。但更常见的情况是,直接修改缓存数据会破坏 React Query 内部状态,导致各种奇怪行为,包括不更新。
  3. queryFn 返回的数据有问题: 有没有可能你的 queryFn 在拿到 API 原始数据后,做了一些处理,结果返回了一个不符合预期的东西?或者返回的数据结构不稳定,导致 React Query 难以正确 diff?虽然这里 API 响应看起来没问题,但检查下 complexQueryFunction 内部逻辑总没错。
  4. 依赖项和闭包问题: 你的 useItemsQuery hook 依赖了外部的 filteruser.id。如果这些依赖项更新了,queryKey 会变,这没问题。但有没有可能 complexQueryFunction 本身因为闭包的原因,捕获了旧的 filteruser.id 值?虽然 queryKey 变了会触发新的查询,但如果 queryFn 内部逻辑没用上最新的依赖值,也可能导致看似“没更新”。
  5. React 的渲染问题(不太可能但需排除): 极少数情况下,可能是 React 组件本身由于 React.memoshouldComponentUpdate 等优化,或者父组件渲染被阻止,导致即使 useQuery 的返回值更新了,组件也没有重新渲染。但从看,连 DevTools 数据都不更新,这个可能性较低。

从现象(DevTools 数据不更新)来看,Query Key 不匹配数据直接被修改 的嫌疑最大。

如何揪出真凶并解决?

咱们挨个排查,对症下药。

一、核对 Query Key:一个字母都不能错

这是首要检查点。invalidateQueries 时用的 Key,和你组件里 useQuery 定义的 Key,必须一模一样!

原理与作用:
React Query 用 queryKey 作为缓存的唯一标识符。所有与数据获取、更新、失效相关的操作,都依赖这个 Key 来找到正确的缓存条目。任何不匹配都会导致操作作用在错误的缓存上,或者干脆找不到目标。

检查步骤:

  1. 打印大法: 在你的 useItemsQuery hook 内部,以及调用 invalidateQueries 的地方,把 queryKey 打印出来看看。

    // in useItemsQuery hook
    export const useItemsQuery = (search = "") => {
      const { user } = usePermissions();
      const filter = useStore((state) => state.projects.filter);
    
      const queryKey = [
        "Items list",
        `Items by ${filter}`, // 确保 filter 值是最新的
        `User: ${user.id}`     // 确保 user.id 是最新的
      ];
      console.log("useQuery Key:", JSON.stringify(queryKey)); // 打印出来看
    
      const projects = useQuery({
        queryKey,
        queryFn: () => complexQueryFunction(filter, user.id)
      });
    
      return projects;
    };
    
    // 在触发 invalidate 的地方
    import { useQueryClient } from '@tanstack/react-query';
    
    function MyComponentWhereActionHappens() {
      const queryClient = useQueryClient();
      const { user } = usePermissions(); // 确保这里的 user 和 filter 来源与 useItemsQuery 一致
      const filter = useStore((state) => state.projects.filter);
    
      const handleCreateSuccess = () => {
        const queryKeyToInvalidate = [
          "Items list",
          `Items by ${filter}`,
          `User: ${user.id}`
        ];
        console.log("Invalidating Key:", JSON.stringify(queryKeyToInvalidate)); // 打印出来对比
        queryClient.invalidateQueries({ queryKey: queryKeyToInvalidate });
      };
    
      // ...
    }
    
  2. 比对输出: 仔细比对两个地方打印出来的 queryKey。字符串、数字、顺序,任何一点不一样都不行。特别注意动态部分(filter, user.id)的值在两个地方是否真的完全一致。可能某个地方拿到了旧的 state 值。

  3. 使用 Query Key 工厂函数(推荐): 为了避免手写 Key 时出错,或者在不同地方构造 Key 的方式不一致,最好定义一个生成 Query Key 的函数。

    // src/queries/itemKeys.js
    export const itemKeys = {
      all: ["Items list"], // 基础 Key
      lists: () => [...itemKeys.all, 'list'], // 所有列表的基础 Key
      list: (filters) => [...itemKeys.lists(), filters], // 带过滤条件的列表 Key
      detail: (id) => [...itemKeys.all, 'detail', id], // 详情 Key
    };
    
    // 使用 Query Key 工厂
    // in useItemsQuery
    import { itemKeys } from './itemKeys';
    
    export const useItemsQuery = (search = "") => {
      const { user } = usePermissions();
      const filter = useStore((state) => state.projects.filter);
      // 注意:这里 key 的结构和你原来稍有不同,是为了演示工厂模式
      // 你需要根据你的实际情况调整 itemKeys 工厂函数
      const queryKey = itemKeys.list({ filter: `Items by ${filter}`, userId: `User: ${user.id}` });
      console.log("useQuery Key:", JSON.stringify(queryKey));
    
      const projects = useQuery({
        queryKey,
        queryFn: () => complexQueryFunction(filter, user.id)
      });
      // ...
    };
    
    // 在触发 invalidate 的地方
    import { useQueryClient } from '@tanstack/react-query';
    import { itemKeys } from './itemKeys'; // 引入同一个工厂
    
    function MyComponentWhereActionHappens() {
      const queryClient = useQueryClient();
      const { user } = usePermissions();
      const filter = useStore((state) => state.projects.filter);
    
      const handleCreateSuccess = () => {
        // 使用同一个工厂函数生成 Key
        const queryKeyToInvalidate = itemKeys.list({ filter: `Items by ${filter}`, userId: `User: ${user.id}` });
        console.log("Invalidating Key:", JSON.stringify(queryKeyToInvalidate));
        queryClient.invalidateQueries({ queryKey: queryKeyToInvalidate });
      };
      // ...
    }
    

    使用工厂函数能大大降低 Key 不一致的风险。

进阶技巧:
React Query DevTools 本身就是检查 Key 的利器。当你在 DevTools 里看到你的 Query 时,它的 Key 就显示在那里。确保你用来 invalidate 的 Key 和 DevTools 里显示的一模一样。

二、严查直接修改缓存数据的“黑手”

如果你在代码的某个角落,不小心直接修改了从 React Query 缓存中拿到的数据,那很可能就是罪魁祸首。

原理与作用:
React Query 默认数据是不可变的(immutable)。它通过比较新旧数据的引用来决定是否需要更新。如果你直接修改了缓存中的对象或数组(in-place mutation),它的引用没变,React Query 就认为数据没变,即使里面的内容翻天覆地了。这不仅导致更新失败,还可能污染缓存,引发更多怪异问题。

排查方法:

  1. 代码审查: 全局搜索可能接触到查询数据的地方,特别是使用了 queryClient.getQueryDataqueryClient.setQueryData 的地方。检查是否有类似以下的操作:

    // 错误示范:直接修改缓存数据
    const currentItems = queryClient.getQueryData(queryKey);
    if (currentItems) {
      currentItems.push(newItem); // 不要直接 push!
      // 或者
      const itemToUpdate = currentItems.find(item => item.id === updatedItemId);
      if (itemToUpdate) {
        itemToUpdate.status = 'completed'; // 不要直接修改属性!
      }
      // 甚至直接用 setQueryData 设置同一个引用回去,也可能导致问题
      // queryClient.setQueryData(queryKey, currentItems);
    }
    
  2. 正确做法:创建新副本
    如果你确实需要在获取新数据前“乐观更新”或者手动更新缓存,务必创建新的数组或对象:

    // 正确示范:使用 setQueryData 更新,并提供新数组/对象
    queryClient.setQueryData(queryKey, (oldData) => {
      if (!oldData) return [newItem]; // 如果旧数据不存在
      // 创建新数组
      return [...oldData, newItem];
    });
    
    // 更新某一项
    queryClient.setQueryData(queryKey, (oldData) => {
      if (!oldData) return oldData;
      // 创建新数组,并替换需要更新的项
      return oldData.map(item =>
        item.id === updatedItemId ? { ...item, status: 'completed' } : item
      );
    });
    
  3. 排查组件内部: 组件内部拿到 items.data 后,有没有可能在渲染逻辑或者事件处理函数里不小心修改了它?虽然 props 和 state 应该是不可变的,但 JavaScript 本身不强制,得靠开发者自觉。仔细检查所有对 items.data 的操作。

安全建议:

  • 养成数据不可变性的好习惯,多用 map, filter, reduce, spread syntax (...) 等创建新数据,而不是直接修改。
  • 考虑使用 Immer 库来简化不可变更新的操作。

三、检查 queryFn 和它的依赖

确保你的 queryFn (也就是 complexQueryFunction) 总是返回 API 的最新数据,并且它内部使用的 filteruser.id 确实是 useQuery 当前执行时最新的值。

原理与作用:
queryFn 是实际获取数据的函数。如果它内部逻辑有问题,或者使用了陈旧的闭包变量,那么即使 queryKey 更新触发了重新执行,queryFn 可能还是基于旧的条件去获取数据,或者返回了错误处理过的数据。

检查步骤:

  1. 简化 queryFn 暂时修改 complexQueryFunction,让它直接返回最原始的 API 响应,不做任何处理。看问题是否消失。如果消失了,说明是你添加的处理逻辑有问题。

    const complexQueryFunction = async (filter, userId) => {
       const response = await fetch(`/api/items?filter=${filter}&userId=${userId}`);
       if (!response.ok) {
         throw new Error('Network response was not ok');
       }
       // 暂时直接返回 JSON,不做额外处理
       return response.json();
       // 原来的复杂逻辑暂时注释掉
       // const data = await response.json();
       // const processedData = ... // 你的处理逻辑
       // return processedData;
    };
    
  2. 确认依赖传递: 确认 useItemsQuery 每次执行时,传递给 queryFnfilteruser.id 是不是最新的。可以在 complexQueryFunction 内部也打印一下接收到的参数。

    const complexQueryFunction = async (filter, userId) => {
      console.log("queryFn received filter:", filter, "userId:", userId); // 确认参数
      // ... rest of the function
    }
    

    对比这个打印结果和 useQuery 处打印的 queryKey 中的值,确保一致。

进阶技巧:
如果 complexQueryFunction 非常复杂,考虑将其拆分成更小的、可测试的单元。确保数据转换逻辑是纯函数,给定相同输入总能得到相同输出。

四、排查 React 渲染及其他可能性

虽然可能性小,但以防万一。

  • 检查 React.memoshouldComponentUpdate 如果你的组件或其父组件使用了这些优化,确保它们没有错误地阻止了因 useQuery 数据更新而触发的重渲染。可以暂时去掉这些优化试试。
  • 检查是否有其他状态管理库在捣乱: 比如,你在 useEffect 里根据 React Query 的数据去更新 Zustand 或 Redux store,会不会是这个过程出了问题,或者更新逻辑覆盖了 React Query 想要设置的新数据?可能性不大,但如果前几步都无效,可以看看。

解决 React Query 数据不更新的问题,往往需要像侦探一样,一步步排查最可能的疑犯。耐心点,仔细核对 Query Key,严格遵守数据不可变原则,通常就能找到症结所在。祝你好运!