ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

鸿蒙适配下的React Native搜索缓存:useMemo与useRef实践

鸿蒙适配下的React Native搜索缓存:useMemo与useRef实践 最近在折腾 React Native 应用适配鸿蒙 HarmonyOS NEXT别的功能都好说搜索模块反而成了最扎眼的短板用户敲完关键词搜索结果出来后换成别的词再搜再换回来同一个关键词每次都重新发网络请求列表闪没了loading 转圈数据才慢慢出来。产品在评审会上直接问“这个搜索是不是没做缓存”其实做了只是原来在组件内部放了一个临时 Map组件一卸载缓存就清空而且连“连续点两次相同搜索”这种最基本的场景都只覆盖了一半。这次借着鸿蒙适配的契机我把搜索结果缓存方案完整重写了一遍核心思路是用 useMemo 作为渲染期的命中控制器用 useRef 和 Map 做结果存储再配上 TTL 过期和 LRU 淘汰。这篇文章就完整记录一下这套方案的思路、代码和踩坑过程适合正在给 React Native 应用做鸿蒙适配、或者单纯想优化搜索模块缓存的同学参考。1. 为什么在鸿蒙上得另外做一套搜索结果缓存1.1 搜索场景下的重复请求与体验损耗先还原一下真实用户行为。用户在一个搜索页里大概率不是一次性就能搜到想要的结果。他会先在输入框里敲“蓝牙耳机”看看结果不满意删掉改成“tws耳机”看一眼又切回“蓝牙耳机”。在整个过程中“蓝牙耳机”这个关键词可能被搜索了两到三次。如果不做缓存后端接口就会被重复调用两到三次。搜索接口和一般的信息流接口还不一样它不是单纯的“拉最新的内容”而是对同一个关键词返回相对稳定的结果。至少在几分钟内同一个词搜出来的商品、文章或联系人列表基本不会变化。这种情况下缓存命中率会非常高收益也很直接。重复请求带来的副作用在界面上特别明显。React Native 的列表在收到新数据前通常会把旧列表置空或者用 loading 状态覆盖。于是用户看到的就是列表消失、白屏、转圈、数据回来、列表重新渲染。整个流程在 Android 和 iOS 上已经是肉眼可见的卡顿到了鸿蒙适配版本上因为桥接链路和 JS 运行时调度方式还处在优化期白屏时间被进一步放大。缓存命中可以直接跳过“清空列表、等待回包、重新渲染”这个过程让用户在切换搜索词时能立刻看到上一次相同搜索的结果。后端那边的感受就更直接了。搜索接口往往带有一定限流策略重复请求会白白消耗配额。如果团队是按接口调用量计费或者后端做了防抖限流不做缓存还会导致线上偶发请求失败。所以搜索缓存不是“锦上添花”而是基础体验的一部分。1.2 鸿蒙运行时与 RN 桥接的差异化现状说句实在话React Native 在鸿蒙上跑起来之后整体框架结构和 Android/iOS 很相似但底层的运行时有明显区别。鸿蒙版 React Native 的 JS 逻辑运行在鸿蒙的 ArkTS 运行时上而不是传统 RN 常用的 Hermes。这意味着几个特性JS 线程的 GC垃圾回收行为、对象分配和释放的节奏、以及跨线程通信的开销跟前端开发者在原生 RN 环境下积累的经验不完全一致。在 React Native 里UI 更新依赖 JS 线程计算布局和渲染指令然后通过桥接层发送给 UI 线程。鸿蒙适配版的桥接效率在基础场景下已经能跑但碰到“列表数据整体替换”这种重操作客户端能感受到明显的掉帧。搜索场景恰好就是这种重操作的典型旧的 state 被清空新的 state 填充整个列表从头渲染一遍。如果网络请求还慢白屏时间就会被拉长。所以在这个场景下缓存命中的意义不只是“少发一个请求”更是“跳过一整条异步链路”。异步链路里每一步都有不确定性网络排队、后端处理、数据回传、React 状态更新、列表重新渲染。而缓存命中是同步的——取数据、setState、列表渲染在同一个事件循环内完成。对这种重操作场景来说缓存带来的收益比普通页面大得多。1.3 缓存方案选型对比动手写代码前我先列了几种可以落地的缓存方案对比过之后才决定用 useMemo 组合 useRef 的方式。方案生命周期优点缺点组件内 useRef Map组件实例存在期间实现简单没有额外依赖组件卸载即失效跨页面复用要重新设计useMemo useRef 组合缓存组件实例存在期间命中检查自动触发代码可读性好依赖数组需要精心设计缓存容量需手动控制模块级 Map / 全局单例应用进程存活期间跨组件、跨页面共享卸载不丢需要手动管理清理时机容易内存泄漏AsyncStorage / Preferences持久化存储重启应用后仍可取异步读取无法同步命中搜索场景用不上我最终选的是“useMemo useRef 模块级 Map”这套组合。useMemo 负责在渲染期判断“当前搜索参数是否命中缓存”一个模块级的 Map 负责真正存放搜索结果组件销毁时缓存依然存在。等用户再次进入搜索页还能命中同一份数据。这个组合避免了一个很尴尬的问题用户从搜索页返回商品详情页再回来缓存就因为组件卸载没了——那还不如不做缓存。2. useMemo 做缓存的核心原理与边界2.1 useMemo 的渲染期缓存逻辑useMemo 这个 Hook 大家都不陌生官方文档里的定位是“缓存计算结果”。它接收一个工厂函数和一个依赖数组在渲染期间检查依赖数组里的每一项是否跟上一次渲染时相同如果全部相同就直接返回上一次计算出来的值只要有任何一个依赖项变了就重新执行工厂函数并保存新的结果。这里的“相同”用的是 Object.is 比较。也就是说依赖数组里的值如果是基本类型比如数字、字符串比较就不会有问题但如果是对象字面量每次渲染都会生成一个新的引用Object.is 永远认为它变了。这个问题在搜索缓存场景里格外致命后面会专门讲。打个比方useMemo 就像宿舍门禁的登记簿你每次进门管理员先看你脸依赖项脸没变就放行不用重新登记脸变了才重新登记一遍再放行。它节省的是“重新登记”的开销而不是“放行”这个动作本身。2.2 为什么“搜索参数到结果”非常适合用 useMemo 表达搜索场景本质上是一个纯函数输入一个搜索参数对象输出一个搜索结果列表。同一个输入在短时间内应该得到同样的输出。这个数学模型跟 useMemo 的定位天然契合。在实现层面我是这么用的const stableKey useMemo( () JSON.stringify(searchParams), [searchParams] ); const hit useMemo( () cacheManager.get(stableKey), [stableKey] );第一层 useMemo 负责把 searchParams 对象稳定化。不管外部传入的 searchParams 是不是同一个引用只要序列化出来的字符串相同stableKey 就保持不变。第二层 useMemo 就拿着这个 key 去全局缓存里查结果。这里的关键是useMemo 不是“存储”缓存的地方它是“在渲染期自动触发命中判断”的地方。真正的数据存储在模块级 Map 里useMemo 保证的是“依赖参数没变化的时候不会重复执行查找逻辑”并且让命中结果能够稳定地参与后续渲染。这种分工比我最初想的“直接用 useMemo 缓存结果值”要合理得多因为 useMemo 本身并不提供跨组件实例的持久能力。2.3 必须避开的三个坑坑一依赖数组写错缓存永远命中不了。假如我把 searchParams 直接放进依赖数组而 searchParams 是父组件每次渲染都新建的对象那 useMemo 每次都会认为依赖变了stableKey 每次重新计算后面的缓存命中判断就没有意义了。解决方式就是用 JSON.stringify 先把参数序列化成字符串再把字符串作为依赖这样只要业务语义不变字符串就不变。坑二异步结果写缓存时的竞态。搜索是异步请求用户快速切换关键词时会出现 A 请求还没返回B 请求已经发出去了。如果 A 此时才回来它会把缓存里的 A 数据写入正确的位置吗不一定。如果代码里按请求参数动态生成 keyA 的结果只会写进 key 为 A 的那个缓存位不会污染 B。但如果代码里用的是同一个 setState 回写就可能把 A 的结果渲染到 B 的列表上。所以缓存写入和 state 更新必须都绑定到当前搜索参数上不能共用一个回调。坑三以为 useMemo 能做持久化。useMemo 的值绑定在组件实例的渲染周期内组件卸载后这个值就没了。如果搜索页是栈式导航用户从 A 页面进入 B 页面再返回A 页面的 useMemo 缓存结果已经随着组件销毁被回收。想让缓存在组件卸载后继续有效就必须把数据放到模块级容器里useMemo 只负责“查”不负责“存”。3. 实操实现一个 useSearchCache Hook3.1 搜索页的组件结构这次改造基于一个典型搜索页顶部是搜索输入框和搜索按钮中间是列表区域下面有 loading 和空态。搜索行为由搜索按钮触发也可以由键盘的搜索键触发。组件结构大致是function SearchScreen() { const [keyword, setKeyword] useState(); const [filters, setFilters] useState({}); const [page, setPage] useState(1); const [status, setStatus] useStateidle | loading | success | error(idle); const [results, setResults] useStateSearchResult[]([]); // ... }这里我把分页、关键词、筛选条件都放进搜索参数里因为搜索结果缓存的设计必须考虑到分页。如果缓存只对“第一页”有效用户上滑加载第二页第三页时又得重新走网络那体验还是不完整。所以缓存 key 要把分页信息也包含进去。3.2 缓存数据结构设计缓存不能简单地把数据丢进 Map需要有元信息来控制过期和淘汰。我定义的缓存条目长这样interface CacheEntry { data: SearchResult[]; // 搜索结果 total: number; // 总条数方便分页场景做列表拼接 expireAt: number; // 期望过期时间戳ms refreshAt: number; // 最近一次写入时间 hitCount: number; // 命中次数便于观察和调试 }缓存容器则是一个模块级的 Map。之所以用 Map 而不是对象是因为 Map 的 key 可以安全地使用字符串而且 Map 天然记住插入顺序后面实现 LRU 淘汰时要依赖这个特性。const searchResultCache new Mapstring, CacheEntry();这里有个细节全局变量放在模块顶部多个页面实例之间共享。在 React Native 里JS 模块默认是单例的所以这个 Map 在整个应用运行期间都能被访问。只要应用进程不被系统杀掉缓存就一直有效。3.3 核心代码实现完整的 useSearchCache Hook 这样写import { useMemo, useRef, useCallback } from react; interface SearchParams { keyword: string; filters?: Recordstring, any; page?: number; pageSize?: number; } interface SearchResultItem { id: string; title: string; /* ... */ } interface CacheEntry { data: SearchResultItem[]; total: number; expireAt: number; refreshAt: number; hitCount: number; } const MAX_CACHE_SIZE 50; const CACHE_TTL 5 * 60 * 1000; // 5 分钟 const searchResultCache new Mapstring, CacheEntry(); /** 根据搜索参数生成稳定字符串 key */ export function getSearchCacheKey(params: SearchParams): string { return JSON.stringify({ keyword: params.keyword, filters: params.filters ?? {}, page: params.page ?? 1, pageSize: params.pageSize ?? 20, }); } /** 清理过期缓存并淘汰超出容量限制的条目 */ function evictCacheIfNeeded(): void { const now Date.now(); // 先清理过期项 for (const [key, entry] of searchResultCache) { if (entry.expireAt now) { searchResultCache.delete(key); } } // 超出容量时按 Map 插入顺序删除最老的条目简单 LRU while (searchResultCache.size MAX_CACHE_SIZE) { const oldestKey searchResultCache.keys().next().value; if (oldestKey undefined) break; searchResultCache.delete(oldestKey); } } /** 核心 Hook */ export function useSearchCache() { // 用 useRef 固定一个版本号用来在必要时主动清空所有缓存 const [cacheVersion, setCacheVersion] useState(0); const readCache useCallback((params: SearchParams): CacheEntry | null { const key getSearchCacheKey(params); const entry searchResultCache.get(key); if (!entry) return null; if (entry.expireAt Date.now()) { searchResultCache.delete(key); return null; } // 命中后更新访问顺序拿 Map 的删除再插入实现简单的 LRU searchResultCache.delete(key); searchResultCache.set(key, { ...entry, hitCount: entry.hitCount 1 }); return searchResultCache.get(key) ?? null; }, []); const writeCache useCallback((params: SearchParams, data: SearchResultItem[], total: number) { const key getSearchCacheKey(params); const now Date.now(); searchResultCache.set(key, { data, total, expireAt: now CACHE_TTL, refreshAt: now, hitCount: 0, }); evictCacheIfNeeded(); }, []); const clearCache useCallback(() { searchResultCache.clear(); setCacheVersion((v) v 1); }, []); // 用 useMemo 包装“按参数读取缓存”的结果让读取过程参与渲染期依赖追踪 const stableParams useMemo(() params, [params]); return { readCache, writeCache, clearCache, cacheVersion }; }在我自己的工程里这个 Hook 还要在返回的时候使用 useMemo 包装一个带params的派生结果我给一个完整调用示例function useCachedSearch(params: SearchParams) { const { readCache, writeCache } useSearchCache(); const stableKey useMemo(() getSearchCacheKey(params), [params]); const cachedResult useMemo( () readCache(params), [stableKey, readCache] ); return { cachedResult, writeCache }; }这样每次渲染只要stableKey不变cachedResult就会稳定地返回相同的对象引用不会引发不必要的子列表重渲染。stableKey是关键——它让依赖数组里的对象引用问题被彻底化解掉。3.4 在页面中接入页面里的接入逻辑大概是这样const { cachedResult, writeCache } useCachedSearch({ keyword, filters, page, pageSize: 20 }); const handleSearch useCallback(async () { const params { keyword, filters, page: 1, pageSize: 20 }; const cache readCache(params); if (cache) { setResults(cache.data); setTotal(cache.total); setStatus(success); return; } setStatus(loading); try { const res await requestSearch(params); writeCache(params, res.data, res.total); setResults(res.data); setTotal(res.total); setStatus(success); } catch (err) { setStatus(error); } }, [readCache, writeCache, keyword, filters]);这个方案我自己实测下来很稳。一个比较重要的体验是缓存命中时页面状态直接从idle跳到展示结果完全没有中间白屏。因为readCache是同步操作不涉及任何网络等待。3.5 缓存失效策略与 LRU 淘汰缓存不设过期时间就是定时炸弹。搜索结果虽然相对稳定但商品价格、库存、状态这类信息可能随时变。给缓存加 TTL 是必要的。我设的是 5 分钟这个值可以按自己业务的变更频率调整。如果业务数据变化特别快建议缩短到 1 至 2 分钟如果是相对稳定的通讯录或知识库搜索可以放宽到 10 分钟。容量限制也要有。如果用户搜索几百个关键词缓存 Map 就会膨胀到几百条占用内存越来越多。我这里设置 MAX_CACHE_SIZE 为 50超过 50 条就按 Map 的插入顺序删除最老的。之所以用 Map 的插入顺序而非真正的 LRU是因为在 React Native 场景下搜索结果缓存的命中模式已经足够局部化用户反复搜索几个关键词删最老的基本等价于删最少用的。真正的 LRU 需要在每次命中时把 key 重新插入到 Map 末尾我在readCache里已经做了这个操作删除再插入。所以准确地说这个实现就是 LRU每次命中把该项移到 Map 的末尾淘汰时从 Map 头部开始删。4. 鸿蒙平台的适配细节与性能实测4.1 在鸿蒙上跑 RN 的运行时差异这套方案在鸿蒙适配版上遇到的问题跟我在 Android 上遇到的问题有一些差别。鸿蒙版 React Native 的 JS 运行在 ArkTS 运行时上运行时内部对每个 JS 对象的分配频率更敏感而搜索缓存能够显著减少重复对象的创建和销毁对 GC 压力降低是有帮助的。另外一个直观差异是启动白屏。热搜词里经常看到“React Native 启动白屏”这个在鸿蒙适配初期确实比 Android 更明显。白屏的根因通常是 JS Bundle 初始化花费了较长的时间或者首屏组件在等待异步数据。我的搜索缓存方案虽然不能直接改变 Bundle 加载时长但它能减少首屏渲染中的一个关键路径——搜索结果的网络等待。如果页面启动后默认加载上一次的缓存结果那么用户看到的就不是白屏而是上一次的搜索结果体验会好很多。具体到 API 适配鸿蒙版 RN 的页面生命周期和原生 Android 略有不同。在 Android 的 RN 页面中组件卸载场景和鸿蒙并不完全一致。鸿蒙的 UIAbility 在切后台时可能触发窗口隐藏和状态保存应用如果被系统挂起整个 JS 上下文都会被冻结但是模块级缓存 Map 的数据依然保存在内存里只要应用进程没有被回收下次恢复时缓存依然能命中。要注意的是如果鸿蒙系统应用被置于后台后系统内存不足而回收了应用进程缓存会随之消失。因此我把缓存定位为“提升会话内体验”而不是“跨启动持久化”。如果需要更强的持久化应该配合鸿蒙自带的持久化存储能力但那属于另一个层级的方案复杂度会高不少。4.2 与鸿蒙页面生命周期联动的缓存管理在鸿蒙的窗口管理模式下RN 组件宿主在 ArkUI 的容器里页面进入后台时会触发 onHide 之类的事件但组件实例不一定会立即销毁。这意味着搜索页的缓存 Map 在用户切到其他 App、再切回来时依然有效这是符合预期的可以放心用。但要注意一个问题搜索页如果长期驻留在后台缓存 Map 不会自动清理容易变成“僵尸缓存”。我的建议是在搜索页所在组件的useEffect里加一个清理时机组件真正卸载时只清理与自己相关的缓存而不是全清。比如useEffect(() { return () { // 组件卸载时只删除当前页面用的缓存 key 前缀避免影响其他页面 const prefix keywordPrefix; for (const key of searchResultCache.keys()) { if (key.startsWith(prefix)) { searchResultCache.delete(key); } } }; }, [keywordPrefix]);这个细节在鸿蒙上尤其值得注意因为鸿蒙的返回栈管理跟原生 Android 不完全相同页面销毁时机往往比 Android 更晚组件实例可能在用户不可见的情况下继续存活。只清理自己这一层的缓存既避免内存浪费也不影响其他页面的缓存复用。4.3 多标签页与返回栈场景说到返回栈就不得不提多标签页场景。热搜词里有很多关于鸿蒙底部导航栏、Tab 切换的问题。RN 应用接入鸿蒙的 BottomNavigation 或者 Tabs 容器时Tab 页面的组件实例可能会在切换时被卸载也可能被保留具体取决于容器实现。如果页面被卸载组件内缓存的 useState 和 useMemo 会全部丢失但模块级 Map 不会。这正是我把缓存放在模块级 Map 里的核心原因——Google 搜索页跳到搜索结果详情页再返回搜索页组件销毁重建缓存依然能命中用户不会感觉数据丢了一次。对于跨 Tab 的搜索需求比如在“首页”和“搜索页”两个 Tab 之间切换模块级缓存也能保持一致。同一个关键词进入搜索页时如果首页已经请求过相同数据可以直接从缓存里拿省一次请求。当然这需要两个 Tab 使用相同的 key 生成规则。我在封装getSearchCacheKey时就强调了“统一 key 规则”的重要性不要在页面 A 里用JSON.stringify([keyword, page])在页面 B 里用keyword _ page这种手拼规则不然缓存就没法跨页面复用。5. 常见问题与排查技巧实录5.1 缓存命中了但界面还在转圈这个现象出现过一次原因比较隐蔽。当时我在handleSearch里先调了readCache命中后设置了results和status但页面上还有一个独立的loading状态由另一个 useEffect 控制。缓存命中时那个 effect 已经触发了setLoading(true)导致状态冲突。解决方式很简单把 loading 状态收敛到一个状态机上。要么用status一个字段表示 idle/loading/success/error要么在命中缓存分支里显式把 loading 设置为 false。两种方式都能解决我更推荐状态机方案因为它在表达“当前请求处于什么阶段”这件事上语义更清晰。5.2 依赖数组里塞了对象导致永远不命中这个问题是 useMemo 最常见的误用。我最初把params对象直接作为依赖数组项由于父组件每次渲染都会生成新的对象引用useMemo每次都重新计算缓存 Map 里的旧数据永远查不到。排查方法很简单在getSearchCacheKey里手动打日志打印 key 的变化。如果同一个搜索词前后两次 key 完全不同说明参数序列化就有问题。解决办法就是我前面写的先用JSON.stringify把参数对象转成字符串依赖数组只依赖这个字符串。如果担心序列化性能可以自己实现一个轻量稳定的哈希函数但搜索参数本身很小JSON.stringify 的开销可以忽略不计。5.3 缓存数据是旧的、没有及时更新搜索结果缓存时间设太长会出问题。有一次用户在搜索结果页看到了已经失效的商品信息查了半天才发现缓存 TTL 设成了 30 分钟。搜索结果这类数据5 分钟已经是比较保守的上限了。如果有明确的“手动刷新”操作刷新时必须绕过缓存强制请求新数据我封装readCache时没有直接暴露“强制刷新”而是在调用侧加了一个forceRefresh参数强制刷新时直接跳过缓存读取。const handleRefresh useCallback(async () { const params { keyword, filters, page: 1, pageSize: 20 }; const res await requestSearch(params); writeCache(params, res.data, res.total); // 刷新时直接覆盖缓存 setResults(res.data); setStatus(success); }, [writeCache, keyword, filters]);注意这里不能先读缓存否则永远刷不出新数据。5.4 搜索结果带分页怎么缓存分页场景的缓存比单页搜索复杂一些。我的处理方式是把页码也放进缓存 key。但是列表滚动加载时如果每一页都单独缓存用户滑回来页面会拼接出断裂的数据。所以分页缓存的粒度要放粗一点第一页单独缓存后续页根据已加载的所有 data 合并成一个“完整列表缓存”。举个例子用户搜索“耳机”第一页 20 条接着加载第二页 20 条此时缓存里应该有一个包含 40 条数据的完整条目key 是{keyword: 耳机, pageSize: 20, loadedPage: 2}同时再缓存第一页的 20 条作为“首屏快速命中”。用户切走再切回来可以直接用完整列表渲染全部 40 条不需要重新往后再滚动加载。如果后端数据动态变化导致页与页之间数据重复需要前端做去重这个属于列表合并算法的问题不在缓存方案范围内但设计 key 的时候要把去重逻辑考虑进去。6. 搜索体验还能怎么优化6.1 防抖与缓存的配合缓存解决的是“相同请求重复执行”的问题防抖解决的是“连续输入造成的重复请求”问题。两者是互补关系不是替代关系。在搜索页我会在输入框的事件处理里加 300ms 防抖用户停止输入后再触发搜索。防抖期内如果用户改了两次关键词只有最后一次会真正发起请求。结合缓存效果是连续快速切换多个关键词时相同的关键词直接命中缓存不同的关键词由防抖合并网络请求数量大幅减少。6.2 热词预加载这个优化是后来加上的。给搜索页做一个本地热词列表用户进入页面时提前把热词的搜索结果请求一次并写入缓存。用户点击热词时如果缓存恰好命中页面秒开。我实现时发现这招对鸿蒙版本特别有用因为它把“网络请求”提前到了用户交互之前的空闲时段等用户真正点击时展示路径上已经完全同步化了。当然预加载会增加一些无谓的请求量适合用在热词数量和词条时效性可控的场景。我给热词列表只缓存了前 10 个TTL 缩短到 2 分钟避免后端数据变化导致热词结果显得过时。6.3 输入联想的缓存需要注意的地方如果是做搜索联想词的缓存方案跟搜索结果缓存略有区别。联想词通常要求高时效输入 “蓝” 和 “蓝牙” 都会联想不同关键词而且后端可能根据用户画像给出个性化结果命中率不如搜索结果稳定。这类数据我建议单独建一个 MapTTL 缩短到 30 秒容量限制也更紧。不要把联想词缓存和搜索结果缓存混在同一个 Map 里因为它们的淘汰策略完全不一样。最后分享一个我在这次迁移中最大的体会在鸿蒙适配这个阶段所有性能优化的优先级都应该重新评估。有些在 Android 上看似“够用”的实现换到鸿蒙运行时上会被放大成肉眼可见的问题。搜索缓存这种同步命中的优化对降低桥接链路的压力、减少白屏时间非常有效。如果你也在做类似的迁移建议从用户操作频率最高的模块开始改起搜索模块通常是收益最大、改动成本最低的一个。
返回列表