欢迎来到“服务器状态求生指南”系列讲座。我是你们的主讲人,一个每天在 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):
- 组件挂载。
- React Query 检查缓存。
- 如果数据是新鲜的(
staleTime内),直接返回缓存数据。不请求。 - 如果数据是旧的,发起请求。
但是! 如果你的 staleTime 是 0,或者你手动设为 true,情况就变了。
场景 A:强制刷新(refetchOnMount: true)
const { data } = useQuery(['user', userId], fetchUser, {
refetchOnMount: true, // 每次组件挂载都重新请求
});
这会导致一个严重的性能问题。想象一下,你在一个父组件里循环渲染 10 个子组件,每个子组件都依赖同一个查询键 ['user', userId]。
- 父组件挂载 -> 10 个子组件挂载。
- React Query 看到请求键一样,通常只会发一次请求(React Query 内部有去重机制)。
- 但是,如果
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。
逻辑是这样的:
- 用户离开标签页。
- React Query 监听
visibilitychange事件。 - 用户切回来。
- React Query 检查所有活跃的查询。
- 如果查询的数据是旧的(
staleTime过期),它会在后台发起请求。
关键问题来了:这和“组件卸载与重挂载”有什么关系?
关系在于,如果你的组件在后台是卸载状态的。
假设你有一个 Dashboard 页面,包含两个组件:UserList 和 UserStats。
- 你切到 Gmail 标签页。
Dashboard组件卸载(Unmount)。UserList和UserStats也卸载。 - 你切回 Dashboard。组件重挂载。
- 此时,React Query 会触发
refetchOnWindowFocus。 - 如果
staleTime过期,它会发起请求。 - 如果
refetchOnMount是false(默认),且组件重挂载时数据是新鲜的,它可能不会发请求(取决于staleTime)。
代码演示:
function Dashboard() {
const { data } = useQuery(['todos'], fetchTodos, {
staleTime: 60000, // 1分钟内认为新鲜
refetchOnWindowFocus: true, // 切回来时如果过期就刷新
refetchOnMount: false, // 组件重挂载时不刷新
});
return <div>{data ? data.length : 'Loading'}</div>;
}
测试场景:
- 请求发出,数据拿到。
- 等待 2 分钟(超过
staleTime)。 - 切到其他标签页,再切回来。
Dashboard组件重挂载。- 结果: React Query 检测到
refetchOnMount: false,并且staleTime已经过期。它发起请求了!(因为refetchOnWindowFocus覆盖了refetchOnMount的逻辑,或者至少在这个场景下触发了)。
注意: 实际上,refetchOnWindowFocus 的优先级通常高于 refetchOnMount 的逻辑判断。如果你希望完全禁止后台更新,你需要设置 refetchOnWindowFocus: false。
第六章:initialData 与 placeholderData 的“死亡陷阱”
这是最让人头秃的地方。当你想在组件重挂载时保留旧数据,但又想如果旧数据不存在(比如组件第一次挂载)能显示加载状态,该用什么?
很多新手会这样写:
function UserProfile({ userId }) {
// 错误示范!
const { data } = useQuery(['user', userId], fetchUser, {
initialData: { name: '未知用户' }, // 这里的坑是,这会立即把数据放进缓存!
});
return <div>{data.name}</div>;
}
后果:
- 组件挂载,显示“未知用户”。
- 请求发出去。
- 请求回来,数据更新。
- 组件卸载。
- 组件重挂载。
- 因为
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>;
}
逻辑流:
- 组件挂载。
placeholderData传入旧的prevData。UI 显示旧数据。- 如果请求完成,
data变为新数据。UI 更新为新数据。 - 组件卸载。
- 组件重挂载。
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>
);
}
逻辑分析:
- 组件挂载: 两个组件同时挂载。React Query 检查缓存。如果有数据,直接用。如果没有,发起请求。
- 组件卸载: 用户点击“返回”。组件卸载。数据留在内存里。
- 切标签页: 用户切到微信。React Query 监听到焦点丢失。
- 切回标签页:
ProductInfo组件重挂载。因为refetchOnMount: false且staleTime10秒内(假设没过10秒),不发请求。UI 显示缓存数据。ReviewList组件重挂载。因为staleTime60秒内,不发请求。
- 5秒后:
useEffect触发invalidateQueries。 - 组件重挂载(如果有的话): 数据标记为过期,发起请求。
第九章:高级话题 —— refetchOnReconnect
最后,我们来聊聊网络断开与重连。
当用户从 Wi-Fi 切到 4G,或者从 4G 切回 Wi-Fi 时,React Query 也会触发 refetchOnReconnect。默认是 true。
这意味着,一旦网络恢复,所有活跃的查询都会尝试刷新数据。
但是! 这里有个陷阱。
如果你的组件因为网络断开而挂起了(比如 isLoading: true),然后网络恢复了。组件依然挂载着。
React Query 会自动发起请求。
如果此时你手动把组件卸载了(比如用户点了返回),然后又重挂载了。
数据会变吗?
如果 staleTime 没过,且 refetchOnMount 是 false,组件重挂载时,它可能直接拿的是断网前(或重连后)的最新缓存数据。
结语:如何优雅地管理这种混乱
React Query 的缓存失效策略,本质上是在性能和实时性之间做权衡。
- 如果你希望组件重挂载时零延迟: 使用
staleTime。确保数据在组件卸载后依然被认为是“新鲜”的。 - 如果你希望组件重挂载时总是最新: 谨慎使用
refetchOnMount: true,或者使用invalidateQueries。 - 如果你希望切标签页时更新: 依赖默认的
refetchOnWindowFocus,但要注意staleTime的设置,否则切回来只会白忙一场。 - 如果你想在组件重挂载时保留旧数据作为占位: 使用
placeholderData而不是initialData。
记住,React Query 不是魔法,它只是一个帮你管理服务器状态生命周期的工具。理解了“组件卸载”和“数据缓存”之间的分离,你就能彻底掌控你的应用状态。
好了,今天的讲座就到这里。去写代码吧,别让你的缓存失效了!