ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React 列表页返回重复请求?从 useState 到 React Query 的缓存方案

React 列表页返回重复请求?从 useState 到 React Query 的缓存方案 前几天一个做移动端 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 在开发环境下会故意模拟组件卸载再挂载如果这个标志处理不好开发环境里会出现大量“Cant 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?category1否则会拿到别人的数据。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 和 dataReact 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省掉的不仅是重复请求还有很多状态同步的麻烦。另外缓存这东西写进去容易让它“该失效时失效”才是最花功夫的。别只盯着“多缓存一会儿”要给数据定好过期策略不然早晚会被一份过期列表坑一次。
返回列表