做前端时间长了,你会发现下拉框这个东西看起来不起眼,但却是最容易翻车的地方。尤其是带远程搜索的下拉框,用户每敲一个关键词,后端就重新查一遍,下拉列表就重新渲染一遍。看起来没啥,但在低端手机上,配合上输入法回弹、候选词高频变化、重复请求乱序返回,体验能直接掉到及格线以下。前端下拉数据缓存策略,简单说,就是怎么把已经查过的下拉数据存下来,让第二次、第三次打开时不再白白发请求,而是直接走本地缓存。我第一次认真做这类缓存,是在一个库存管理系统的物料选择器上。那个下拉框绑定了上万条物料,每次打开都要请求一次接口,接口响应本身不算慢,但配合上模糊搜索,用户每输入一个字符就触发一次请求,后端扛得住,前端的数据流却乱成一团。后来加了缓存,页面切换再回来,数据几乎秒开,体验提升肉眼可见。这篇内容适合所有跟表单、搜索、联动下拉打过交道的同学。不管你是刚入门的前端,还是已经在写业务代码但没系统整理过缓存方案的开发者,下面这份从选型到实现、再到踩坑的记录,都能直接拿来用。1. 选型之前,先想清楚这三个问题缓存方案不是越复杂越好,动手之前必须先想清楚几个问题:你的下拉是什么场景,缓存放在哪一层,缓存多久该失效。这三件事没想清楚,代码写多少都是白搭。1.1 你的下拉场景属于哪一种下拉框虽然长得都差不多,但数据来源和交互模式有本质区别。我一般把它分成三类:静态下拉:数据几乎不变,比如性别、学历、省份这类枚举项。最简单的方案是把数据直接打进前端配置表,连请求都不发。远程搜索下拉:数据量很大,必须按关键词去后端查,比如商品搜索、人员选择、地址联想。这类场景是缓存策略的主力对象。级联联动下拉:选择省后加载城市,选择分类后加载子分类。每一层的数据都可以缓存,而且父级ID一变,子级数据重复请求的问题特别明显。不同场景的缓存策略完全不同。静态下拉根本不需要缓存工具,初始化时加载一次就行;远程搜索下拉要缓存的是“关键词到结果集”的映射;级联下拉缓存的是“父级ID到子级列表”的映射。把这三件事分开设计,会比一个万能缓存方案干净得多。我见过不少同学一上来就套现成的缓存库,结果自己的场景其实只需要一个简单的Map就能搞定,反而把简单问题搞复杂了。所以动手前,先拿张纸列一下自己页面里到底有哪几类下拉,再逐一对应策略,这个步骤省不得。1.2 缓存放哪里:内存还是持久化这是第一个真正的分水岭。内存缓存(用Map或者对象直接存)读取极快,几乎零延迟,缺点是刷新页面就没了,而且页面运行期间会一直占着内存。持久化缓存(localStorage、sessionStorage)能跨刷新、跨页面存活,但读写是同步IO,数据一多就要做序列化和反序列化,甚至比重新请求一次接口还慢。我实测过一个有几万条选项的物料下拉框,localStorage存JSON再解析回来,耗时大概在20到50毫秒。看起来不高,但在快速键盘输入时会明显感觉到有一点顿,而内存缓存基本是0.5毫秒以内,差距不是一个量级。所以我的默认建议是:内存缓存为主,持久化缓存为辅。同一个页面的会话内复用,优先内存;真需要跨会话保存的场景,再单独把持久化方案加上。很多下拉数据对时效性要求并不高,能扛住一次会话就够了,刷新后重新拉一次未必是坏事。1.3 判断标准与取舍几个判断标准,我每次做选型都会过一遍:数据量:几百条还是几万条?数据量大,内存里也必须做淘汰,不能无脑存。变化频率:选项多久变一次?一天变一次和一分钟变一次,TTL的配置完全不同。时效敏感度:用户选错选项的代价高不高?比如选错药品、选错库存批次,这类场景宁可少缓存,也不要拿旧数据误导用户。我习惯把这几个维度整理成一张对比表:维度内存缓存localStorage混合方案读取速度极快需要序列化快跨页面恢复不支持支持视实现而定容量控制手动做淘汰约5MB上限可控适合场景会话内频繁切换低频、跨会话复用复杂业务很多团队一上来就上localStorage,其实是被“数据丢了要重新拉”这个想法吓到了。但下拉数据本质上就是一个查询结果的快照,丢了重新拉一次花费也不高。想明白这一点,方案就简单多了。2. 手写一个可以直接用的缓存工具类选型想清楚后,直接上一份能用的代码。这个工具类核心就四件事:存、取、删、过期。我会把演进过程也讲一遍,方便你理解每一步的取舍。2.1 基础结构:用Map而不是普通对象很多同学喜欢用普通对象当缓存:const cache = {}; cache[key] = data;这在简单场景没问题,但对象有原型链,键名可能会和内置属性冲突,而且没有内置的顺序概念。Map就舒服很多,有size、delete、keys、values等方法,后面做淘汰策略时特别顺手。下面是一个基础版本的实现:class DropdownCache { constructor(options = {}) { this.cache = new Map(); this.ttl = options.ttl || 5 * 60 * 1000; // 默认过期时间5分钟 } set(key, value) { this.cache.set(key, { data: value, expireAt: Date.now() + this.ttl, hitCount: 0, }); } get(key) { const item = this.cache.get(key); if (!item) return null; if (Date.now() > item.expireAt) { this.cache.delete(key); return null; } item.hitCount++; return item.data; } has(key) { const item = this.cache.get(key); if (!item) return false; if (Date.now() > item.expireAt) { this.cache.delete(key); return false; } return true; } delete(key) { this.cache.delete(key); } clear() { this.cache.clear(); } }代码看起来简朴,但核心逻辑已经全在了。set的时候记录过期时间,get的时候判断是否过期,过期就删掉并返回null。hitCount这个命中次数字段先埋在这里,后面做淘汰策略和统计时会用到。我在实际项目里就是拿这个版本起步的,后面逐步加了自动清理、LRU淘汰、命中统计,慢慢长成了一个小而稳的工具,用了大半年没出过问题。注意:Map的键是严格相等的。key = 1和key = '1'会被当成两个不同的缓存条目,使用的时候千万不要混着来。2.2 带TTL的过期机制与自动清理缓存必须有过期,没有过期时间的缓存就是定时炸弹。TTL怎么定,算是个经验活。我常用的默认值是5分钟。5分钟内,普通下拉数据基本不会变,够用。如果业务对数据新鲜度要求高,比如库存数量、实时价格,TTL要缩到30秒甚至更短;如果数据几乎不变,比如行政区划列表,TTL放到1小时也没问题。过期检查有两种做派:惰性删除和定时清理。上面代码用的是惰性删除,只在get的时候判断过期。这种方案实现简单,但有个隐患:如果一个键一直没被访问,它会一直躺在内存里,直到过期后偶然被访问到才清除。长期运行的单页应用里,这种“僵尸缓存”积累多了,内存还是会缓慢上升。所以我后来给工具类加了一个定时清理器:class DropdownCache { // 上面的基础方法省略 startAutoClean(interval = 60 * 1000) { this.timer = setInterval(() => { const now = Date.now(); for (const [key, item] of this.cache) { if (now > item.expireAt) { this.cache.delete(key); } } }, interval); if (this.timer.unref) this.timer.unref(); } stopAutoClean() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } }清理间隔一分钟一次就够了。太频繁反而消耗性能。我曾经把间隔调成5秒,结果在选项特别多的页面上,清理过程本身成了负担,页面滚动都开始掉帧。改成30秒后,效果反而更好。这个教训说明,定时任务不是越勤越好,要结合业务的真实访问频率来定。2.3 命中率统计:缓存好不好,数据说了算前面埋了hitCount字段,这会派上用场。我建议在缓存类里加两个统计入口:class DropdownCache { // ... stats() { let totalHit = 0; let totalExpired = 0; this.cache.forEach((item) => { totalHit += item.hitCount; if (item.expireAt < Date.now()) totalExpired++; }); return { size: this.cache.size, totalHit, totalExpired, }; } }这个stats方法在开发环境里特别有用。我把几类下拉缓存实例都挂在一个全局调试对象上,打开控制台就能看到当前缓存的条目数、命中次数、过期次数。如果某个缓存的命中率长期低于30%,说明键设计很可能有问题,或者TTL太短导致缓存形同虚设。顺带说一个我自己遇到的例子。某次做人员选择下拉,我以为缓存没生效,一查统计才发现,命中率其实很高,只是因为用户每次都选不同的人,缓存里的旧结果没被展示而已。这种“缓存正确但业务逻辑不匹配”的情况,单靠肉眼很难看出来,统计面板反而比任何性能工具更快暴露问题。3. 关键细节:键设计、淘汰策略、数据一致性工具类写完,真正决定方案上限的是细节。这一章说的三块内容,全是我在实际项目里踩过坑之后才沉淀下来的经验。3.1 缓存键的设计规范和冲突避免缓存键是整个方案里最容易各自为政的地方。有人用接口路径当键,有人用接口路径加参数,有人用业务描述当键,结果同一个下拉数据在系统里存了三四份,互相不命中,缓存等于白做。我采用的规则是:方法名加版本加参数摘要。比如:const key = `getSubCategory:v1:${parentId}`;版本号很重要。业务方升级排序规则、增减字段之后,版本号一变,所有旧缓存自动失效,不用手动清。我在一个实际项目里遇到过这样一件事:后端改了列表排序规则,但前端缓存没失效,用户看到的还是旧顺序。产品以为后端没改对,前后端两个团队排了一下午,最后才发现是缓存里存了旧数据。参数多的时候,必须用稳定的序列化方式拼接。这里说的“稳定”,是指同一个参数组合无论调用多少次,生成的键都完全一致:function serializeParams(params) { if (!params) return ''; return Object.keys(params) .sort() .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); } const key = `searchUser:v1:${serializeParams({ keyword: '张', page: 1 })}`;这里有两个细节:第一,参数要排序,避免因为JSON字段顺序不同导致同一个请求生成两个键;第二,值要做encodeURIComponent,不然中英文和特殊字符会把键搞乱。3.2 数据一致性:缓存了之后怎么失效缓存的最大敌人就是数据更新。最常见的不一致场景是:后台改了一条选项的展示名称,用户端却还显示着旧名称。解决思路有三层,我习惯一层一层叠:第一层,业务操作后主动失效。修改、删除、离职等操作发生后,主动调用缓存实例的delete方法删掉相关键。这是最基本的做法,也是最容易漏的。第二层,后端返回版本戳。如果后端支持返回数据版本号,把版本号也一并记录到缓存里。打开下拉前先拉一个轻量的版本接口,比对一下版本号,变了就重新拉主数据。第三层,定时刷新。结合TTL,让下拉数据每天、或者每小时自动过期一次。我最推荐第一层加第三层的组合。业务操作时主动清缓存,能保证修改后立刻生效;TTL自动过期兜底,能防止“漏删”造成的长期脏数据。不少团队只做了第二层,但后端改造工作量往往比想象中大,如果前端能先靠主动失效加TTL解决大部分问题,就没必要一开始就推全局版本戳。3.3 LRU淘汰策略:缓存不能无限涨内存缓存有个先天问题:不控制体量,缓存条目就会滚雪球。几万条物料数据,每条可能占几百KB,几十个键就能吃掉不小的内存。LRU(Least Recently Used,最近最少使用)是最常用的淘汰策略。核心思路是:很久没用的缓存条目优先清掉。实现有两种常见做法,链表法适合极端性能要求,但代码复杂;利用Map的迭代顺序做LRU则非常简单,性能也完全够用。思路是利用Map会记住插入顺序的特性。命中缓存时,把这条记录删除再重新插入,它就自然跑到了队尾;set新数据时如果容量满了,就把队伍最前面的删掉,因为最前面的一定是最久没被用过的。class LRUDropdownCache { constructor({ max = 100, ttl = 5 * 60 * 1000 } = {}) { this.cache = new Map(); this.max = max; this.ttl = ttl; } get(key) { const item = this.cache.get(key); if (!item) return null; if (Date.now() > item.expireAt) { this.cache.delete(key); return null; } // 删除再插入,让这条记录到队尾 this.cache.delete(key); this.cache.set(key, item); return item.data; } set(key, data) { if (this.cache.has(key)) { this.cache.delete(key); } this.cache.set(key, { data, expireAt: Date.now() + this.ttl, }); // 超出容量,删掉最早的记录 if (this.cache.size > this.max) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } } }这个实现利用了Map的有序迭代特性,整体复杂度是O(1),不需要引入额外的双向链表结构。我在一个公用组件库里直接用了这套代码,跑了大半年,没出过问题。max容量的设置很关键。太小,缓存命中率低;太大,浪费内存。对普通下拉场景,我给出的经验值是单实例最多存100条左右。注意,这里说的是缓存条目数,不是数据总条数。比如一个关键词对应一页结果,一页结果算一条缓存,100条就意味着可以覆盖100个不同关键词的查询结果,对正常用户操作来说已经非常宽裕了。4. 实战接入:从工具类到业务组件工具类写好了,怎么自然接入业务,才是决定这套方案能不能落地的关键。这里说几个我在业务里最常用的接入方式。4.1 接口层统一封装,让缓存对业务透明我最推荐的接入方式,不是到处在组件里调cache.get和cache.set,而是在接口层封装一个带缓存的请求方法。业务组件完全感知不到缓存的存在,将来要把缓存方案换掉,也只动接口层,不用动任何业务代码。以搜索用户下拉为例,原始写法是这样:async function searchUser(keyword) { return request('/api/user/search', { keyword }); }加了缓存之后,变成这样:const userCache = new LRUDropdownCache({ max: 50, ttl: 3 * 60 * 1000 }); async function searchUser(keyword) { if (!keyword) return []; const key = `searchUser:v1:${keyword}`; const cachedData = userCache.get(key); if (cachedData) return cachedData; const data = await request('/api/user/search', { keyword }); userCache.set(key, data); return data; }业务组件里调用searchUser的方式完全没变,下拉框只需要关心自己的UI状态。这个封装的隐藏好处是,将来团队决定换掉LRU库或者调整淘汰策略,所有改动都收敛在一个文件里,不会波及十几处业务调用。4.2 级联下拉的缓存怎么组合级联场景比普通搜索稍微复杂一些,因为父级变化会带动子级变化。缓存的键必须带上父级ID,同时还要处理父级选项本身变化的问题。我的做法是给每个层级单独建一个缓存实例,父级缓存和子级缓存严格分开。拿省市区联动举例:const provinceCache = new LRUDropdownCache({ max: 20, ttl: 30 * 60 * 1000 }); const cityCache = new LRUDropdownCache({ max: 100, ttl: 30 * 60 * 1000 }); async function getCities(provinceId) { if (!provinceId) return []; const key = `getCities:v1:${provinceId}`; const cachedData = cityCache.get(key); if (cachedData) return cachedData; const data = await request('/api/cities', { provinceId }); cityCache.set(key, data); return data; }不同层级用不同缓存实例的好处有三个。第一,每个实例可以设置不同的容量上限和过期时间,比如省份数量少,20条容量足够;城市数量多,就放宽到100条。第二,父级变化时可以只清理子级的相关项,而不是把整个缓存全部清空,这样可以最大限度保留其他正常缓存。第三,统计命中率时能分维度看到底是哪个层级的缓存设计有问题。4.3 请求竞态与并发合并下拉框还有一个很容易被忽略的问题:请求竞态。用户快速输入时,连续触发多个请求,后发起的请求可能比先发起的更早返回,页面上最终展示的结果就很可能跟当前输入不匹配。缓存能把已经查过的结果直接返回,降低竞态概率,但第一个未缓存的请求该怎么做,还是需要处理。我采用了一个请求中的Map方案,同一个键在请求未完成时,后续的调用直接复用同一个Promise:const pendingMap = new Map(); async function fetchWithCache(key, fetcher) { if (pendingMap.has(key)) { return pendingMap.get(key); } const promise = fetcher() .then(data => { cache.set(key, data); pendingMap.delete(key); return data; }) .catch(err => { pendingMap.delete(key); throw err; }); pendingMap.set(key, promise); return promise; }这样既避免了同一关键词的重复请求,也规避了响应乱序造成的展示错位。配合AbortController在组件卸载时取消未完成的请求,还能进一步避免组件销毁后回调更新DOM引发的告警和状态泄漏。这里有一个我要特别提醒的点:竞态处理不能只靠简单的“丢弃旧结果”逻辑,因为用户下一次输入可能又触发了同一个关键词,如果没有pendingMap复用,还是会形成重复请求。pendingMap本质上是在缓存与后端之间加了一层短路保护,属于缓存体系的一部分。4.4 组件卸载时到底要不要清缓存这个问题我经常被人问:组件卸载的时候要不要清缓存?我的答案很明确:不要清。缓存的生命周期不应该由某个组件的挂载和卸载决定,而应该由页面会话或业务状态决定。组件卸载后,另一处可能需要用到同一份数据,这时候清掉反而白费了一次缓存。真正需要清理的情况只有两种:第一种是页面进入销毁流程,比如路由跳走后不会回来;第二种是内存告急。第二种在实际前端业务里很罕见,第一种可以放在路由级别做一次统一重置。我在一个项目里曾经在组件的onUnmounted里执行了cache.clear(),导致来回切换Tab时,每次都重新请求接口,后端访问量直接翻了一倍。后来把清理逻辑挪到路由级,只在退出大模块时清一次,问题才解决。5. 踩坑记录与问题排查速查表这一部分是我最想分享的。缓存这种东西,不出问题岁月静好,一出问题就要人老命。下面这些坑都是我一遍遍趟出来的。5.1 缓存了但一直不命中:键值不一致排查缓存命中率低的第一步,就是打印所有缓存的键,看看跟get时候的键是不是对得上。最常见的两个原因,一是后端返回的keyword里带着空格,前端没做trim,等到set和get的时候键就不一样;二是同一个页面里用了两种不同的参数序列化方式,一个地方用JSON.stringify,另一个地方自己拼字符串。解决办法很粗暴:在请求前统一清洗参数,比如keyword.trim(),并且全项目统一用一种序列化方法。我在团队里干脆把它抽成公共函数,放进一个util文件,并且约定业务代码里不允许各自造轮子。5.2 缓存数据是脏的:过期了还在用这个现象排查起来比较费劲。说一个真实案例:某次后端做了批量数据修正,一批用户的名称变了,但前端缓存把旧名称记下来了。用户点开下拉框,看到的完全是旧数据。排查路径我总结了四步:先看业务操作有没有触发缓存主动失效。再看TTL是不是配得太长,把当天更新的数据一直扛着不换。然后看版本号有没有变。最后,如果都没问题,就要怀疑前端静态资源本身没更新,这跟缓存无关,但现象和缓存问题几乎一样。排查这类问题还有一个经验:先清一次缓存再试一次,如果问题消失,基本可以锁定就是缓存造成的。这个动作简单有效,比一大堆分析都直接。5.3 内存一直涨:僵尸缓存页面跑的时间长了内存往上涨,大概率是缓存没做淘汰。最简单的排查办法,是在控制台里看一下当前缓存实例的stats,到底有多少个条目,每份数据大概多大。如果几十条就吃掉了几百MB,说明这份数据不适合全量缓存,需要加一个“只缓存当前可见结果”的裁剪策略。还有一点,get返回的如果是对象引用,组件里改了对象内容,等于直接改了缓存里的数据,下次取出来就是被修改过的版本。为避免这种隐性问题,缓存里存的数据尽量保持只读状态,业务侧不要直接操作返回的引用。5.4 常见问题速查表现象可能原因解决办法命中率极低键设计不一致 / TTL过短统一序列化方法,延长TTL数据显示旧值后端更新后缓存未失效主动失效 + 版本号内存持续上涨没有淘汰策略 / 数据量太大引入LRU淘汰,设置容量上限同参数请求重复发送用户快速输入加pendingMap合并并发请求结果乱序展示并发请求未处理配合AbortController取消旧请求刷新后缓存丢失内存缓存天然局限按需引入localStorage持久化改了缓存对象导致脏数据返回了对象引用缓存数据保持只读这七类问题基本覆盖了我这几年遇到的下拉缓存故障。有些问题不是一次就会出现,而是数据量达到一定规模后才突然爆发,所以排查工具和统计能力一定要提前搭好,别等问题来了再临时抱佛脚。我个人在实际操作里最深的一点体会是:下拉数据缓存策略最难的地方,不是写一个能用的缓存类,而是想清楚什么时候绝对不能使用缓存。数据一致性要求高、实时性要求强、用户容错率低的场景,宁愿多发几次请求,也别让一份旧数据坑了用户。这就像给系统装了一个阀门,该开的时候开,该关的时候要果断关,不然水压倒是稳了,水质却出了问题。最后再分享一个实战小技巧:给所有下拉缓存统一加一个调试开关,在开发环境里按一个快捷键就能一键清空所有下拉缓存,顺带打印当前的缓存统计信息。这个开关在联调阶段帮了我无数次。每次后端说“数据改了,前端怎么没生效”,我按下快捷键立刻就能确认到底是前端缓存问题,还是后端确实没改对。等你把这套体系搭起来,再回头看看之前那种点开就转圈、切换页面就重新加载的下拉框,那种从根上解决问题的踏实感,是真的会上瘾。
做前端时间长了,你会发现下拉框这个东西看起来不起眼,但却是最容易翻车的地方。尤其是带远程搜索的下拉框,用户每敲一个关键词,后端就重新查一遍,下拉列表就重新渲染一遍。看起来没啥,但在低端手机上,配合上输入法回弹、候选词高频变化、重复请求乱序返回,体验能直接掉到及格线以下。前端下拉数据缓存策略,简单说,就是怎么把已经查过的下拉数据存下来,让第二次、第三次打开时不再白白发请求,而是直接走本地缓存。我第一次认真做这类缓存,是在一个库存管理系统的物料选择器上。那个下拉框绑定了上万条物料,每次打开都要请求一次接口,接口响应本身不算慢,但配合上模糊搜索,用户每输入一个字符就触发一次请求,后端扛得住,前端的数据流却乱成一团。后来加了缓存,页面切换再回来,数据几乎秒开,体验提升肉眼可见。这篇内容适合所有跟表单、搜索、联动下拉打过交道的同学。不管你是刚入门的前端,还是已经在写业务代码但没系统整理过缓存方案的开发者,下面这份从选型到实现、再到踩坑的记录,都能直接拿来用。1. 选型之前,先想清楚这三个问题缓存方案不是越复杂越好,动手之前必须先想清楚几个问题:你的下拉是什么场景,缓存放在哪一层,缓存多久该失效。这三件事没想清楚,代码写多少都是白搭。1.1 你的下拉场景属于哪一种下拉框虽然长得都差不多,但数据来源和交互模式有本质区别。我一般把它分成三类:静态下拉:数据几乎不变,比如性别、学历、省份这类枚举项。最简单的方案是把数据直接打进前端配置表,连请求都不发。远程搜索下拉:数据量很大,必须按关键词去后端查,比如商品搜索、人员选择、地址联想。这类场景是缓存策略的主力对象。级联联动下拉:选择省后加载城市,选择分类后加载子分类。每一层的数据都可以缓存,而且父级ID一变,子级数据重复请求的问题特别明显。不同场景的缓存策略完全不同。静态下拉根本不需要缓存工具,初始化时加载一次就行;远程搜索下拉要缓存的是“关键词到结果集”的映射;级联下拉缓存的是“父级ID到子级列表”的映射。把这三件事分开设计,会比一个万能缓存方案干净得多。我见过不少同学一上来就套现成的缓存库,结果自己的场景其实只需要一个简单的Map就能搞定,反而把简单问题搞复杂了。所以动手前,先拿张纸列一下自己页面里到底有哪几类下拉,再逐一对应策略,这个步骤省不得。1.2 缓存放哪里:内存还是持久化这是第一个真正的分水岭。内存缓存(用Map或者对象直接存)读取极快,几乎零延迟,缺点是刷新页面就没了,而且页面运行期间会一直占着内存。持久化缓存(localStorage、sessionStorage)能跨刷新、跨页面存活,但读写是同步IO,数据一多就要做序列化和反序列化,甚至比重新请求一次接口还慢。我实测过一个有几万条选项的物料下拉框,localStorage存JSON再解析回来,耗时大概在20到50毫秒。看起来不高,但在快速键盘输入时会明显感觉到有一点顿,而内存缓存基本是0.5毫秒以内,差距不是一个量级。所以我的默认建议是:内存缓存为主,持久化缓存为辅。同一个页面的会话内复用,优先内存;真需要跨会话保存的场景,再单独把持久化方案加上。很多下拉数据对时效性要求并不高,能扛住一次会话就够了,刷新后重新拉一次未必是坏事。1.3 判断标准与取舍几个判断标准,我每次做选型都会过一遍:数据量:几百条还是几万条?数据量大,内存里也必须做淘汰,不能无脑存。变化频率:选项多久变一次?一天变一次和一分钟变一次,TTL的配置完全不同。时效敏感度:用户选错选项的代价高不高?比如选错药品、选错库存批次,这类场景宁可少缓存,也不要拿旧数据误导用户。我习惯把这几个维度整理成一张对比表:维度内存缓存localStorage混合方案读取速度极快需要序列化快跨页面恢复不支持支持视实现而定容量控制手动做淘汰约5MB上限可控适合场景会话内频繁切换低频、跨会话复用复杂业务很多团队一上来就上localStorage,其实是被“数据丢了要重新拉”这个想法吓到了。但下拉数据本质上就是一个查询结果的快照,丢了重新拉一次花费也不高。想明白这一点,方案就简单多了。2. 手写一个可以直接用的缓存工具类选型想清楚后,直接上一份能用的代码。这个工具类核心就四件事:存、取、删、过期。我会把演进过程也讲一遍,方便你理解每一步的取舍。2.1 基础结构:用Map而不是普通对象很多同学喜欢用普通对象当缓存:const cache = {}; cache[key] = data;这在简单场景没问题,但对象有原型链,键名可能会和内置属性冲突,而且没有内置的顺序概念。Map就舒服很多,有size、delete、keys、values等方法,后面做淘汰策略时特别顺手。下面是一个基础版本的实现:class DropdownCache { constructor(options = {}) { this.cache = new Map(); this.ttl = options.ttl || 5 * 60 * 1000; // 默认过期时间5分钟 } set(key, value) { this.cache.set(key, { data: value, expireAt: Date.now() + this.ttl, hitCount: 0, }); } get(key) { const item = this.cache.get(key); if (!item) return null; if (Date.now() > item.expireAt) { this.cache.delete(key); return null; } item.hitCount++; return item.data; } has(key) { const item = this.cache.get(key); if (!item) return false; if (Date.now() > item.expireAt) { this.cache.delete(key); return false; } return true; } delete(key) { this.cache.delete(key); } clear() { this.cache.clear(); } }代码看起来简朴,但核心逻辑已经全在了。set的时候记录过期时间,get的时候判断是否过期,过期就删掉并返回null。hitCount这个命中次数字段先埋在这里,后面做淘汰策略和统计时会用到。我在实际项目里就是拿这个版本起步的,后面逐步加了自动清理、LRU淘汰、命中统计,慢慢长成了一个小而稳的工具,用了大半年没出过问题。注意:Map的键是严格相等的。key = 1和key = '1'会被当成两个不同的缓存条目,使用的时候千万不要混着来。2.2 带TTL的过期机制与自动清理缓存必须有过期,没有过期时间的缓存就是定时炸弹。TTL怎么定,算是个经验活。我常用的默认值是5分钟。5分钟内,普通下拉数据基本不会变,够用。如果业务对数据新鲜度要求高,比如库存数量、实时价格,TTL要缩到30秒甚至更短;如果数据几乎不变,比如行政区划列表,TTL放到1小时也没问题。过期检查有两种做派:惰性删除和定时清理。上面代码用的是惰性删除,只在get的时候判断过期。这种方案实现简单,但有个隐患:如果一个键一直没被访问,它会一直躺在内存里,直到过期后偶然被访问到才清除。长期运行的单页应用里,这种“僵尸缓存”积累多了,内存还是会缓慢上升。所以我后来给工具类加了一个定时清理器:class DropdownCache { // 上面的基础方法省略 startAutoClean(interval = 60 * 1000) { this.timer = setInterval(() => { const now = Date.now(); for (const [key, item] of this.cache) { if (now > item.expireAt) { this.cache.delete(key); } } }, interval); if (this.timer.unref) this.timer.unref(); } stopAutoClean() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } }清理间隔一分钟一次就够了。太频繁反而消耗性能。我曾经把间隔调成5秒,结果在选项特别多的页面上,清理过程本身成了负担,页面滚动都开始掉帧。改成30秒后,效果反而更好。这个教训说明,定时任务不是越勤越好,要结合业务的真实访问频率来定。2.3 命中率统计:缓存好不好,数据说了算前面埋了hitCount字段,这会派上用场。我建议在缓存类里加两个统计入口:class DropdownCache { // ... stats() { let totalHit = 0; let totalExpired = 0; this.cache.forEach((item) => { totalHit += item.hitCount; if (item.expireAt < Date.now()) totalExpired++; }); return { size: this.cache.size, totalHit, totalExpired, }; } }这个stats方法在开发环境里特别有用。我把几类下拉缓存实例都挂在一个全局调试对象上,打开控制台就能看到当前缓存的条目数、命中次数、过期次数。如果某个缓存的命中率长期低于30%,说明键设计很可能有问题,或者TTL太短导致缓存形同虚设。顺带说一个我自己遇到的例子。某次做人员选择下拉,我以为缓存没生效,一查统计才发现,命中率其实很高,只是因为用户每次都选不同的人,缓存里的旧结果没被展示而已。这种“缓存正确但业务逻辑不匹配”的情况,单靠肉眼很难看出来,统计面板反而比任何性能工具更快暴露问题。3. 关键细节:键设计、淘汰策略、数据一致性工具类写完,真正决定方案上限的是细节。这一章说的三块内容,全是我在实际项目里踩过坑之后才沉淀下来的经验。3.1 缓存键的设计规范和冲突避免缓存键是整个方案里最容易各自为政的地方。有人用接口路径当键,有人用接口路径加参数,有人用业务描述当键,结果同一个下拉数据在系统里存了三四份,互相不命中,缓存等于白做。我采用的规则是:方法名加版本加参数摘要。比如:const key = `getSubCategory:v1:${parentId}`;版本号很重要。业务方升级排序规则、增减字段之后,版本号一变,所有旧缓存自动失效,不用手动清。我在一个实际项目里遇到过这样一件事:后端改了列表排序规则,但前端缓存没失效,用户看到的还是旧顺序。产品以为后端没改对,前后端两个团队排了一下午,最后才发现是缓存里存了旧数据。参数多的时候,必须用稳定的序列化方式拼接。这里说的“稳定”,是指同一个参数组合无论调用多少次,生成的键都完全一致:function serializeParams(params) { if (!params) return ''; return Object.keys(params) .sort() .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); } const key = `searchUser:v1:${serializeParams({ keyword: '张', page: 1 })}`;这里有两个细节:第一,参数要排序,避免因为JSON字段顺序不同导致同一个请求生成两个键;第二,值要做encodeURIComponent,不然中英文和特殊字符会把键搞乱。3.2 数据一致性:缓存了之后怎么失效缓存的最大敌人就是数据更新。最常见的不一致场景是:后台改了一条选项的展示名称,用户端却还显示着旧名称。解决思路有三层,我习惯一层一层叠:第一层,业务操作后主动失效。修改、删除、离职等操作发生后,主动调用缓存实例的delete方法删掉相关键。这是最基本的做法,也是最容易漏的。第二层,后端返回版本戳。如果后端支持返回数据版本号,把版本号也一并记录到缓存里。打开下拉前先拉一个轻量的版本接口,比对一下版本号,变了就重新拉主数据。第三层,定时刷新。结合TTL,让下拉数据每天、或者每小时自动过期一次。我最推荐第一层加第三层的组合。业务操作时主动清缓存,能保证修改后立刻生效;TTL自动过期兜底,能防止“漏删”造成的长期脏数据。不少团队只做了第二层,但后端改造工作量往往比想象中大,如果前端能先靠主动失效加TTL解决大部分问题,就没必要一开始就推全局版本戳。3.3 LRU淘汰策略:缓存不能无限涨内存缓存有个先天问题:不控制体量,缓存条目就会滚雪球。几万条物料数据,每条可能占几百KB,几十个键就能吃掉不小的内存。LRU(Least Recently Used,最近最少使用)是最常用的淘汰策略。核心思路是:很久没用的缓存条目优先清掉。实现有两种常见做法,链表法适合极端性能要求,但代码复杂;利用Map的迭代顺序做LRU则非常简单,性能也完全够用。思路是利用Map会记住插入顺序的特性。命中缓存时,把这条记录删除再重新插入,它就自然跑到了队尾;set新数据时如果容量满了,就把队伍最前面的删掉,因为最前面的一定是最久没被用过的。class LRUDropdownCache { constructor({ max = 100, ttl = 5 * 60 * 1000 } = {}) { this.cache = new Map(); this.max = max; this.ttl = ttl; } get(key) { const item = this.cache.get(key); if (!item) return null; if (Date.now() > item.expireAt) { this.cache.delete(key); return null; } // 删除再插入,让这条记录到队尾 this.cache.delete(key); this.cache.set(key, item); return item.data; } set(key, data) { if (this.cache.has(key)) { this.cache.delete(key); } this.cache.set(key, { data, expireAt: Date.now() + this.ttl, }); // 超出容量,删掉最早的记录 if (this.cache.size > this.max) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } } }这个实现利用了Map的有序迭代特性,整体复杂度是O(1),不需要引入额外的双向链表结构。我在一个公用组件库里直接用了这套代码,跑了大半年,没出过问题。max容量的设置很关键。太小,缓存命中率低;太大,浪费内存。对普通下拉场景,我给出的经验值是单实例最多存100条左右。注意,这里说的是缓存条目数,不是数据总条数。比如一个关键词对应一页结果,一页结果算一条缓存,100条就意味着可以覆盖100个不同关键词的查询结果,对正常用户操作来说已经非常宽裕了。4. 实战接入:从工具类到业务组件工具类写好了,怎么自然接入业务,才是决定这套方案能不能落地的关键。这里说几个我在业务里最常用的接入方式。4.1 接口层统一封装,让缓存对业务透明我最推荐的接入方式,不是到处在组件里调cache.get和cache.set,而是在接口层封装一个带缓存的请求方法。业务组件完全感知不到缓存的存在,将来要把缓存方案换掉,也只动接口层,不用动任何业务代码。以搜索用户下拉为例,原始写法是这样:async function searchUser(keyword) { return request('/api/user/search', { keyword }); }加了缓存之后,变成这样:const userCache = new LRUDropdownCache({ max: 50, ttl: 3 * 60 * 1000 }); async function searchUser(keyword) { if (!keyword) return []; const key = `searchUser:v1:${keyword}`; const cachedData = userCache.get(key); if (cachedData) return cachedData; const data = await request('/api/user/search', { keyword }); userCache.set(key, data); return data; }业务组件里调用searchUser的方式完全没变,下拉框只需要关心自己的UI状态。这个封装的隐藏好处是,将来团队决定换掉LRU库或者调整淘汰策略,所有改动都收敛在一个文件里,不会波及十几处业务调用。4.2 级联下拉的缓存怎么组合级联场景比普通搜索稍微复杂一些,因为父级变化会带动子级变化。缓存的键必须带上父级ID,同时还要处理父级选项本身变化的问题。我的做法是给每个层级单独建一个缓存实例,父级缓存和子级缓存严格分开。拿省市区联动举例:const provinceCache = new LRUDropdownCache({ max: 20, ttl: 30 * 60 * 1000 }); const cityCache = new LRUDropdownCache({ max: 100, ttl: 30 * 60 * 1000 }); async function getCities(provinceId) { if (!provinceId) return []; const key = `getCities:v1:${provinceId}`; const cachedData = cityCache.get(key); if (cachedData) return cachedData; const data = await request('/api/cities', { provinceId }); cityCache.set(key, data); return data; }不同层级用不同缓存实例的好处有三个。第一,每个实例可以设置不同的容量上限和过期时间,比如省份数量少,20条容量足够;城市数量多,就放宽到100条。第二,父级变化时可以只清理子级的相关项,而不是把整个缓存全部清空,这样可以最大限度保留其他正常缓存。第三,统计命中率时能分维度看到底是哪个层级的缓存设计有问题。4.3 请求竞态与并发合并下拉框还有一个很容易被忽略的问题:请求竞态。用户快速输入时,连续触发多个请求,后发起的请求可能比先发起的更早返回,页面上最终展示的结果就很可能跟当前输入不匹配。缓存能把已经查过的结果直接返回,降低竞态概率,但第一个未缓存的请求该怎么做,还是需要处理。我采用了一个请求中的Map方案,同一个键在请求未完成时,后续的调用直接复用同一个Promise:const pendingMap = new Map(); async function fetchWithCache(key, fetcher) { if (pendingMap.has(key)) { return pendingMap.get(key); } const promise = fetcher() .then(data => { cache.set(key, data); pendingMap.delete(key); return data; }) .catch(err => { pendingMap.delete(key); throw err; }); pendingMap.set(key, promise); return promise; }这样既避免了同一关键词的重复请求,也规避了响应乱序造成的展示错位。配合AbortController在组件卸载时取消未完成的请求,还能进一步避免组件销毁后回调更新DOM引发的告警和状态泄漏。这里有一个我要特别提醒的点:竞态处理不能只靠简单的“丢弃旧结果”逻辑,因为用户下一次输入可能又触发了同一个关键词,如果没有pendingMap复用,还是会形成重复请求。pendingMap本质上是在缓存与后端之间加了一层短路保护,属于缓存体系的一部分。4.4 组件卸载时到底要不要清缓存这个问题我经常被人问:组件卸载的时候要不要清缓存?我的答案很明确:不要清。缓存的生命周期不应该由某个组件的挂载和卸载决定,而应该由页面会话或业务状态决定。组件卸载后,另一处可能需要用到同一份数据,这时候清掉反而白费了一次缓存。真正需要清理的情况只有两种:第一种是页面进入销毁流程,比如路由跳走后不会回来;第二种是内存告急。第二种在实际前端业务里很罕见,第一种可以放在路由级别做一次统一重置。我在一个项目里曾经在组件的onUnmounted里执行了cache.clear(),导致来回切换Tab时,每次都重新请求接口,后端访问量直接翻了一倍。后来把清理逻辑挪到路由级,只在退出大模块时清一次,问题才解决。5. 踩坑记录与问题排查速查表这一部分是我最想分享的。缓存这种东西,不出问题岁月静好,一出问题就要人老命。下面这些坑都是我一遍遍趟出来的。5.1 缓存了但一直不命中:键值不一致排查缓存命中率低的第一步,就是打印所有缓存的键,看看跟get时候的键是不是对得上。最常见的两个原因,一是后端返回的keyword里带着空格,前端没做trim,等到set和get的时候键就不一样;二是同一个页面里用了两种不同的参数序列化方式,一个地方用JSON.stringify,另一个地方自己拼字符串。解决办法很粗暴:在请求前统一清洗参数,比如keyword.trim(),并且全项目统一用一种序列化方法。我在团队里干脆把它抽成公共函数,放进一个util文件,并且约定业务代码里不允许各自造轮子。5.2 缓存数据是脏的:过期了还在用这个现象排查起来比较费劲。说一个真实案例:某次后端做了批量数据修正,一批用户的名称变了,但前端缓存把旧名称记下来了。用户点开下拉框,看到的完全是旧数据。排查路径我总结了四步:先看业务操作有没有触发缓存主动失效。再看TTL是不是配得太长,把当天更新的数据一直扛着不换。然后看版本号有没有变。最后,如果都没问题,就要怀疑前端静态资源本身没更新,这跟缓存无关,但现象和缓存问题几乎一样。排查这类问题还有一个经验:先清一次缓存再试一次,如果问题消失,基本可以锁定就是缓存造成的。这个动作简单有效,比一大堆分析都直接。5.3 内存一直涨:僵尸缓存页面跑的时间长了内存往上涨,大概率是缓存没做淘汰。最简单的排查办法,是在控制台里看一下当前缓存实例的stats,到底有多少个条目,每份数据大概多大。如果几十条就吃掉了几百MB,说明这份数据不适合全量缓存,需要加一个“只缓存当前可见结果”的裁剪策略。还有一点,get返回的如果是对象引用,组件里改了对象内容,等于直接改了缓存里的数据,下次取出来就是被修改过的版本。为避免这种隐性问题,缓存里存的数据尽量保持只读状态,业务侧不要直接操作返回的引用。5.4 常见问题速查表现象可能原因解决办法命中率极低键设计不一致 / TTL过短统一序列化方法,延长TTL数据显示旧值后端更新后缓存未失效主动失效 + 版本号内存持续上涨没有淘汰策略 / 数据量太大引入LRU淘汰,设置容量上限同参数请求重复发送用户快速输入加pendingMap合并并发请求结果乱序展示并发请求未处理配合AbortController取消旧请求刷新后缓存丢失内存缓存天然局限按需引入localStorage持久化改了缓存对象导致脏数据返回了对象引用缓存数据保持只读这七类问题基本覆盖了我这几年遇到的下拉缓存故障。有些问题不是一次就会出现,而是数据量达到一定规模后才突然爆发,所以排查工具和统计能力一定要提前搭好,别等问题来了再临时抱佛脚。我个人在实际操作里最深的一点体会是:下拉数据缓存策略最难的地方,不是写一个能用的缓存类,而是想清楚什么时候绝对不能使用缓存。数据一致性要求高、实时性要求强、用户容错率低的场景,宁愿多发几次请求,也别让一份旧数据坑了用户。这就像给系统装了一个阀门,该开的时候开,该关的时候要果断关,不然水压倒是稳了,水质却出了问题。最后再分享一个实战小技巧:给所有下拉缓存统一加一个调试开关,在开发环境里按一个快捷键就能一键清空所有下拉缓存,顺带打印当前的缓存统计信息。这个开关在联调阶段帮了我无数次。每次后端说“数据改了,前端怎么没生效”,我按下快捷键立刻就能确认到底是前端缓存问题,还是后端确实没改对。等你把这套体系搭起来,再回头看看之前那种点开就转圈、切换页面就重新加载的下拉框,那种从根上解决问题的踏实感,是真的会上瘾。