
1. 为什么 JavaScript 项目最终都要补上“缓存系统”这一课先讲一个我经历过的真实场景。某个项目上线新版本之后客服那边陆续收到反馈说用户打开页面看到的还是老样式强制刷新、退出重进都没用。第一反应是代码部署出问题了后端同事排查完说接口、静态资源都是新的运维也确认 CDN 回源正常。最后定位到问题出在 Service Worker旧版本的 Service Worker 还在接管页面请求而且缓存名称没有随版本更新导致页面始终从旧缓存里加载资源。那次之后我就意识到JavaScript 缓存系统不是一个“性能优化加分项”而是每个前端项目迟早要正面面对的基础设施。缓存系统的核心价值是“用空间换时间”把重复计算和重复请求的结果存起来让下一次访问更快、更省资源。但缓存的问题也很典型缓存帮你挡住真实数据后你很难判断用户看到的到底是新是旧缓存策略配错轻则功能异常重则事故。本文会围绕 JavaScript 项目里实际能落地的几层缓存展开浏览器自带的 HTTP 缓存、代码里的内存与本地存储缓存、Service Worker 离线缓存以及最容易被低估的缓存失效方案和问题排查链路。无论你是刚接触前端还是已经在生产环境里维护大型应用这些内容应该都能直接参考。2. 浏览器 HTTP 缓存不写代码就有的三层机制很多人以为做缓存必须写代码其实浏览器内置的 HTTP 缓存早就帮你挡掉了大量重复请求。理解这一层是后面所有缓存设计的基础。2.1 强缓存与协商缓存的优先级关系HTTP 缓存最核心的两个阶段是强缓存和协商缓存。强缓存阶段浏览器直接读本地副本不发请求协商缓存阶段浏览器带着条件请求头去问服务器“这个资源变了吗”服务器返回 304 就继续用本地副本返回 200 就换新的。控制强缓存的字段主要是Cache-Control和Expires。Expires是 HTTP/1.0 时代的绝对时间依赖客户端时间用户改了系统时间就会不准。Cache-Control是 HTTP/1.1 引入的相对时间用max-age指定从响应生成开始算起多少秒内有效两者同时存在时Cache-Control优先级更高。下面这张表列出了几个我在配置时常用的指令指令作用使用场景max-age31536000缓存一年带 hash 文件名的静态资源immutable缓存期间不需重新验证带 hash 文件名且内容永远不变的资源no-cache每次都要向服务器验证HTML 入口文件no-store完全不缓存含敏感信息的接口响应private只允许浏览器缓存用户私有数据public允许中间代理和 CDN 缓存公开静态资源协商缓存靠Last-Modified/If-Modified-Since和ETag/If-None-Match两对头实现。Last-Modified只能精确到秒如果同一秒内内容多次变化会漏判ETag是根据内容生成的标识精确度更高服务器看到If-None-Match和当前内容匹配就返回 304。我的习惯是优先用ETagLast-Modified作为兜底。2.2 最坑的启发式缓存什么都没配置反而更危险还有一个容易被忽略的机制叫启发式缓存。当响应头里既没有Cache-Control也没有Expires浏览器会根据Last-Modified推断一个缓存时间通常取 10% 的时间差。比如文件最后修改时间是 10 天前浏览器可能缓存 1 天。这意味着即使你什么都没配置浏览器也可能自作主张缓存资源等你看不到更新时还很困惑为什么没配缓存也会缓存我排查过不少“接口明明没配缓存但请求一直不刷新”的问题最后都是启发式缓存在起作用。解决方法很简单给所有响应都明确设置Cache-Control优先考虑no-cache或no-store不要留空。2.3 静态资源与 HTML 入口的差异化配置生产环境里我通常把 HTML 和静态资源分开配置。业务 JS、CSS、图片这类文件名里带内容 hash 的资源配Cache-Control: public, max-age31536000, immutable。因为文件名变了就是新资源不变就是同一份内容缓存一年也安全。HTML 入口文件配Cache-Control: no-cache让它每次都向服务器确认避免用户拿到旧 HTML 后引用旧资源链接。这里要注意一个常见的反向例子有人为了“保险”给所有资源都配了很短的max-age结果每次发布后用户确实能看到新文件但资源命中率下降加载性能反而变差。真正合理的做法是文件名 hash 决定永久缓存HTML 决定版本入口发布时用新 HTML 引新的 hash 文件。2.4 在 DevTools 里判断缓存命中情况排查缓存问题时打开 Chrome DevTools 的 Network 面板点一下某个请求看 Size 列就能知道它是从哪来的。from memory cache从内存缓存读取速度最快关闭页面就没了。from disk cache从磁盘缓存读取浏览器重启后依然存在。304 Not Modified走了协商缓存请求发出去了但服务器说可以用本地副本。正常的200真正的网络请求。我一般用这个办法快速判断“用户是不是拿到了旧资源”。如果本地 DevTools 打开时设置了 Disable cache看到的请求行为和生产环境完全不同这个坑后面会专门展开。3. 应用层缓存从内存到持久化的选型与封装浏览器 HTTP 缓存解决的是资源请求层面的重复加载但业务代码里还有大量需要自己管理的缓存某个计算结果、某个接口的 JSON、用户的偏好设置。这层缓存我们通常叫应用层缓存它按“读速度、容量、持久化程度”分成好几档。3.1 内存级缓存闭包、Map 与函数记忆化最快的缓存是内存缓存读数据是微秒级但页面一刷新就没了。实现方式很直接用一个闭包持有 Map 对象外部函数通过 get/set 操作它。function createMemoized(fn, options {}) { const cache new Map(); const { maxSize 100, ttl 0 } options; return function (...args) { const key JSON.stringify(args); if (cache.has(key)) { const item cache.get(key); if (ttl 0 Date.now() - item.timestamp ttl) { cache.delete(key); } else { return item.value; } } const value fn.apply(this, args); if (!cache.has(key) cache.size maxSize) { const firstKey cache.keys().next().value; cache.delete(firstKey); } cache.set(key, { value, timestamp: Date.now() }); return value; }; }这段代码做了最基础的过期和容量控制超过最大条数时删掉最早插入的 key。实际使用中我很少直接用这段裸逻辑一般会让缓存服务来统一管理但函数记忆化的思想很适合重计算场景比如解析大数据、处理正则、计算总数这类重复调用很多次的操作。内存缓存要注意一个细节key 如果直接用对象做索引JavaScript 会隐式调用toString()导致不同对象变成同一个 key。所以我习惯用JSON.stringify(args)生成 key但也要小心循环引用的对象会抛异常用的时候要包一层 try/catch或者只对可序列化的参数做缓存。3.2 Web Storage同步 API 的便捷与陷阱localStorage和sessionStorage都是同步 APIAPI 简单适合存储用户偏好、登录 token、表单草稿这类小体量数据。两者的区别很多资料都写过但落到实际选型时我的判断依据是“刷新之后这个数据还应不应该在”存储方式生命周期容量上限同标签页共享典型场景sessionStorage标签页关闭即清除约 5MB否表单草稿、多步填写状态localStorage永久保存需手动清除约 5MB同源下共享用户偏好、登录态Web Storage 最大的坑是只能存字符串。你以为存了个对象进去读出来发现是[object Object]存undefined和null读出来是字符串undefined、null。每次读写都要JSON.stringify和JSON.parse包一层而且JSON.parse一旦遇到非法 JSON 会直接抛异常白屏事故就是这么来的。所以封装 storage 工具时parse 必须包一层 try/catch让失败时返回 null 而不是抛错。另一个容易踩的坑是容量限制。5MB 看似不小但如果你把接口列表数据一整包塞进去高并发写入时可能直接抛QuotaExceededError。我有一次在低端安卓机上复现用户问题发现就是 try/catch 没接住导致整个同步流程中断。Web Storage 本质是同步阻塞 IO别用它保存大体积数据。3.3 IndexedDB大容量和离线场景的正确去处当一个缓存单元超过几 MB或者需要存储 Blob、File、视频片段时localStorage 就不够用了这时候应该选 IndexedDB。IndexedDB 是浏览器内置的非关系型数据库容量远大于 Web Storage支持索引、事务和异步 API并且不会阻塞主线程。不过 IndexedDB 原生 API 用起来相对繁琐。我建议直接用idb-keyval或localforage这类封装库简单场景用idb-keyval就够了它会自动把值序列化存进去import { get, set, del } from idb-keyval; await set(user-profile, { name: 张三, level: 5 }); const profile await get(user-profile);离线能力是这个方案的核心价值。比如移动端 H5 在弱网下打开先读 IndexedDB 里的缓存数据渲染页面后台再发请求拉取新数据这种做法在 PWA 和 WebView 应用里很常见。存储策略上IndexedDB 适合放“用户主动产生或需要长期保留的数据”不适合当万能垃圾桶因为清除浏览器缓存时 IndexedDB 默认不会被清掉一些过期数据会一直占着磁盘。3.4 封装带过期时间的存储工具越简单越可靠实际业务里我见过很多团队因为“不想重复造轮子”直接引了各种缓存库但最后项目还要自己再包一层。其实一个带过期时间的存储工具并不复杂核心就三件事读写、序列化、过期判断。下面是一个可以直接抄作业的版本const MemoryStore (() { const store new Map(); return { get(key) { const item store.get(key); if (!item) return null; if (item.expire ! 0 Date.now() item.expire) { store.delete(key); return null; } return item.value; }, set(key, value, ttl 0) { const expire ttl 0 ? Date.now() ttl : 0; store.set(key, { value, expire }); }, delete(key) { store.delete(key); }, clear() { store.clear(); }, has(key) { return this.get(key) ! null; } }; })();基于这个内存版再扩展一个 localStorage 持久化版写入时存JSON.stringify({ value, expire })读取时JSON.parse并检查过期时间。过期时间expire为 0 表示永不过期这个语义一定要在文档里写清楚否则同事很容易忘了传 ttl 导致缓存长期不失效。4. Service Worker 缓存把资源和离线的控制权握在自己手里HTTP 缓存和本地存储能解决大部分问题但都受限于“浏览器让你做什么你就做什么”。Service Worker简称 SW是真正由开发者掌控代理层的东西它位于浏览器和网络之间能拦截页面发出的所有请求由你决定是走网络、走缓存还是两者混合。4.1 SW 的生命周期和注册方式SW 有独立的生命周期注册后先 install然后 activate之后就能拦截 fetch 事件。注册时要注意脚本路径的 scope默认是脚本文件所在目录。一个常见错误是把它放在/static/sw.js结果它只能控制/static/路径下的页面。if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker .register(/sw.js) .then(() console.log(SW registered)) .catch((err) console.error(SW registration failed:, err)); }); }SW 必须运行在 HTTPS 环境中局域网 localhost 不算正式环境。我见过不少团队在本地开发时顺手注册了 SW结果后续调试时缓存总是旧内容反过来怪框架有问题。我的习惯是本地开发环境除非专门测 PWA否则不要开启 SW。4.2 三种主流缓存策略的适用场景SW 缓存策略业界已经有公认的分类我挑最常用的三种说一下权衡逻辑。Cache First 策略请求发出后先查缓存有就返回缓存没有才请求网络并写入缓存。这类策略适合不常变动的静态资源响应速度快离线也能访问但可能出现内容更新延迟。Network First 策略先请求网络成功就更新缓存网络失败才回退到缓存。这类策略适合接口数据、HTML 页面保证在线时永远拿最新内容弱网或离线时也能兜底。Stale While Revalidate 策略先返回缓存内容然后后台发起网络请求响应成功后更新缓存。用户感知到的首屏速度最快但页面展示的可能不是最新内容下一次访问才是新的。适合大图、头像这类“差几秒没关系”的资源。我把选择逻辑整理成一个简单判断资源是否可离线使用、更新频率高不高、用户体验对延迟是否敏感。没有银弹每种策略都是一次权衡。下面是一个简化版 fetch 拦截示例self.addEventListener(fetch, (event) { const { request } event; if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const copy response.clone(); caches.open(CACHE_NAME).then((cache) cache.put(request, copy)); return response; }) .catch(() caches.match(request)) ); } else { event.respondWith( caches.match(request).then((cached) { if (cached) return cached; return fetch(request).then((response) { const copy response.clone(); caches.open(CACHE_NAME).then((cache) cache.put(request, copy)); return response; }); }) ); } });这里有一个细节fetch 的响应 body 是流只能消费一次。所以往 cache 里写入前必须response.clone()否则返回给页面的 Response 已经是空 body页面拿到的可能不是预期数据。4.3 版本更新缓存名称必须进版本号SW 本身也是会被浏览器缓存的而且 SW 文件的更新有自己的规则默认情况下浏览器会每隔 24 小时检查一次但开发调试时几乎等不及。想让新 SW 尽快接管页面需要在 install 阶段调用skipWaiting()在 activate 阶段调用clients.claim()。真正的坑在于缓存存储的版本管理。我建议把缓存名称设计成cache-v1、cache-v2这种带版本号的形式每次项目发布时递增版本号并在 activate 阶段清理掉旧版本缓存。下面是核心逻辑const CACHE_VERSION v2; const CACHE_NAME app-${CACHE_VERSION}; const STATIC_ASSETS [ /, /index.html, /static/js/main.js, /static/css/main.css ]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME).then((cache) cache.addAll(STATIC_ASSETS)) ); self.skipWaiting(); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys .filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) ) ) ); self.clients.claim(); });如果你用的是 Workbox这些逻辑它已经替你封装了但理解底层原理有助于排查“为什么我更新了 SW 文件但页面还是旧资源”这类问题。这类问题通常不是因为 SW 文件没更新而是 activate 阶段没有清理旧缓存或者页面被旧 SW 控制住了一直用旧逻辑拦截新页面。5. 缓存失效与一致性设计比“写缓存”更考验功力很多人写缓存只关心怎么写不关心怎么失效。但缓存系统出问题十次有九次出在失效环节。缓存失效不是一个点而是一整套规则过期时间、版本号、清理策略、多端一致性。5.1 缓存 Key 的设计命名的坑比想象中多缓存 key 承担着“区分不同数据”的职责设计不好就会出现数据串号。我踩过一个真实案例某个用户列表接口的缓存 key 只用了 URL没有区分当前登录用户结果 A 用户登录后看到的是 B 用户缓存下来的数据。排查了很久才注意到“缓存污染”的元凶是 key 不完整。一个合格的缓存 key 至少要考虑资源路径、查询参数、当前用户身份、数据版本号。比如function buildCacheKey(url, params {}, user ) { const paramsStr JSON.stringify(params); return ${url}?${paramsStr}#user${user}; }还要考虑命名空间同源下多个业务模块共用一个 localStorage 前缀如果 A 模块用了user_profile这个 keyB 模块也用了同名 key相互覆盖是必然的。事前规划好前缀比如order:detail:12345、user:profile:12345后面排查会省很多事。5.2 TTL、版本号和手动失效三种手段的配合缓存失效有三个手段不能只依赖其中一个TTL过期时间适合自然衰减型数据。它的问题在于设置多长都是拍脑袋太短缓存命中率下降太长用户看到的可能不是最新数据。我一般根据业务容忍度来定价格、库存这类数据 TTL 设 30 秒到 1 分钟用户昵称这类低敏感数据可以设 5 分钟活动配置、公共资料可以更长。版本号适合结构性变化。接口返回的数据结构升级了、页面改了交互旧缓存里存的字段已经不能用了这时候抛一个版本参数到缓存 key 里让所有旧缓存瞬间失效。手动失效适合用户主动操作后立刻刷新数据。比如用户修改了头像你期待下一次访问该用户的主页就是新头像而不是等 TTL 慢慢过期。这时应该在修改成功的回调里主动删除对应的缓存 key。我在大型项目里的做法是三层配合。接口层用 TTL 控制自然过期发布流程用版本号做全量失效用户操作后手动清除指定 key。三层兜底基本不会出现“该更新的时候不更新”的问题。5.3 从 LRU 角度理解“缓存满了怎么办”缓存工具如果不管容量内存迟早会爆。LRULeast Recently Used算法是业界最常用的淘汰策略缓存满了之后优先淘汰“最久没被使用”的数据。JavaScript 里实现一个简化版 LRU 其实很简单利用 Map 的有序性即可class LRUCache { constructor(capacity 100) { this.capacity capacity; this.cache new Map(); } get(key) { if (!this.cache.has(key)) return undefined; const value this.cache.get(key); this.cache.delete(key); this.cache.set(key, value); return value; } set(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size this.capacity) { const oldestKey this.cache.keys().next().value; this.cache.delete(oldestKey); } this.cache.set(key, value); } }每次读取一个 key就把它删掉重新插入 Map 尾部这样 Map 头部自然就是“最久没被使用”的数据。淘汰时删头部就是 LRU。前端业务里真正需要手写 LRU 的场景不多如果做 Canvas 编辑器的大量历史状态、图片懒加载的预加载池或者客户端类型的复杂交互时才会用到。但我建议至少了解这个思路因为很多缓存问题的本质就是“该淘汰的没淘汰”。5.4 缓存一致性多级缓存之间的同步难题前端缓存往往不是单层而是内存缓存、localStorage、SW Cache、服务端 CDN 都有数据副本多层缓存同步是个大难题。以我自己项目里的处理经验跨层同步没有完美方案只能靠约定。我见过比较有效的做法是“统一由数据层读写”。业务代码不直接操作各类缓存而是通过一个 CachedStore 对象访问CachedStore 内部负责内存缓存和持久化缓存的同步。写入时双写内存和 localStorage读取时先查内存命中就不查持久化内存没有就到持久化查查到后回填内存。这样至少保证同一份业务数据在一个源头里不会出现“内存是新的localStorage 是旧的”这种局面。跨设备缓存同步基本只有服务端能做得干净前端能做的只是定期刷新或者接受最终一致性。6. 一套可复用的缓存排查链路从现象到根因的完整过程最后这部分我要完整走一遍排查链路这是我认为缓存系统最值钱的技能出了问题你怎么一步步定位到是缓存层的问题而不是把时间浪费在代码上。6.1 一次“发布后页面未更新”的完整排查过程某天测试同事反馈QA 环境发布了新版本但某些机器上页面始终是旧版。由于本机 DevTools 开了 Disable cache我在开发机上复现不了直觉告诉我这和缓存有关。第一步打开用户报障的机器在 Network 面板看关键资源的加载来源。发现首页 HTML 是304 Not ModifiedJS 文件名是旧的main.abc123.js而不是新版本的main.def456.js。这里就已经说明浏览器没有拿到新的 HTML 入口。第二步检查响应头。HTML 响应头的Cache-Control是no-cache按道理应该重新验证。继续看请求头发现If-None-Match带了一个旧 ETag服务器返回 304 说明当前 HTML 内容和旧 ETag 匹配。问题不在浏览器缓存而在服务器端QA 环境的静态资源服务没有在发布后更新 ETag。第三步给运维反馈后确认是发布脚本没有更新 QA 环境的静态资源版本号导致服务器还拿着旧文件在响应。这类问题看起来像“浏览器缓存”其实从浏览器视角出发Cetag 变了就应该拿新文件浏览器只是忠实地执行了 HTTP 协议。排查到最后定位层级在服务端不在前端代码。这个案例说明一个原则看到“页面没更新”别急着怀疑前端代码先顺着 Network 面板的响应头一路查上去判断缓存到底发生在哪一层。6.2 定位“用户看到旧接口数据”的五个检查点接口数据不更新我一般按下面顺序检查看 Network 面板请求是否真的发出去了。如果 Size 列是from disk cache或from memory cache说明请求根本没到网络层。看请求是否有304。如果有说明服务器认为可以复用本地缓存此时检查服务端返回的Cache-Control和ETag是什么。看代码里有没有主动读缓存。搜索localStorage、sessionStorage、IndexedDB、caches.open相关调用确认是不是应用层缓存拦住了。看 Service Worker 是否拦截了请求。Application 面板里查看 Service Workers看 Control 列是不是有 SW 在接管。看接口返回的数据里是否有“数据版本”字段。如果接口自己带了版本号对比新旧版本就能马上判断数据新旧。往这个链路走下来90% 的“缓存不更新”都能定位。剩下的 10%基本都是某个同事在代码里写死了旧缓存 key或者后端接口返回了一个很长的max-age。6.3 调式缓存时容易误导自己的三件事排查缓存问题技术手段很重要但更要小心调试动作本身制造假象。我总结了自己踩过的三件事第一Chrome DevTools 开了 Disable cache。这个选项会让所有请求绕过 HTTP 缓存看起来“一切正常”但用户环境不是这样。排查缓存问题的第一步就是关掉它改用无痕窗口。第二用“强制刷新”验证缓存是否生效。强制刷新通常忽略强缓存但保留协商缓存你不能因为强制刷新后看到新内容就断言“缓存应该更新了”。要验证缓存规则应该在不强制的情况下正常刷新甚至用 curl 模拟请求看响应头。第三无痕窗口里调试时依赖的旧缓存不存在。很多用户问题只有在“有缓存”的状态下才复现无痕窗口直接跳过缓存路径问题自然就消失了。你只能在普通窗口、关闭 Disable cache 的前提下慢慢复现。6.4 从缓存系统设计之初就避免的坏味道最后一小段分享一些我在代码评审时经常指出的缓存坏味道缓存 key 里不带版本号也没有命名空间。用 localStorage 存接口列表数据但没有任何过期时间判断。响应被 Service Worker 缓存但缓存策略选错了接口数据用了 Cache First导致更新永远滞后。缓存读写逻辑散落在各个业务组件里没有统一入口出了 bug 只能全局搜索。localStorage 里存 JSON 时不做 try/catchparse 失败直接白屏。好的缓存系统不是一蹴而就的而是一轮轮线上问题逼出来的。如果你现在正在做一个新项目我的建议是先把过期时间和 key 命名规范定好再决定用哪个存储方案先不要追求多复杂能保证“写了能读到、过期能清掉、用户能更新”就足够。等业务复杂度上来了再逐步引入 LRU、分级策略和更完善的失效规则。我自己在实际开发中还有一个习惯每次发版前会把涉及到的缓存规则从头到尾梳理一遍谁写了缓存、谁负责清缓存、缓存 key 包含哪些信息全部列出来。这个动作看起来繁琐但真的能帮我在问题爆发前提前堵住漏洞。JavaScript 缓存系统越往后做越会发现它不是一个技术题而是一个工程题。