ARTICLE DETAIL

资讯详情

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

React 列表缓存方案全解:从 useCachedList 到性能优化实践

React 列表缓存方案全解:从 useCachedList 到性能优化实践 项目标题里这个需求看着简单实际上坑不少。很多 React 项目做到后期首页列表数据重复请求的问题一定会暴露出来用户从首页切到详情页再返回列表闪一下 loading接口重新打了一遍或者 tab 切来切去每次切回来都在转圈。这不光是体验问题接口压力和流量成本也会成倍增加。这篇文章我就围绕HomeList这个场景把列表数据缓存的完整方案、实现细节和踩坑经验一次性讲透适合正在做 React 中后台、移动端 H5 或者 Taro 跨端项目的同学参考。1. 问题定位HomeList 为什么会一遍遍请求接口1.1 先从日常场景说起我接手过好几个 React 项目HomeList几乎是每个项目都会有的组件首页的商品列表、资讯列表、任务列表名字可能不一样但本质都一样——一个需要请求接口并渲染列表数据的组件。这类组件最典型的问题就是反复请求。你从 Home 页点进详情再点返回HomeList 重新挂载useEffect里的请求函数又跑了一遍。如果用户反复切换 tab、反复进出详情页每一次都会触发网络请求。更糟的是很多项目没有做请求取消前一个请求还没回来后一个又发出去了响应顺序错乱还会导致列表数据闪动错乱。有人会说这不是很正常吗组件重新挂载重新请求React 数据流就是这样。但从产品角度和性能角度来看这不正常。用户已经在首页看过这批数据了30 秒内返回首页他看到的不应该是 loading 转圈而应该是刚才那份数据直接呈现在眼前后台静默刷新。1.2 重复请求的四个来源我梳理了一下HomeList重复请求的来源基本是这四个第一组件卸载重建。这是最普遍的。React Router 在路由切换时默认会卸载当前页面组件返回时重新挂载useEffect依赖数组为空时首次挂载必然执行请求。如果你的页面组件在卸载时没有走缓存逻辑重新挂载就会重新请求。第二状态提升不足。列表数据被放在组件内部useState里组件一卸载数据就丢了。如果一开始就把数据放到父组件、Context、甚至全局状态库里数据生命周期就能超过组件本身。第三子组件重复挂载。有些列表组件在父组件重新渲染时因为 key 变化或者条件渲染逻辑不当被销毁重建。比如父组件里写了{visible HomeList /}visible 从false变true又变false每次都会重新走一遍挂载流程。第四React 18 StrictMode 的额外调用。这个比较隐蔽。React 18 开发模式下StrictMode 会故意让组件挂载、卸载、再挂载目的是帮你暴露副作用问题。如果请求函数没做幂等保护你会看到接口被请求了两次。这个问题只在开发环境出现但很多同学会误以为自己代码写错了或者在开发环境把问题掩盖了上线后反而没注意到。1.3 问题的本质是数据生命周期太短其实仔细想想HomeList重复请求的本质不是请求函数写错了而是数据生命周期设计不合理。列表数据本质上是一种读多写少的资源读多意味着同一个数据用户会在短时间内反复查看写少意味着它的内容在短时间内不会频繁变动。如果数据的生命周期只跟组件一样长那用户每次回首页都是重新拿数据这就是设计缺陷。正确做法是让数据的生命周期跟用户的实际需求对齐——用户在一定时间内反复访问首页数据就应该在内存里存着过期后再重新拉取。所以在动手之前要明确一点我们要做的是带过期策略的内存缓存而不是简单的永远缓存。否则会出现另一个问题——用户看到的数据永远不是最新的。2. 缓存方案选型五种常见做法对比2.1 模块级变量缓存这是最简单的方案在组件外部定义一个变量存数据// cache.js const cacheMap new Map(); export function getCache(key) { return cacheMap.get(key); } export function setCache(key, data) { cacheMap.set(key, data); }组件内部这样用const CACHE_KEY home_list_v1; function HomeList() { const [list, setList] useState([]); const [loading, setLoading] useState(false); useEffect(() { const cached getCache(CACHE_KEY); if (cached) { setList(cached); setLoading(false); return; } setLoading(true); fetchList().then((data) { setList(data); setCache(CACHE_KEY, data); setLoading(false); }); }, []); // 渲染逻辑... }这个方案的最大优点是零依赖、实现简单。缺点是数据放在模块作用域里刷新页面就没了而且没有过期时间数据会一直保留到页面卸载。但注意这个方案已经能解决 80% 的需求了。如果你只是想让用户在返回首页时不用重新加载模块级变量缓存完全够用。2.2 useRef Context 缓存如果你的项目用了 React Context可以把列表数据放到 Context 里同时用useRef记录缓存时间戳const HomeContext createContext(null); export function HomeProvider({ children }) { const cacheRef useRef({ data: null, timestamp: 0, }); const value useMemo(() ({ cacheRef, }), []); return HomeContext.Provider value{value}{children}/HomeContext.Provider; }这种方案的优点是把缓存提升到了全局但缺点也很明显Context一旦放数据所有订阅组件都会受影响性能上需要额外注意。而且 Context 本身不负责过期逻辑你还是要自己写时间戳判断。2.3 SessionStorage 缓存SessionStorage 的特点是标签页关闭即清除适合做页面刷新后的缓存恢复。用户刷新页面后模块级变量缓存会丢失但 SessionStorage 里的数据还在可以先渲染缓存数据再在后台静默刷新。实现时要注意数据量限制在 5MB 左右不要存大图 base64只存 JSON 序列化后的数据不能存函数、Date等读取时要try...catch因为用户可能手动改过存储内容。2.4 localStorage 缓存LocalStorage 的特点是持久化适合存不敏感、时效性低的列表数据。但要谨慎使用因为 LocalStorage 是同步读写存储大量数据会阻塞主线程。如果列表数据量大比如几千条记录用 localStorage 会在序列化/反序列化时产生明显的卡顿。我一般只在需要用户下次进入仍能看到上次数据的场景才用 localStorage。2.5 React Query / SWR如果你的项目已经在用 React QueryTanStack Query或者 SWR其实不需要自己实现缓存。这些库自带缓存、过期、去重、重新验证等功能专门解决这类问题。React Query 的核心用法import { useQuery } from tanstack/react-query; function HomeList() { const { data, isLoading } useQuery({ queryKey: [home-list], queryFn: fetchList, staleTime: 30 * 1000, // 30秒内不重新请求 cacheTime: 5 * 60 * 1000, // 5分钟内缓存有效 }); // 渲染逻辑... }staleTime和cacheTime是两个关键参数。staleTime表示数据在多少毫秒内被认为是新鲜的新鲜期内不会重新请求cacheTime表示缓存数据在内存中保留多久。这两个参数配合使用就能实现短时间内不重复请求超过时间后静默刷新的效果。这个方案最省心功能完整。缺点是你需要引入一个新依赖而且有些团队可能觉得为一个列表引入一个数据请求库太重了。2.6 我的选型建议不同场景的选型逻辑不一样我列一个参考表场景推荐方案原因简单场景只有一两个列表页模块级变量 时间戳过期零依赖实现快中后台项目列表页多且重复访问频繁React Query / SWR自带缓存、去重、并发控制移动端 H5页面会刷新但不想白屏SessionStorage 内存缓存刷新时可恢复数据列表会实时变化但不能太频繁请求自定义 Hook 轮询/定时刷新灵活控制刷新粒度团队已有自己的状态管理库Redux/Zustand 持久化中间件与技术栈统一复用现有工具我自己在实践中用得最多的是模块级变量 时间戳过期因为大部分项目的需求就是返回首页别重新 loading这个方案足够。等需求复杂到需要多维度缓存治理的时候再切 React Query 不迟。3. 核心实现一步一步搭出可复用的 useCachedList Hook3.1 确定缓存 key 的策略缓存的 key 很关键key 决定了缓存能不能命中会不会串数据。以一个常见的HomeList场景为例列表通常有自己的参数当前页数、每页条数、搜索关键词、分类 ID、排序方式。这些参数任何一个变化请求的数据就不同缓存就应该分开存储。我的做法是把请求参数序列化后作为 key 的一部分function buildCacheKey(baseKey, params) { const stableParams JSON.stringify(params || {}); return ${baseKey}:${stableParams}; }注意JSON.stringify对对象属性顺序敏感。{ a: 1, b: 2 }和{ b: 2, a: 1 }序列化后是不同的字符串会导致缓存命中失败。为了避免这个问题可以写一个稳定序列化函数对对象的 key 排序后再序列化function stableStringify(obj) { if (!obj || typeof obj ! object || Array.isArray(obj)) { return JSON.stringify(obj); } const sortedKeys Object.keys(obj).sort(); const result {}; for (const key of sortedKeys) { result[key] stableStringify(obj[key]); } return JSON.stringify(result); }3.2 带过期时间的内存缓存模块虽然 React 组件内可以用useRef存缓存但我更推荐把缓存逻辑抽到独立的工具模块里。这样不只在HomeList能用任何地方都能复用。先定义一个完整的缓存模块// utils/listCache.js const CACHE_TTL 30 * 1000; // 默认缓存时间30秒 const cacheStore new Map(); function normalizeKey(key) { if (typeof key string) { return key; } return stableStringify(key); } function setCache(key, data, ttl CACHE_TTL) { const normalized normalizeKey(key); const expireAt Date.now() ttl; cacheStore.set(normalized, { data, expireAt, }); } function getCache(key) { const normalized normalizeKey(key); const item cacheStore.get(normalized); if (!item) { return null; } // 过期则删除并返回 null if (Date.now() item.expireAt) { cacheStore.delete(normalized); return null; } return item.data; } function removeCache(key) { const normalized normalizeKey(key); cacheStore.delete(normalized); } function clearCache() { cacheStore.clear(); } export { setCache, getCache, removeCache, clearCache };这里的核心是Map数据结构。为什么用Map而不是普通对象因为Map的 key 可以是任意类型而且Map的读写性能在频繁增删时更稳定还有delete、size等原生方法直接可用语义清晰。过期时间的存在是为了避免数据永远不更新。假设用户的首页列表 5 秒前刚刷过5 秒后又进来一次这期间数据基本不可能变化可以放心用缓存但如果用户过了 10 分钟再进来缓存应该失效重新拉取最新数据。3.3 核心 HookuseCachedList有了缓存模块接下来写useCachedList。这个 Hook 的目标是有缓存先用缓存无缓存请求接口请求成功更新缓存。// hooks/useCachedList.js import { useState, useEffect, useRef, useCallback } from react; import { getCache, setCache } from ../utils/listCache; function useCachedList({ fetcher, cacheKey, ttl 30 * 1000, params {}, enabled true, onSuccess, }) { const [data, setData] useState([]); const [loading, setLoading] useState(false); const [error, setError] useState(null); const isMountedRef useRef(true); const requestIdRef useRef(0); // 缓存 key 使用参数序列化不同参数不同缓存 const finalCacheKey ${cacheKey}:${stableStringify(params)}; const loadData useCallback(async (options {}) { const { forceRefresh false, silent false } options; // 非强制刷新时先检查缓存 if (!forceRefresh) { const cached getCache(finalCacheKey); if (cached) { setData(cached); setLoading(false); return; } } if (!silent) { setLoading(true); } const requestId requestIdRef.current; try { const result await fetcher(); // 防止组件卸载后 setState if (!isMountedRef.current) { return; } // 防止过期请求覆盖新请求的结果 if (requestId ! requestIdRef.current) { return; } setData(result); setCache(finalCacheKey, result, ttl); setError(null); onSuccess?.(result); } catch (err) { if (requestId requestIdRef.current) { setError(err); } } finally { if (requestId requestIdRef.current) { setLoading(false); } } }, [finalCacheKey, fetcher, ttl, onSuccess]); // 首次挂载加载 useEffect(() { if (!enabled) { return; } isMountedRef.current true; loadData(); return () { isMountedRef.current false; }; }, [enabled, loadData]); return { data, loading, error, refresh: useCallback(() loadData({ forceRefresh: true }), [loadData]), silentRefresh: useCallback(() loadData({ forceRefresh: true, silent: true }), [loadData]), }; } export default useCachedList;这个 Hook 的亮点在三个地方第一个亮点是requestId防竞态。前端项目里用户连续点击刷新会同时发出多个请求。如果先发出的请求比后发出的慢先发出的结果反而最后返回导致列表显示的数据是旧的。requestId保证只有最新一次请求的结果才会被应用到状态里。第二个亮点是isMountedRef防内存泄漏。组件卸载后setState会报警告React 18 里虽然不报错但会有内存泄漏隐患。这个 ref 在卸载时置为false异步回调里检查一下安全很多。第三个亮点是区分了强制刷新和静默刷新。refresh会清掉 loading让用户明确看到数据在更新silentRefresh在后台拉新数据不打断用户操作。这两个方法在后面的交互场景里会非常有用。3.4 在 HomeList 中使用写一个完整的HomeList组件示例// components/HomeList.jsx import useCachedList from ../hooks/useCachedList; import { fetchHomeList } from ../api/home; function HomeList({ categoryId all }) { const { data, loading, refresh, silentRefresh } useCachedList({ fetcher: () fetchHomeList(categoryId), cacheKey: home_list, params: { categoryId }, ttl: 60 * 1000, }); useEffect(() { // 从详情页返回时静默刷新一次让用户看到旧数据同时后台更新 const onShow () silentRefresh(); window.addEventListener(focus, onShow); return () window.removeEventListener(focus, onShow); }, [silentRefresh]); if (loading data.length 0) { return div加载中.../div; } return ( div {data.map((item) ( div key{item.id} classNamelist-item {item.title} /div ))} button onClick{() refresh()}刷新/button /div ); }这里的window.addEventListener(focus, onShow)是一个很实用的技巧。用户从详情页返回首页时页面的visibilitychange或者focus事件会被触发此时执行静默刷新用户看到的是旧数据秒开新数据在后台更新体验非常平滑。如果不想监听全局事件也可以用react-router的useLocation监听路由变化。3.5 缓存失效的四个触发条件缓存不能永远有效失效策略直接决定用户看到的数据新不新鲜。我总结出四个必须让缓存失效的场景场景一用户手动刷新。下拉刷新、点击刷新按钮这时候必须绕过缓存直接请求新数据。对应代码里的forceRefresh: true。场景二关键词或筛选条件变化。用户改了搜索词、切换了分类请求参数变了此时应该请求新数据。这里的处理有两种要么把参数拼进 key实现不同参数不同缓存要么清空旧缓存避免旧数据干扰新参数的结果。我的实践经验是宁可多一个 key也不要清旧缓存因为用户很可能会切回之前的筛选条件多一个 key 能让切回时秒开。场景三定时过期。数据超过ttl设定的时间后缓存自动失效。这是防止永远看到旧数据的兜底方案。场景四业务变更。比如用户做了某个操作导致列表内容必须更新删除了一条数据、改了一条数据的状态。此时要主动清理或覆盖对应缓存避免列表显示错误。做法示例import { removeCache } from ../utils/listCache; function handleDelete(itemId) { // 调用删除接口成功之后 removeCache(home_list); // 同时移除列表里对应的本地项或直接刷新 refresh(); }4. 进阶并发请求去重与 React 18 批处理4.1 缓存穿透同一个请求同时被触发多次现在处理一个更隐蔽的问题。假设HomeList和它的子组件ListHeader都需要列表数据它们同时挂载同时触发了请求。如果没有并发去重机制同一个接口会被打两次。这就是前端版的缓存穿透——缓存里没有数据导致大量请求同时打到后端。解决思路是在请求层做去重同样的请求在还没有返回结果时不要重复发起而是共享同一个 Promise。// utils/requestDedupe.js const pendingMap new Map(); function dedupeRequest(key, fetcher) { if (pendingMap.has(key)) { return pendingMap.get(key); } const promise fetcher().finally(() { pendingMap.delete(key); }); pendingMap.set(key, promise); return promise; } export default dedupeRequest;使用时const result await dedupeRequest(home_list_${categoryId}, () fetchHomeList(categoryId));这段代码的核心是第一次请求时把 Promise 存到pendingMap后续同样的请求直接返回同一个 Promise等请求完成后从pendingMap里删掉。这样同时发出的 N 个相同请求只会产生一次实际网络请求。结合缓存模块完整的链路是多个组件同时请求home_listpendingMap保证只发一个请求请求成功后结果写入缓存后续组件再请求时缓存命中直接返回缓存数据连 Promise 都不用共享了。4.2 React 18 自动批处理对请求次数的影响React 18 默认启用了自动批处理Automatic Batching。在 React 17 及之前React 只在事件处理器内部做批处理在 Promise 回调、setTimeout 里不会批处理。React 18 开始所有地方都会自动批处理包括 Promise 回调、setTimeout、原生事件处理器。这跟缓存有什么关系关系非常大。看这个例子async function loadData() { const data await fetcher(); setList(data); // 第一次 setState setLoading(false); // 第二次 setState setCache(data); // 第三次 updateCache }在 React 18 中setList和setLoading会被批处理只触发一次重新渲染这是好事。但要注意如果你的请求函数内部有多个setState它们会被合并如果你的组件里有边请求边渲染的需求批处理可能会影响你的时序预期。另外一个坑是React 18 的 StrictMode 在开发模式下会模拟组件卸载再挂载。在HomeList里如果useEffect执行了loadData()StrictMode 下会执行两次挂载 → 请求1 卸载 挂载 → 请求2如果没做缓存或去重请求1和请求2会同时发出。很多同学在开发环境看到接口调了两次以为是自己代码的问题排查半天发现是 StrictMode。上线后没有 StrictMode问题消失但代码里可能还潜藏着一堆副作用隐患。解决方法是用我们自己封装的useCachedList请求本身就带缓存和去重逻辑第一次请求发出后第二次挂载时会走缓存逻辑不会真的发两次请求。或者你可以用useRef标记是否已经请求过const hasRequestedRef useRef(false); useEffect(() { if (hasRequestedRef.current) { return; } hasRequestedRef.current true; loadData(); }, [loadData]);4.3 下拉刷新与静默刷新结合的最佳实践列表页最常见的交互是下拉刷新和上拉加载更多。HomeList里我会把这两个交互和缓存策略结合起来首次进入页面有缓存就先渲染缓存无缓存就 loading 请求下拉刷新强制刷新绕过缓存清掉 loading用户能看到刷新结果返回页面useEffect 监听 静默刷新旧数据先展示新数据后台拉超过 TTL 后再次进入缓存过期直接请求新数据。用代码组织一下const { data, loading, refresh, silentRefresh } useCachedList({ fetcher: fetchHomeList, cacheKey: home_list, ttl: 30 * 1000, }); // 下拉刷新 const onPullDownRefresh () { refresh(); }; // 页面返回时静默刷新 useEffect(() { const onVisible () { if (document.visibilityState visible) { silentRefresh(); } }; document.addEventListener(visibilitychange, onVisible); return () document.removeEventListener(visibilitychange, onVisible); }, [silentRefresh]);这里有个小细节visibilitychange事件比focus事件更适合移动端 H5。因为focus在页面初次加载时也会触发可能导致首次就静默刷新一次多了一次无意义请求。用visibilityState visible判断可以只在页面从后台切换到前台时刷新。5. 常见问题与排查实录5.1 缓存一直不生效接口还是重复请求这个问题最常出现在首次方案不完整、新老代码混用的情况下。比如你已经在新组件里用了useCachedList但旧代码里还有直接调用接口的地方两套逻辑都要走缓存就会出现绕过缓存的请求。排查顺序是确认实际请求的代码路径是否都经过loadData有没有直接调 API 的组件确认cacheKey是否稳定有的组件每次渲染都生成新 key缓存永远命不中检查 key 是否包含Math.random()、Date.now()等不稳定值确认是否有forceRefresh: true的调用意外触发比如 useEffect 依赖变化导致刷新函数被反复执行。5.2 缓存数据串了A 分类显示 B 分类的内容这个问题几乎都是cache key 没算好参数导致的。如果你把categoryId放在了参数里但拼 key 的时候忘了加进去就会出现用户切到 B 分类请求的是 B 分类的接口但 key 还是 A 分类的 key读缓存的时候返回了 A 分类的数据。解决方式很简单key 必须完整包含所有影响请求结果的参数。写完后可以加个单元测试或者 console.log 打印 key看一眼 key 是否随参数变化。5.3 缓存数据过期时间到底设多少这个没有标准答案取决于你的业务。我的经验值业务类型建议 TTL实时要求高库存、价格5-10 秒一般列表新闻、动态30-60 秒低频变更数据配置、地区列表5-30 分钟用户个人数据收藏、订单根据操作频率10 秒-5 分钟TTL 不是越长越好。设太长用户会看到过期数据设太短缓存形同虚设。建议以用户操作一次页面的平均时长为参考比如用户平均在一个页面停留 30 秒返回首页时 30 秒内没变过TTL 设为 30-60 秒就比较合适。5.4 内存缓存导致页面刷新后白屏模块级变量缓存的痛点是刷新页面就没了。如果你的列表页刷新后要先显示 loading 再重新请求体验确实差。遇到这种情况可以加一层sessionStorage兜底async function loadData() { // 1. 先查内存缓存 const memoryCached getCache(key); if (memoryCached) { setData(memoryCached); return; } // 2. 再查 sessionStorage const storageCached getSessionCache(key); if (storageCached) { setData(storageCached); // 3. 后台静默刷新一次保证数据新鲜 silentRefresh(); return; } // 4. 都没有才显示 loading 请求 setLoading(true); const data await fetcher(); setData(data); setCache(key, data); setSessionCache(key, data); }这样用户刷新页面后能立刻看到上次的数据从 sessionStorage 恢复同时后台静默更新。视觉上几乎没有空白期。5.5 团队协作中缓存策略的约定最后说个容易被忽略的问题如果你们团队多人维护一个项目缓存策略一定要有约定、有文档、有命名规范。我踩过的一个坑是A 同事在HomeList里用cacheKey: home-listB 同事在别的组件里也用cacheKey: home-list但数据结构完全不一样。结果 A 组件读到了 B 组件写的数据列表渲染直接崩溃。排查了很久才发现是两个 key 冲突了。建议在项目里维护一个cacheKeys.js文件统一管理所有缓存 key// constants/cacheKeys.js export const CACHE_KEYS { HOME_LIST: home_list, USER_INFO: user_info, MESSAGE_LIST: message_list, };所有组件引用这个常量避免魔法字符串。key 命名时带上业务前缀比如home_list_category_1这种降低冲突概率。6. 一点实操心得在我实际做过的项目里列表缓存的关键不是技术方案多复杂而是想清楚数据和用户的关系。用户返回首页想立刻看到内容这是体验需求后端不想接受太多重复请求这是性能需求列表数据要保持相对新鲜这是业务需求。三个需求会冲突缓存策略就是在三者之间找平衡点。建议第一次做的时候别一上来就上 React Query 这种重型方案。先用模块级缓存 时间戳过期把完整链路跑通再根据实际需要决定要不要引入更完整的方案。我自己现在维护的几个项目里80% 的场景用自定义的useCachedList就够了剩下的 20% 才需要专门的数据请求库来处理。还有个小细节不管用哪种方案一定要把refresh和silentRefresh两个方法暴露出去。因为列表页面一定会遇到用户主动刷新和后台静默更新两种场景只留一个刷新方法后面必然要改代码。这是我在几个项目里反复踩过的坑一开始想着有个刷新方法够用了结果每次加新功能都要回去改 Hook现在学乖了一上来就把两个方法都设计好。
返回列表