前几天一个做移动端 H5 的同事跑过来问我:HomeList 列表页,从详情页返回时每次都要重新 loading,网络请求一遍遍发。他自己在组件内部用 useState 存了列表,但一离开页面状态就没了,回来又得重新拉一遍。这个场景太典型了,几乎每个 React 项目里都会遇到。今天就把我实际做过的几种方案完整梳理一遍,从最简单的模块级缓存到团队里常用的 React Query / SWR 方案,再到缓存失效怎么处理,一次性讲清楚,适合正在写 React 项目、被重复请求问题困扰的开发者直接参考。
1. 先想清楚:为什么 useState 里的数据会“丢”
1.1 组件卸载之后,状态随组件一起销毁
很多同学第一次遇到这个问题时,第一反应是“我明明已经把数据存到 state 里了,为什么返回页面又没了”。这个理解其实有偏差。React 的 useState 状态是挂在组件实例上的,组件一旦卸载,对应 fiber 节点被销毁,状态自然就没了。HomeList 从详情页返回时,会重新挂载,函数组件重新执行,useState 又走回初始值,所以网络请求必然重新发起。
这里要区分一个概念:路由切换和组件卸载不是一回事,但对函数组件来说,路由切走以后 HomeList 确实被卸载了。除非你用 keep-alive 或者把列表组件挂在常驻的内存节点上,否则组件内部的状态不可能跨路由保留。想让数据跨“挂载周期”存活,唯一的思路就是把数据放到组件外面去,组件只是一个“读取者”而不是“持有者”。
1.2 缓存到底要解决哪几类问题
拿 HomeList 举例,不同场景下的重复请求,其实性质完全不一样,不能用一个方案硬套。
第一种是“返回恢复现场”:用户从列表进详情,再返回列表,希望直接看到刚才的内容,不要 loading。这种情况缓存的是同一个组件、同一个路由下的数据。
第二种是“多个地方共享同一份数据”:比如首页一个列表卡片、搜索页一个列表、个人中心一个热门推荐,三个组件可能调的是同一个接口。如果各自发请求,浪费带宽也浪费用户流量。
第三种是“并发请求去重”:用户快速切换 tab,组件被反复挂载,短时间内同一个接口被触发了五六次。这时候光有结果缓存还不够,还得把“正在请求中的 Promise”也缓存住,让并发调用直接复用同一个请求。
这三类问题选型不一样,揉在一起写代码就会乱。我的习惯是先问自己:这个缓存是给谁用的?是给同一个组件恢复现场用,还是给多个组件共享用?这两个答案基本决定了代码要写在组件内部还是抽到通用层。
1.3 缓存放哪里:四种容器的对比
| 缓存位置 | 生命周期 | 是否跨组件共享 | 典型场景 | 缺点 |
|---|---|---|---|---|
| 组件内 useState + useEffect | 随组件挂载销毁 | 否 | 简单列表状态 | 无法解决返回重复请求 |
| 模块级变量 / Map | 页面刷新前一直存在 | 是 | 单页应用内存缓存 | 刷新失效,热更新可能重置 |
| 全局状态库 Redux / Zustand | 页面刷新前一直存在 | 是 | 多组件共享用户信息、列表 | 需要配套 action,样板代码多 |
| 请求库缓存 React Query / SWR | 由 gcTime / cacheTime 控制 | 是 | 列表、详情、筛选数据 | 引入额外依赖,团队需要统一规范 |
这里并不是说全局状态库不能做缓存,而是很多团队把列表缓存写进 Redux 其实是用重武器干轻活。列表数据塞进全局状态,一方面要维护一套 action / reducer,另一方面一旦要缓存多个条件组合,state 里的 key 会越写越复杂。如果你们项目里还没有全局状态库,只是为了缓存一个列表去引入它,我建议直接往后看,用模块级缓存或者请求缓存库更划算。
2. 最轻量的方案:用模块级缓存把列表“留在内存”
2.1 模块级缓存的最简实现
如果项目没有引入 React Query / SWR,也不想为了一个列表去加全局状态库,最简单的做法是把列表数据保存在 HomeList 这个文件的模块作用域里。因为 ES Module 的变量不会被组件卸载清掉,只要页面不刷新,模块变量就一直在内存里。
import { useEffect, useState } from 'react'; import { fetchHomeList } from './api'; let cachedList = null; function HomeList() { const [list, setList] = useState(cachedList || []); const [loading, setLoading] = useState(!cachedList); useEffect(() => { let ignore = false; if (cachedList) { setList(cachedList); setLoading(false); return; } setLoading(true); fetchHomeList() .then((res) => { if (ignore) return; cachedList = res.data; setList(res.data); setLoading(false); }) .catch(() => { if (!ignore) setLoading(false); }); return () => { ignore = true; }; }, []); // 渲染逻辑 return <List data={list} loading={loading} />; }这段代码的要点在于:组件挂载时先看缓存,有就直接用,没有才发请求。我第一次用这个方案的时候忽略了一个细节:请求成功之后,如果用户已经离开页面,组件卸载了,直接 setState 会触发 React 的警告。所以加了一个 ignore 标志,在组件卸载后跳过 setState。别小看这一步,React 18 的 StrictMode 在开发环境下会故意模拟组件卸载再挂载,如果这个标志处理不好,开发环境里会出现大量“Can't perform a React state update on an unmounted component”警告。
2.2 把缓存逻辑封装成 useCachedList Hook
直接在组件文件里写模块变量思路清楚,但一旦有第二个组件也需要缓存,就会想把这段逻辑抽出来。我建议抽成一个通用的 useCachedList Hook,核心是给缓存加一层“过期时间”,避免数据永远不更新。
// useCachedList.js import { useEffect, useState } from 'react'; const cacheStore = new Map(); function isFresh(cached, ttl) { return cached && Date.now() - cached.timestamp < ttl; } export function useCachedList({ key, fetcher, ttl = 5 * 60 * 1000 }) { const cached = cacheStore.get(key); const [state, setState] = useState(() => { if (isFresh(cached, ttl)) { return { data: cached.data, loading: false }; } return { data: cached ? cached.data : null, loading: !cached }; }); useEffect(() => { let ignore = false; const current = cacheStore.get(key); if (isFresh(current, ttl)) { setState({ data: current.data, loading: false }); return; } let requestPromise = current && current.promise; if (!requestPromise) { requestPromise = fetcher().then((res) => { // 只把成功的结果写入缓存 cacheStore.set(key, { data: res.data, timestamp: Date.now(), }); return res; }).catch((err) => { // 请求失败不写缓存,删掉 pending 标记 cacheStore.delete(key); throw err; }); // 先把 Promise 存进去,用于并发请求去重 cacheStore.set(key, { data: current ? current.data : null, timestamp: 0, promise: requestPromise }); } setState({ data: current ? current.data : null, loading: true }); requestPromise.then((res) => { if (!ignore) { setState({ data: res.data, loading: false }); } }); return () => { ignore = true; }; }, [key]); return state; }这个版本比最简版多了一个能力:并发请求去重。思路是:如果缓存里已经有 Promise,说明这个请求已经在飞了,后面进来的调用直接复用同一个 Promise,而不是再发一个。这个技巧在处理 tab 快速切换、多个组件同时挂载的场景下非常好用。我之前在一个后台管理系统里,一个筛选条件在三个地方联动,理论上一次操作会触发三处列表刷新,就是靠这个 Promise 缓存把请求合并成一次。
用的时候很简单:
const { data, loading } = useCachedList({ key: '/api/homeList', fetcher: fetchHomeList, ttl: 60 * 1000, });从这里也能看出一件事:所谓缓存,本质上就是用 key 去映射内存里的数据。key 的设计会直接影响缓存命中率。同样的接口、不同查询参数,key 必须带上参数,比如/api/homeList?category=1,否则会拿到别人的数据。
2.3 这个方案的适用边界与隐患
模块级缓存看着轻量,实际用起来有几个坑必须讲清楚。
第一个坑是热更新。开发环境里,Webpack Dev Server 在改代码时会触发模块热替换,模块变量可能被重置,缓存预期行为会变得很奇怪。如果你在开发时发现缓存时灵时不灵,不一定是逻辑错了,很可能是热更新在作怪。
第二个坑是 SSR。如果项目用了服务端渲染,模块作用域在服务器上是所有请求共享的,一个用户请求的数据可能被另一个用户读到。这个方案只适合纯客户端渲染项目,SSR 场景千万别这么干。
第三个坑是数据主动失效。模块变量没有内置的失效机制,一旦某个操作导致列表需要刷新,你得手动去清理缓存,通常是把 cacheStore 删掉或者重新赋值。业务简单还好,逻辑一多,删缓存的地方一多,很容易漏掉其中一个。漏掉之后的表现就是:列表数据明明已经改了,页面展示的还是旧的。这也是我在后面专门写缓存失效这一章的原因。
3. 业务上常用的工程化方案:交给请求层缓存
3.1 手写缓存的问题不只是“麻烦”
手写模块级缓存,问题不在于代码量,而在于它把所有细节都暴露给了业务方。你需要自己处理过期时间、内存清理、并发合并、主动失效,还要保证写出来的代码别人看得懂。这些逻辑单独拎出来都不难,但叠在一起之后,排查问题的成本会指数级上升。
我接手过一个大项目,里面每个列表页都有一套自己的缓存方案,有的是 module 变量,有的是 sessionStorage,有的是把数据挂在 window 上。结果就是:改一个接口字段,要全局搜索哪些地方调过这个接口、哪些地方做过缓存、哪里忘记清理。所以后来我给自己定了一个原则:如果要缓存的列表超过两个,直接上请求状态管理库,不要再各自造轮子。
3.2 用 React Query / SWR 替代手写缓存
React Query(现在叫 TanStack Query)和 SWR 是目前最主流的两个请求状态管理库。它们的核心思想一致:把服务端状态和客户端状态分开,请求结果放到“服务端状态缓存”里,由库统一管理。写起来比手写缓存简洁得多。
import { useQuery } from '@tanstack/react-query'; function HomeList() { const { data, isLoading, refetch } = useQuery({ queryKey: ['homeList'], queryFn: fetchHomeList, staleTime: 60 * 1000, gcTime: 5 * 60 * 1000, }); if (isLoading) return <Loading />; return <List data={data} onRefresh={refetch} />; }这里面有几个核心概念,我第一次用的时候容易混淆,现在一次性说清楚。
queryKey 是缓存的唯一标识。React Query 会把 queryKey 序列化后作为缓存 key,不同 key 对应不同缓存。如果你的列表依赖筛选条件,queryKey 必须包含筛选参数:
const { data } = useQuery({ queryKey: ['homeList', { category, page }], queryFn: () => fetchHomeList({ category, page }), });staleTime 表示数据在多少毫秒内是“新鲜”的。在新鲜期内,组件重新挂载时会直接返回缓存,不会重新请求。这是解决你“返回重复请求”最关键的一个参数。staleTime 设成 0,那等于每次进入都重新拉取,缓存形同虚设。我通常给列表数据设置 30 秒到 5 分钟不等,具体看业务对实时性的要求。
gcTime 表示缓存数据在内存里保留多久。超过这个时间,缓存会被垃圾回收。它的作用是控制内存占用,和一个组件是否重新请求没有直接关系,这点很多人会搞混。
SWR 的用法类似:
import useSWR from 'swr'; function HomeList() { const { data, isLoading, mutate } = useSWR('/api/homeList', fetchHomeList, { revalidateOnFocus: false, revalidateIfStale: true, dedupingInterval: 60 * 1000, }); }SWR 的 dedupingInterval 是请求去重的时间窗口,在这个时间内相同 key 的请求只会发一次,直接复用 Promise。如果你看过它内部实现会发现,它的核心和我在 2.2 节写的 Promise 缓存思路是一样的,只不过被库完整实现、测试、维护好了。
这两个库还有一个共同优势:DevTools 非常成熟。React Query Devtools 能直接看到当前有哪些缓存、状态是新鲜还是过期、最近一次请求时间,排查问题比手写缓存方便太多。
3.3 React 18 与缓存有关的两个底层能力
React 18 有两个特性,和缓存更新有关系,值得提一下。
第一个是自动批处理(Automatic Batching)。React 18 之前,只有在 React 事件处理函数里,多个 setState 才会被合并成一次渲染。promise、setTimeout、原生事件回调里的 setState 都会触发多次渲染。React 18 之后,这些场景全部自动批处理。对缓存逻辑来说,如果你在请求回调里同时更新 loading 和 data,React 18 会合并成一次 render,不会闪两次。这个细节对性能优化是有帮助的,但不会影响缓存命中逻辑,属于“知道即可”的底层知识。
第二个是 useSyncExternalStore。React 18 之后,官方推荐用这个 Hook 来读取外部状态源,目的是保证并发模式下 React 的一致性模型。如果你自己实现了一个外部 store 来存列表缓存,并且希望 React 能感知 store 的变化,可以试试用它。不过 React Query / SWR 内部已经处理好了这一层,日常开发里手写场景其实不多。
4. 缓存失效:缓存最怕的不是没有,而是过期数据被当成真的
4.1 必须主动失效的典型场景
缓存不是写进去就完事了,最怕的是缓存一直命中,但背后的数据已经变了,用户看到的是过期内容。我在实际项目里遇到过的必须主动失效的场景大致有这几类。
第一,手动下拉刷新。用户明确触发了刷新操作,这时候不能继续读缓存,必须绕过缓存重新请求。
第二,列表数据被修改。比如列表里有一个“删除”按钮,用户删掉一条以后,再不刷新缓存,那条数据会一直显示在列表里。这个场景几乎每个项目都有,也是最容易漏掉失效逻辑的地方。
第三,从详情页返回列表时,详情页可能修改了列表的某条数据。这种情况不能只看缓存是否过期,还要结合业务场景决定,如果详情页有编辑操作,返回时最好主动把列表缓存设成过期。
第四,登出、切换账号。这是最严重的一类,如果登录用户变了但缓存没清,用户 B 会看到用户 A 的数据,出过线上事故的都懂这种痛苦。
4.2 TTL 与 staleTime 怎么设置
TTL 和 staleTime 本质上都是“时间窗口”,窗口内缓存有效,窗口外需要重新请求。没有标准答案,只能给经验值。
数据实时性要求低、体量大的:比如商品分类树、城市列表、配置项,TTL 可以设到 5 到 10 分钟,甚至更长。
实时性要求中等、用户操作频繁的:比如订单列表、消息列表,TTL 设 30 到 60 秒比较合适。太短了缓存命中率低,太长了用户会抱怨“我明明刚处理了这个订单,列表里还是待处理”。
实时性要求非常高、几乎不允许看到过期数据的:比如聊天消息、实时库存,这种就不建议用 TTL 控制,而是每次进入页面强制 refetch,或者用 WebSocket 推送主动刷新。
设置 staleTime 时有一个小技巧:宁可先设长一点,再配合主动失效。因为主动失效是精确控制,TTL 是兜底策略。两者组合起来,缓存既不会长期脏着,也不会因为频繁自动刷新而浪费请求。
4.3 主动失效与版本号方案
React Query 里主动失效非常简单,一个 API 搞定:
import { useQueryClient } from '@tanstack/react-query'; const queryClient = useQueryClient(); // 失效单个列表 queryClient.invalidateQueries({ queryKey: ['homeList'] }); // 登出时清空全部缓存 queryClient.clear();SWR 里对应的是 mutate,几乎一样的语义。
但如果用的是自己写的模块级缓存,主动失效就得靠手动删 Map。为了不让失效逻辑散落到各个组件里,我通常给缓存模块加一个版本号机制。给数据本身记一个 version,每次请求之前判断版本是否一致,版本不一致就强制重新拉取。
// cacheManager.js const cacheStore = new Map(); const versionMap = new Map(); export function invalidateCache(key) { versionMap.set(key, (versionMap.get(key) || 0) + 1); } export function getCache(key) { const cache = cacheStore.get(key); const version = versionMap.get(key) || 0; if (cache && cache.version === version) { return cache.data; } return null; } export function setCache(key, data) { const version = versionMap.get(key) || 0; cacheStore.set(key, { data, version }); }这个方案的好处是,业务代码里只需要在“删除成功”“修改成功”这些时机调用 invalidateCache,不用关心缓存里存的是什么。版本号一变,所有后续调用都会自然穿透到最新数据。
4.4 容易被忽略的坑:缓存 key 必须和查询参数联动
列表数据往往会带筛选条件、分页参数、搜索关键词。如果缓存 key 只写死一个/api/homeList,那用户从“全部”切到“分类 A”时,拿到的是“全部”的缓存数据,页面内容错乱,而且很难排查,因为从代码上看逻辑没问题。
正确做法是把所有影响结果的参数都拼进 key。React Query 里就是这样,queryKey 是数组,可以放对象,它会自动序列化。
queryKey: ['homeList', { category: activeCategory, page: currentPage, keyword }]手写缓存也是一样,Map 的 key 直接拼成字符串:
const key = `/api/homeList?category=${category}&page=${page}`;这个坑我踩过一次之后养成一个习惯:只要接口参数可能影响返回结果,一律把参数放进缓存 key,而不是只放 URL。
5. 常见问题与排查实录
5.1 现象、原因、解决办法速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 返回页面仍然发请求 | 组件内部 state 没有缓存,或缓存 key 不匹配 | 改用模块级缓存 / React Query,确认 key 一致 |
| 缓存永远不更新,下拉刷新没变化 | staleTime 设置太长,或没有主动 invalidate | 调短 staleTime,并在数据变更后调用 invalidateQueries |
| 多个 tab 切换时请求重复 | 并发请求没有去重 | 在请求层缓存 Promise,或使用 SWR 的 dedupingInterval |
| 切号后看到上一个用户的数据 | 缓存没有在登出时清理 | 登出时调用 queryClient.clear(),手写缓存清 Map |
| 缓存命中但页面数据不对 | queryKey 缺少筛选参数,命中错误缓存 | key 里带上 category、page、keyword 等全部关键参数 |
| 开发环境请求出现两次 | React StrictMode 开发环境故意 double mount | 确认是 Dev 环境行为,线上不会出现;用 ignore 标志 + 请求去重兜底 |
这张表我每次做技术分享都会放,因为这些问题几乎覆盖了团队里新人用缓存时最容易犯的错。
5.2 三个我实际踩过的坑
坑一:StrictMode 双调用导致请求两次,误判为缓存失效。React 18 的 StrictMode 在开发环境下会故意让组件 mount -> unmount -> mount,目的是提前暴露内存泄漏问题。所以你会发现 Network 面板里同一个请求发了两次,这不代表你的缓存逻辑有问题,也不代表线上也会这样。我之前就因为这个排查了半天,后来把开发环境和线上环境拆开看才确认。处理方式是在 useEffect 里做好清理逻辑,同时配合请求层 Promise 缓存,这样即使组件挂载两次,同一个 key 的请求只会发一次。
坑二:把请求 Promise 和请求结果混在同一个缓存变量里。有一版手写缓存,我直接存了 promise,请求成功后 promise 又 resolve 出结果,第二次读取时拿到的是 promise 而不是数据,然后还得 .then 才能拿到值,用起来非常难受。后来改成缓存对象统一带 { data, promise, timestamp } 三个字段,promise 本质上只用于在请求进行中做并发合并,请求成功后就清掉,数据单独存。
坑三:登出逻辑里没有清缓存。这个是最疼的一个坑。用户 A 登录,HomeList 缓存了 A 的列表,退出后用户 B 登录,进 HomeList 直接拿到 A 的数据。虽然只是列表,但如果是后台管理系统里的敏感数据,这就是事故。从那以后,我在所有接入缓存的项目里都会加上一条硬性约定:登出、退出登录、切账号时,必须清理所有请求缓存。
5.3 怎么自查“是否真的没有重复请求”
写完缓存之后,怎么验证真的生效了?我一般用三步走。
第一步,打开 Chrome DevTools 的 Network 面板,Filter 选择 Fetch/XHR,然后从列表页进入详情页再返回,看/api/homeList这个请求是否只出现一次。如果出现两次,说明缓存没有命中。
第二步,在请求函数入口加一行调试日志。我的习惯是封装一个 request 工具,在里面打点记录接口名称和时间戳。缓存命中时不会走到 request 工具,所以通过日志就能判断是走了缓存还是发了请求。
第三步,配合 React Query Devtools 查看缓存状态。如果能看到 queryKey 对应的数据状态是 fresh,说明数据正在被缓存命中,stale 状态则说明下次挂载会重新请求。
这套自查方法很适合接到“首页列表返回时重复请求”这类问题后,快速定位是缓存没写,还是缓存写了但 key 对不上,还是越过了缓存层直接调了接口。
我在实际项目里的习惯是:数据形态简单、只有一两个页面需要缓存时,先用模块级缓存 Hook,代码量小,效果直接;一旦涉及多个页面共享数据、需要频繁失效、或者团队里有新人接手,直接上 React Query / SWR,省掉的不仅是重复请求,还有很多状态同步的麻烦。另外,缓存这东西,写进去容易,让它“该失效时失效”才是最花功夫的。别只盯着“多缓存一会儿”,要给数据定好过期策略,不然早晚会被一份过期列表坑一次。