React 缓存失效策略:React Query 在组件卸载与重挂载时的失效数据背景更新逻辑

欢迎来到“服务器状态求生指南”系列讲座。我是你们的主讲人,一个每天在 React 和后端 API 之间穿针引线的资深老兵。

今天我们要聊的,是一个让无数 React 开发者,从初级到高级,都曾掉进去的坑——React Query 的缓存失效策略,特别是当你的组件“挂了”又“重生”时,那个后台的小幽灵在干什么。

别以为这是个无聊的话题。想象一下,如果你的应用没有缓存,那就像是一个没有记忆的健忘症患者。你刷新页面,数据全没了;你切个标签页,世界清零。而 React Query,就是那个负责给服务器状态“上户口”的神器。

来,搬好小板凳,我们开始。


第一章:当组件卸载时,数据去哪了?

首先,我们要搞清楚一个基本的哲学问题:组件是数据的主人,还是数据的搬运工?

在 React 的世界里,组件卸载(Unmount)通常意味着它要“退场”了。但在 React Query 的世界里,组件卸载只是意味着“搬运工”走了,但“仓库”里的货还在。

让我们来看一段代码。

import { useQuery } from 'react-query';

function UserProfile({ userId }) {
  const { data, isLoading } = useQuery(['user', userId], fetchUser);

  if (isLoading) return <div>正在加载中...</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>邮箱:{data.email}</p>
      {/* 这里有个按钮,点击会触发一个副作用 */}
      <button onClick={() => console.log('组件卸载了,但我还在!')}>
        卸载我
      </button>
    </div>
  );
}

function App() {
  return <UserProfile userId={1} />;
}

当你点击“卸载我”时,UserProfile 组件从 DOM 中消失了。但是,useQuery hook 做了什么?它没有丢弃 data

React Query 的缓存机制是这样的:组件卸载时,查询并不会停止。它只是在后台默默地把数据存在内存里。这就好比你去租了个房子(组件挂载),住了一个月(组件卸载),你搬走了,但房东(React Query)还保留着你的合同和家具。

关键点:

  • isLoading 状态会重置吗? 不会。
  • data 会消失吗? 不会。
  • 组件卸载时,后台还在请求吗? 如果请求正在进行中,它不会取消,而是会继续完成。

我们可以在组件卸载时做一个实验,验证一下后台数据还在:

import { useEffect } from 'react';
import { useQueryClient } from 'react-query';

function UserProfile({ userId }) {
  const queryClient = useQueryClient();

  // 我们监听组件卸载
  useEffect(() => {
    return () => {
      // 当组件卸载时,我们去“看一眼”后台的数据状态
      const state = queryClient.getQueryState(['user', userId]);
      console.log('组件卸载了,后台查询状态是:', state);
    };
  }, [userId, queryClient]);

  const { data } = useQuery(['user', userId], fetchUser);

  return <div>{data?.name}</div>;
}

你会发现,即使组件挂了,后台的查询状态依然存在。这就是 React Query 的第一层护甲:持久化


第二章:组件重生(重挂载)时的“灵魂拷问”

好了,搬运工走了,货还在仓库里。现在,搬运工回来了。组件重新挂载(Remount)。

这时候,React Query 会怎么做?它不会傻傻地又去发一次网络请求。它首先会去翻看仓库里的存货。

这就是缓存命中

function UserProfile({ userId }) {
  const { data, isLoading } = useQuery(['user', userId], fetchUser);

  // 如果是重挂载,isLoading 会是 false,data 会直接从缓存拿出来
  if (isLoading) return <div>加载中...</div>;
  return <div>{data.name}</div>;
}

但是! React Query 是个精明的管家,它不会把所有东西都当成新鲜货。它有个参数叫 staleTime

staleTime:数据的保质期

默认情况下,staleTime 是 0。这意味着,只要组件一重挂载,React Query 就会认为数据是“过期的”,然后立马去问服务器:“嘿,这货还是新鲜的吗?”

const { data } = useQuery(['user', userId], fetchUser, {
  staleTime: 5000, // 5秒内,数据被认为是新鲜的
});

如果 staleTime 设为 5 秒,而你在一个 3 秒内切换了 Tab 再切回来,组件重挂载时,React Query 会看到数据还在 5 秒内,于是它会说:“哦,这货还很新鲜,不用问了,直接给组件用。”

这也就是所谓的零延迟体验

但如果 staleTime 是 0(或者数据超过 5 秒了),React Query 会怎么做?它会发起一次请求。这叫后台刷新


第三章:组件卸载与重挂载的“背景更新”逻辑

现在我们进入正题。这是面试最爱问,也是实际开发中最容易出幺蛾子的地方:当组件卸载又重挂载时,React Query 是如何决定是否更新数据的?

这里有一个默认的配置叫 refetchOnMount。它的默认值是 undefined(也就是 false)。

默认逻辑(refetchOnMount: false):

  1. 组件挂载。
  2. React Query 检查缓存。
  3. 如果数据是新鲜的(staleTime 内),直接返回缓存数据。不请求
  4. 如果数据是旧的,发起请求。

但是! 如果你的 staleTime 是 0,或者你手动设为 true,情况就变了。

场景 A:强制刷新(refetchOnMount: true

const { data } = useQuery(['user', userId], fetchUser, {
  refetchOnMount: true, // 每次组件挂载都重新请求
});

这会导致一个严重的性能问题。想象一下,你在一个父组件里循环渲染 10 个子组件,每个子组件都依赖同一个查询键 ['user', userId]

  1. 父组件挂载 -> 10 个子组件挂载。
  2. React Query 看到请求键一样,通常只会发一次请求(React Query 内部有去重机制)。
  3. 但是,如果 refetchOnMount: true,且 staleTime 不够长,可能会导致重复请求或者不必要的逻辑触发。

更危险的情况:
如果 refetchOnMount: 'always'(这是 v4/v5 的一个特定值,或者你手动写逻辑),每次组件挂载都强制请求。

// 这是一个反模式示例
function UserProfile({ userId }) {
  const { data } = useQuery(['user', userId], fetchUser, {
    refetchOnMount: 'always', 
  });

  // 这里的逻辑可能有问题
  useEffect(() => {
    console.log('组件挂载了,我要刷新数据');
  }, []); 
}

这会导致你每次组件重挂载都去拉取数据,完全失去了缓存的意义。


第四章:组件卸载时的“清理”与“复活”

既然组件卸载时数据还在,那如果我们想彻底清除数据呢?比如,用户登出了,我们希望这个组件再次挂载时,不要看到旧的登录状态。

这时候,我们需要用到 useQueryClient

场景 B:彻底清除缓存(登出逻辑)

import { useQueryClient } from 'react-query';

function LogoutButton() {
  const queryClient = useQueryClient();

  const handleLogout = () => {
    // 清除所有查询
    queryClient.clear(); 
    // 或者清除特定的查询
    // queryClient.removeQueries(['user']);
    window.location.href = '/login';
  };

  return <button onClick={handleLogout}>登出</button>;
}

当你点击登出,所有查询都被清空了。这时候,如果你再次渲染 <UserProfile />,它会重新发起请求,因为缓存里没有东西了。


第五章:组件卸载与重挂载的“背景更新” —— refetchOnWindowFocus

这是最复杂,也最容易被误解的部分。

通常,我们希望组件卸载了,数据还在。但是,当我们把浏览器标签页切到一边,去回个邮件,然后又切回来时,我们希望数据是最新的。

这就是背景更新

React Query 默认开启了这个功能:refetchOnWindowFocus: true

逻辑是这样的:

  1. 用户离开标签页。
  2. React Query 监听 visibilitychange 事件。
  3. 用户切回来。
  4. React Query 检查所有活跃的查询。
  5. 如果查询的数据是旧的(staleTime 过期),它会在后台发起请求。

关键问题来了:这和“组件卸载与重挂载”有什么关系?

关系在于,如果你的组件在后台是卸载状态的。

假设你有一个 Dashboard 页面,包含两个组件:UserListUserStats

  1. 你切到 Gmail 标签页。Dashboard 组件卸载(Unmount)。UserListUserStats 也卸载。
  2. 你切回 Dashboard。组件重挂载。
  3. 此时,React Query 会触发 refetchOnWindowFocus
  4. 如果 staleTime 过期,它会发起请求。
  5. 如果 refetchOnMountfalse(默认),且组件重挂载时数据是新鲜的,它可能不会发请求(取决于 staleTime)。

代码演示:

function Dashboard() {
  const { data } = useQuery(['todos'], fetchTodos, {
    staleTime: 60000, // 1分钟内认为新鲜
    refetchOnWindowFocus: true, // 切回来时如果过期就刷新
    refetchOnMount: false, // 组件重挂载时不刷新
  });

  return <div>{data ? data.length : 'Loading'}</div>;
}

测试场景:

  1. 请求发出,数据拿到。
  2. 等待 2 分钟(超过 staleTime)。
  3. 切到其他标签页,再切回来。
  4. Dashboard 组件重挂载。
  5. 结果: React Query 检测到 refetchOnMount: false,并且 staleTime 已经过期。它发起请求了!(因为 refetchOnWindowFocus 覆盖了 refetchOnMount 的逻辑,或者至少在这个场景下触发了)。

注意: 实际上,refetchOnWindowFocus 的优先级通常高于 refetchOnMount 的逻辑判断。如果你希望完全禁止后台更新,你需要设置 refetchOnWindowFocus: false


第六章:initialDataplaceholderData 的“死亡陷阱”

这是最让人头秃的地方。当你想在组件重挂载时保留旧数据,但又想如果旧数据不存在(比如组件第一次挂载)能显示加载状态,该用什么?

很多新手会这样写:

function UserProfile({ userId }) {
  // 错误示范!
  const { data } = useQuery(['user', userId], fetchUser, {
    initialData: { name: '未知用户' }, // 这里的坑是,这会立即把数据放进缓存!
  });

  return <div>{data.name}</div>;
}

后果:

  1. 组件挂载,显示“未知用户”。
  2. 请求发出去。
  3. 请求回来,数据更新。
  4. 组件卸载。
  5. 组件重挂载。
  6. 因为 initialData 把数据写进了缓存,React Query 会认为数据是“新鲜的”(取决于 staleTime),它不会重新请求!你会一直看到“未知用户”,除非你手动清除缓存。

正确姿势:placeholderData

placeholderData 专门用于这种情况。它只影响 UI 显示,不会把数据写入缓存。

function UserProfile({ userId }) {
  const queryClient = useQueryClient();

  // 获取上一次的缓存数据
  const prevData = queryClient.getQueryData(['user', userId]);

  const { data } = useQuery(['user', userId], fetchUser, {
    placeholderData: prevData, // 如果没有新数据,显示旧数据
    staleTime: Infinity, // 假设旧数据永远是“新鲜”的(因为我们不想因为缓存过期而重发请求)
  });

  return <div>{data?.name || '加载中...'}</div>;
}

逻辑流:

  1. 组件挂载。
  2. placeholderData 传入旧的 prevData。UI 显示旧数据。
  3. 如果请求完成,data 变为新数据。UI 更新为新数据。
  4. 组件卸载。
  5. 组件重挂载。
  6. placeholderData 再次传入旧的 prevData。UI 再次显示旧数据。请求不会再次发出(因为 staleTime: Infinity)。

第七章:手动失效 —— 当数据已经“变质”

有时候,组件卸载了,数据在缓存里,但是数据是错的(比如服务器端数据变了,但客户端缓存没变)。我们需要在组件重挂载之前,或者在组件内部,手动告诉 React Query:“数据脏了,去刷新!”

这就是 invalidateQueries

import { useQueryClient } from 'react-query';

function UserForm() {
  const queryClient = useQueryClient();

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 更新数据...
    await updateUserData();

    // 关键一步:失效缓存
    // 这会让所有包含 ['user', userId] 的查询失效
    queryClient.invalidateQueries(['user', userId]);
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

这和组件卸载/重挂载有什么关系?
当你提交表单并失效查询后,如果你有另一个组件也在监听这个查询,或者当前的组件因为某种原因重新挂载,React Query 会自动发起一次请求来获取新数据。


第八章:实战演练 —— 综合案例

让我们来一个综合案例,模拟一个复杂的场景。

场景: 一个电商详情页。包含基本信息(组件 A)和评价列表(组件 B)。用户可能会离开页面,回来,或者切换标签页。

import { useQuery, useQueryClient } from 'react-query';

// 模拟 API
const fetchProduct = (id) => fetch(`/api/products/${id}`).then(res => res.json());
const fetchReviews = (productId) => fetch(`/api/reviews?productId=${productId}`).then(res => res.json());

// 组件 A:基本信息
function ProductInfo({ id }) {
  const { data: product, isLoading } = useQuery(['product', id], fetchProduct, {
    staleTime: 10000, // 10秒内认为新鲜
    refetchOnMount: false, // 组件重挂载不刷新
    refetchOnWindowFocus: true, // 切回来刷新
  });

  if (isLoading) return <div>Loading Info...</div>;
  return <div>{product.name}</div>;
}

// 组件 B:评价列表
function ReviewList({ id }) {
  const { data: reviews, isLoading } = useQuery(['reviews', id], fetchReviews, {
    staleTime: 60000, // 评价列表比较稳定,1分钟才刷新
    refetchOnMount: false,
    refetchOnWindowFocus: true,
  });

  if (isLoading) return <div>Loading Reviews...</div>;
  return (
    <ul>
      {reviews.map(r => <li key={r.id}>{r.text}</li>)}
    </ul>
  );
}

// 主页面
export default function ProductPage({ id }) {
  const queryClient = useQueryClient();

  // 监听数据变化,模拟后台更新
  useEffect(() => {
    const interval = setInterval(() => {
      // 模拟后台有新数据进来了
      const currentProduct = queryClient.getQueryData(['product', id]);
      if (currentProduct) {
        // 强制标记为过期,下次组件重挂载或切回来时会刷新
        queryClient.invalidateQueries(['product', id]);
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [id, queryClient]);

  return (
    <div>
      <h1>产品详情页</h1>
      <ProductInfo id={id} />
      <ReviewList id={id} />
      <button onClick={() => queryClient.invalidateQueries()}>强制刷新所有</button>
    </div>
  );
}

逻辑分析:

  1. 组件挂载: 两个组件同时挂载。React Query 检查缓存。如果有数据,直接用。如果没有,发起请求。
  2. 组件卸载: 用户点击“返回”。组件卸载。数据留在内存里。
  3. 切标签页: 用户切到微信。React Query 监听到焦点丢失。
  4. 切回标签页:
    • ProductInfo 组件重挂载。因为 refetchOnMount: falsestaleTime 10秒内(假设没过10秒),不发请求。UI 显示缓存数据。
    • ReviewList 组件重挂载。因为 staleTime 60秒内,不发请求
  5. 5秒后: useEffect 触发 invalidateQueries
  6. 组件重挂载(如果有的话): 数据标记为过期,发起请求。

第九章:高级话题 —— refetchOnReconnect

最后,我们来聊聊网络断开与重连。

当用户从 Wi-Fi 切到 4G,或者从 4G 切回 Wi-Fi 时,React Query 也会触发 refetchOnReconnect。默认是 true

这意味着,一旦网络恢复,所有活跃的查询都会尝试刷新数据。

但是! 这里有个陷阱。

如果你的组件因为网络断开而挂起了(比如 isLoading: true),然后网络恢复了。组件依然挂载着。
React Query 会自动发起请求。

如果此时你手动把组件卸载了(比如用户点了返回),然后又重挂载了。
数据会变吗?
如果 staleTime 没过,且 refetchOnMountfalse,组件重挂载时,它可能直接拿的是断网前(或重连后)的最新缓存数据。


结语:如何优雅地管理这种混乱

React Query 的缓存失效策略,本质上是在性能实时性之间做权衡。

  1. 如果你希望组件重挂载时零延迟: 使用 staleTime。确保数据在组件卸载后依然被认为是“新鲜”的。
  2. 如果你希望组件重挂载时总是最新: 谨慎使用 refetchOnMount: true,或者使用 invalidateQueries
  3. 如果你希望切标签页时更新: 依赖默认的 refetchOnWindowFocus,但要注意 staleTime 的设置,否则切回来只会白忙一场。
  4. 如果你想在组件重挂载时保留旧数据作为占位: 使用 placeholderData 而不是 initialData

记住,React Query 不是魔法,它只是一个帮你管理服务器状态生命周期的工具。理解了“组件卸载”和“数据缓存”之间的分离,你就能彻底掌控你的应用状态。

好了,今天的讲座就到这里。去写代码吧,别让你的缓存失效了!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注