1. 项目概述与整体设计思路
1.1 缓存三兄弟:到底解决什么问题
我做了这么多年前端,发现很多人对浏览器缓存的理解停留在"刷新一下就好了""清一下缓存试试"这种程度。真到了线上出问题——用户说页面还是旧的、接口数据不对、资源加载慢——就开始瞎猜,要么让用户强制刷新,要么在静态资源路径后面手动加版本号,治标不治本。
浏览器缓存机制说白了就三个层级:强缓存、协商缓存、Service Worker。这三者不是替代关系,而是配合关系。强缓存管的是"多久之内我不想再问服务器要同一个文件",协商缓存管的是"我不确定文件变没变,让服务器告诉我",Service Worker 管的是"我自己来当中间人,怎么绕开 HTTP 缓存规则我说了算"。
这篇文章我想把这三套机制从原理到配置到实战全部捋一遍,拿一个真实的项目场景去对比它们的表现。适合谁看?前端开发、性能优化工程师,还有那些被"缓存不生效""Service Worker 注册失败"折磨过的人。看完你至少能回答三个问题:为什么改了代码线上还是旧版?为什么 Service Worker 注册了却不干活?强缓存、协商缓存、Service Worker 到底该怎么组合用?
1.2 测试环境准备:搭建一个可复现的本地项目
纸上谈兵没用,缓存这东西必须上手测。我准备用一个极简的 Node.js 静态服务器来演示,不用 webpack、不用 nginx,尽量把变量控制到最少。你只需要装好 Node.js,然后按下面的步骤操作。
bash复制mkdir cache-demo && cd cache-demo
npm init -y
npm install express --save
我选 Express 而不是直接用 http.createServer,是因为 Express 设置响应头太方便了,演示逻辑更清晰。但你别以为我的视线只停留在 Express层——我们真正要操作的是 HTTP 协议层面的 Cache-Control、Expires、Last-Modified、ETag 这些头。Express 只是帮我设置这些头的工具。
这个测试项目我计划做三个页面路径:
/:普通页面,用来演示默认情况下浏览器怎么处理缓存/static/app.js:一个静态 JS 文件,分别用不同缓存策略跑几轮/api/data:一个 JSON 接口,用来演示协商缓存和 Service Worker 对接口数据的处理差异
后面每讲一种缓存策略,都会在对应路径上做实验,用 Chrome DevTools 的 Network 面板记录请求数和耗时。这样你看到的每一个结论都有数据支撑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 强缓存深度拆解:Cache-Control 与 Expires 的细节差异
2.1 强缓存核心机制:浏览器怎么判断"用旧的"
强缓存的名字很形象:只要缓存没过期,浏览器根本不发请求,直接从本地读。Expires 是老一代方案,Cache-Control 是现在的事实标准,两者一起出现时以 Cache-Control 为准。
javascript复制// Express 中设置强缓存
app.get('/static/app.js', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.sendFile(path.join(__dirname, 'public/app.js'));
});
这段代码的含义是:app.js 这个文件在 3600 秒内有效,中间就算用户刷新十次页面,浏览器也不会向服务器发起哪怕一次请求。你去看 Network 面板,这一条请求的 Size 列会显示 (from disk cache) 或 (from memory cache),前者是硬盘读出来的,后者是内存读出来的。
这里有个很多人不理解的细节:from disk cache 和 from memory cache 的区别是什么? 简单说,内存缓存读取速度快,但浏览器关闭就没了;磁盘缓存读取速度相对慢一点,但持久保存。浏览器自己决定把资源放内存还是磁盘,我们没法直接控制,通常脚本文件优先走内存,图片、字体这类大资源更倾向磁盘。
2.2 强缓存核心参数逐个解析
Cache-Control 可以接受多个指令,每个指令都对应一种控制意图,我列一下最常用的几个:
| 指令 | 含义 | 使用场景 |
|---|---|---|
max-age=秒 |
缓存多少秒后过期 | 静态资源,按照更新频率估算 |
public |
中间代理服务器也可以缓存 | CDN 场景,希望边缘节点分担压力 |
private |
只允许浏览器本地缓存,代理不缓存 | 带用户信息的个性化接口 |
no-cache |
不是不缓存,而是每次都要向服务器确认 | 必须走协商缓存的资源 |
no-store |
完全不缓存,连本地都不存 | 支付信息、订单数据等敏感内容 |
s-maxage=秒 |
只控制代理服务器的缓存时长 | 配合 public 使用,CDN 场景很强 |
no-cache 这个名称特别有迷惑性。它不是禁止缓存,而是"每次使用前先问问我服务器"。这个过程就是协商缓存,我们下一节详细讲。no-store 才是真正的"一颗不存",常见于包含 token、地址、银行卡等敏感信息的接口。
还有 must-revalidate 这个指令。它告诉浏览器:缓存过期之后,必须去服务器重新验证,在这期间不能私自沿用过期资源。组合写法如 Cache-Control: public, max-age=60, must-revalidate 在接口场景很常见——允许缓存 1 分钟,但 1 分钟之后必须重新验证,不能用陈旧数据。
2.3 Expires 的历史包袱与浏览器时钟偏移问题
Expires 是 HTTP/1.0 时代的产物,用法是给一个绝对过期时间:
code复制Expires: Wed, 21 Oct 2025 07:28:00 GMT
问题在哪里?它用的是绝对时间。浏览器判断缓存是否过期,靠的是本地计算机时间。如果用户电脑时间被改错了——比如时区设置错误、系统时间快了几个小时——就会出现明明没到过期时间却被判定过期,或者相反明明过期了却被当成新鲜的。
这也是为什么我在实际项目中建议直接忽略 Expires,只设 Cache-Control 就够了。现在的主流浏览器对 HTTP/1.1 的 Cache-Control 支持已经非常完善,没必要为了兼容老古董付出额外的维护成本。如果你是在做一个需要兼容 IE 时代浏览器的项目,那就两个都设上,同时保证 Expires 的时间比真实过期时间晚不少,给时钟偏移留出余量。
2.4 强缓存实战记录:max-age 怎么定才科学
项目里最常被问的问题就是:"max-age 到底设多少合适?"网上有人建议一年,有人说一小时,其实没有标准答案,关键是看资源变更频率和你对"发布后多久让用户看到新版本"的容忍度。
拿我自己做过的线上项目举例。打包后的 JS/CSS 文件只要文件名里带了 hash(比如 app.8f4c2a1.js),内容一变哈希就变,那就大胆设 Cache-Control: public, max-age=31536000,一年。因为文件名变了,浏览器会认为这是一个新的 URL,压根不会命中旧缓存。内容没变的旧文件踏踏实实用一年缓存,完全没毛病。
反过来,如果是接口数据,比如一个排行榜,最好设一个很短的 max-age=30,甚至直接走 no-cache 协商缓存。长缓存时间会导致用户看到一份已经变了的榜单,体验很差。
实操时我在 Express 里给静态资源统一加上了长缓存:
javascript复制app.use('/static', express.static('public', {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
}
}));
immutable 这个指令值得说一嘴,它告诉浏览器这个资源"绝对不会变",刷新页面时不要重新验证,直接用力缓存。配合带哈希的文件名非常合适。不过 immutable 目前还有兼容性问题,少数浏览器不认,所以 max-age=31536000 是兜底方案。
3. 协商缓存机制解析:Last-Modified 与 ETag 的双轨制
3.1 协商缓存工作流程:一次真实的请求往返
协商缓存的核心逻辑是"浏览器不直接决定用不用缓存,而是把判断权交给服务器"。它的请求链路是这样的:
- 浏览器本地已经有这个资源的缓存,但不确定新不新鲜
- 浏览器带上一堆验证头(
If-Modified-Since或If-None-Match)发请求 - 服务器收到后判断资源有没有变化
- 没变,返回
304 Not Modified,响应体为空,浏览器用本地缓存 - 变了,返回
200 OK,响应体带最新内容
关键点在于第 4 步:304 响应是没有 body 的。网络传输的只是一些 HTTP 头,体积极小。所以协商缓存虽然每次都发请求,但实际消耗的带宽远小于直接下载完整文件。
用代码演示一下。Express 静态中间件自带协商缓存支持,但你也可以自定义响应头来精确控制:
javascript复制app.get('/api/data', (req, res) => {
const data = { time: Date.now(), message: 'hello' };
const etag = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'no-cache');
res.json(data);
});
这里设置 Cache-Control: no-cache 是关键,意思是"浏览器你可以缓存,但每次用之前都得来问我"。然后我用 ETag 实现精确的版本比对。当第二次请求发来时,浏览器会自动带上上一次响应里拿到的 ETag 值放在 If-None-Match 头里,服务器比对后发现一致,直接 304。
3.2 Last-Modified 与 ETag:谁是更优解
Last-Modified 的思路是记录文件最后修改时间,用时间戳做判断。ETag 的思路是给文件内容算一个唯一标识符,用内容做判断。
| 维度 | Last-Modified | ETag |
|---|---|---|
| 判断依据 | 文件最后修改时间 | 文件内容哈希/版本号 |
| 精确度 | 低,只能精确到秒级 | 高,内容任何字节变化都能感知 |
| 开销 | 低,服务器只需要读文件时间 | 高,需要计算哈希,大文件有成本 |
| 主要问题 | 文件修改时间变了但内容没变,会误判;修改太快同一秒内,无法识别 | 计算开销;分布式环境下要保持哈希一致 |
实际应用中,绝大多数静态服务器会同时生成 Last-Modified 和 ETag。比如 Express 的 express.static 就默认两个都带。浏览器发起协商验证时,优先用 If-None-Match,如果服务器支持 ETag 就按 ETag 判断,否则看 If-Modified-Since。
3.3 协商缓存实战:接口数据的缓存策略设计
协商缓存最常被忽略的价值其实是接口数据。好多后端同事默认"凡是接口都不能缓存",这句话错得很离谱。很多 GET 接口数据并不是实时敏感的——列表页数据差个 30 秒、文章详情差个 1 分钟,用户根本感知不到。如果你给这些接口配上协商缓存,请求体几乎为零,服务器的压力能降一截。
我在真实项目中是这样设计接口缓存策略的:
javascript复制// 列表数据,可接受短暂陈旧
res.set('Cache-Control', 'no-cache');
res.set('ETag', generateListHash(data));
首先必须是 GET 请求,POST 接口就别想了,HTTP 规范不鼓励缓存 POST 响应。其次只对非关键数据开这个口子。最后一定要用 ETag 而不是 Last-Modified——列表数据的任何排列变化都必须触发缓存失效,时间戳做不到这点。
一个比较坑的地方:用户登录后的个性化数据,比如"我的订单",缓存时需要在 Cache-Control 里加上 private 指令,防止 CDN 或公共代理把这个用户的订单数据缓存下来,泄露给别人。虽然 ETag 判断实现了协商,但 private 是访问控制层面的保障。
3.4 304 状态码与响应头落地的几个细节
平时你看到 304,说明协商缓存生效了。但有几个细节常被忽略:
- 304 响应必须包含和 200 响应相同的验证头(
ETag、Last-Modified),不然浏览器下次不知道拿什么去验证 - 如果 304 响应里修改了
Cache-Control的指令,浏览器会按新的指令更新缓存策略 - 304 响应不能有 body,但有些服务器会错误地返回一段空 JSON,这种实现是不规范的
排查这类问题最简单的方法:看 Network 面板里这条请求的"Status Code"和 Response Headers。如果 304 响应里没有 ETag,你就要怀疑是不是服务器在 304 分支里忘了设置响应头。Express 的 express.static 内部也踩过这类坑的老版本,后来都修掉了,你自己实现协商缓存时尤其要注意。
4. Service Worker 缓存实战:接管请求与缓存优先级
4.1 Service Worker 到底是什么角色
Service Worker 是运行在浏览器后台的一个 JavaScript 线程,独立于页面。它能拦截同源(或同 scope 下)页面的所有 fetch 请求,充当本地代理。有了它,缓存的玩法就不受 HTTP 缓存语义的限制了——Cache-Control 说缓存多久就多久,但 Service Worker 可以自己决定"这个接口我双缓存交叉验证,那个页面我优先走缓存再后台更新"。
值得注意的是,Service Worker 只在 HTTPS(或 localhost)下才能注册。这是因为能拦截网络请求的能力太强大,如果被中间人注入恶意 SW,整个站点的数据都能被改写。所以本地调试用 localhost 没问题,正式环境必须有 HTTPS。
4.2 Service Worker 生命周期:从注册到激活
一个典型的 Service Worker 生命周期包含:注册(register)→ 安装(install)→ 激活(activate)→ 运行(fetch)。每一步都有对应的监听事件。
javascript复制// 页面 JS 中注册
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.log('SW registration failed:', err));
}
注册成功之后,浏览器开始下载 sw.js,校验字节,如果和已注册的版本不同,就触发 install 事件。install 阶段最常做的操作是预缓存关键资源:
javascript复制// sw.js
const CACHE_NAME = 'my-cache-v1';
const PRECACHE_URLS = ['/', '/static/app.js', '/static/main.css'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
event.waitUntil 的作用是告诉浏览器"安装过程还没结束,等等我"。cache.addAll 是原子操作——任何一个 URL 下载失败,整个预缓存都失败,SW 安装也会失败,所以预缓存列表不要放太多不稳定的 URL。
激活阶段最关键的步骤是清理旧缓存。Service Worker 可以存在多个版本,但同一时间只有一个控制页面。当新版本 SW 激活时,通常要把旧版本的缓存删除,不然缓存会无限膨胀:
javascript复制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();
});
self.clients.claim() 很关键。默认情况下,新激活的 SW 不会立刻接管已打开页面,要等页面刷新。调用 clients.claim() 可以立即接管,但也会带来"页面还在运行,资源加载策略一下子变了"的潜在风险,生产环境慎用。
4.3 Fetch 事件拦截策略:五档缓存优先级
SW 最核心的能力体现在 fetch 事件监听器里。我习惯把请求分为五类场景,每一类对应不同的缓存策略:
第一档:仅缓存(Cache Only)
适合不常变、必须在离线时也能访问的资源。不到万不得已不用这种策略,因为一旦手动清掉 SW 的缓存,资源就永远加载不出来了。
javascript复制event.respondWith(caches.match(event.request));
第二档:缓存优先,回源兜底(Cache First, Network Fallback)
这是最常用的策略。适合静态资源,加速明显:
javascript复制event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
});
})
);
第三档:网络优先,缓存兜底(Network First, Cache Fallback)
适合对新鲜度有要求的接口。网络正常时永远拿到最新数据,网络挂了才用旧缓存。实现上要注意:fetch 必须 catch,不然离线时 Promise 直接 reject。
第四档:缓存与网络同时发起,谁快用谁(Race)
也叫 "stale-while-revalidate" 的变体。缓存命中直接返回,同时后台发网络请求更新缓存。这个策略用户感知到的速度最快,但实现最复杂,还要避免立即再次触发一次完整下载。
第五档:仅网络(Network Only)
适合支付、登录等不可缓存请求,直接 fetch 不经过任何缓存检查。
4.4 版本更新策略:为什么改了 sw.js 却不起作用
这是正文里最容易踩的坑。很多人在 install 里改了一堆代码,结果刷新页面后发现 Cache Storage 里还是老缓存,页面还是走老逻辑。原因在于:浏览器默认不会第二次触发 install,除非 SW 文件内容真的发生了变化。
更隐蔽的问题是:如果 SW 文件内容变了,但新 SW 只是"安装成功",并没有激活,此时页面仍然由旧 SW 控制。你打开 chrome://serviceworker-internals 能看到状态停在 "activated" 边缘或者显示 "redundant"。
我总结的规范更新流程是三步:
- 修改
sw.js内容,同时更新CACHE_NAME版本号,比如从v1改成v2 - 在
install事件中调用self.skipWaiting(),让新 SW 跳过等待阶段立即激活 - 在
activate事件中清理旧版本缓存,并用self.clients.claim()接管已有页面
有个更稳妥的做法:在 fetch 处理中加一个版本标记,每次响应都带上当前缓存版本号。调试时如果发现版本号和预期不一致,直接怀疑 SW 被旧缓存坑了。
5. 三种缓存机制横向对比与组合策略
5.1 实测对比:同一个资源在三种策略下的表现差异
我在本地跑了一轮实测,目标资源是 app.js,约 15KB。每次测试切换资源加载策略后,硬刷新一次,然后再单独刷新页面,对比第二次刷新的网络面板数据。
| 策略 | 第二次加载请求数 | 传输数据量 | 耗时(本地) | 离线可用 |
|---|---|---|---|---|
| 无任何缓存头 | 1 | 15KB | 约 45ms | 否 |
| 强缓存 max-age=3600 | 0 | 0Bytes | 约 1ms | 否(需要事先在线加载) |
| 协商缓存 + ETag | 1(304) | 约 200Bytes | 约 20ms | 否 |
| Service Worker 缓存优先 | 0(SW 拦截) | 0Bytes | 约 2ms | 是 |
不同策略的优势一目了然:强缓存和 SW 优先都能做到零请求,但 SW 的独特优势是离线可用;协商缓存虽然发请求,但和强缓存冲突的问题少,适合数据类接口。
这里要特别强调一点:强缓存生效时,请求根本到不了 Service Worker 层。浏览器检查 HTTP 缓存发现没过期,直接返回本地副本,连 fetch 事件都不会触发。所以你在 SW 里写了个"拦截所有请求并更新缓存"的逻辑,但静态资源的 Cache-Control 设了一亿年——那 SW 的 fetch 处理对这个资源永远不会执行。这个坑我见过太多次了。
5.2 分层缓存架构:静态资源、页面、接口各自用什么策略
一个好的缓存架构不会是"全都用 SW"或"全都用强缓存"。我的通用方案是三层配合:
第一层:静态资源(带 hash 的 JS/CSS/图片)。强缓存设 max-age=31536000 加 immutable。一般不需要 SW 插手,除非你要做完整的离线支持。
第二层:HTML 页面入口。用协商缓存或很短的 max-age=0, must-revalidate,保证用户每次打开都能快速拿到最新页面结构。
第三层:接口数据。分场景来:
- 实时性要求高(登录、下单、支付):不缓存,或者只走内存缓存
- 可接受短暂延迟(列表、筛选、推荐):协商缓存,ETag 校验
- 完全静态内容(如帮助文档、UI 配置):强缓存短时间 + SW 缓存优先
5.3 从缓存维度思考代码发布与用户端表现
有没有想过一个问题:用户看到一个"新功能没生效"的抱怨,可能不是功能 bug,而是他浏览器里缓存着一份旧 JS。要解决这个,光靠开发者自己强制刷新没有意义,必须从发布策略上解决。
最可靠的方式是 文件名 hash 方案。构建工具会在文件名里生成内容 hash,内容一变文件名就变,新 URL 不命中旧缓存,强缓存规则天然规避了"老用户拿旧文件"的问题。这本质上不是"绕过缓存",而是"利用缓存"。
另一个实用技巧是灰度发布时的缓存策略调整。发布新版时,先用 max-age=0 跑一段时间,等大部分用户都更新到最新资源之后,再把缓存时间调回长缓存。这个过程可以用发布平台的配置中心控制,不用改代码。
5.4 资源命中优先级:Service Worker、强缓存、磁盘缓存谁先谁后
要理解组合策略,必须先知道浏览器处理一个资源请求的真实链路。我的理解是这样的:
- Service Worker 检查是否存在,是否能拦截这个 URL
- 如果 SW 能拦截,fetch 事件处理器决定用什么策略
- 如果 SW 没拦截或
respondWith没调用,走 HTTP 缓存检查 - HTTP 强缓存生效且未过期,直接用本地缓存
- HTTP 强缓存过期或 no-cache,发协商请求判断 304 还是 200
- 都没有缓存,正常发网络请求
这个顺序有个实战含义:SW 的优先级在 HTTP 缓存之前。SW 里如果写了 event.respondWith(fetch(event.request)),就绕过了 HTTP 缓存层。反过来,如果 SW 没拦截,HTTP 缓存的规则依然有效。
我实际项目里的做法:fetch 事件里先判断请求类型,静态资源和页面请求走 Cache First,接口请求走 Network First。HTTP 缓存层继续保留,作为 SW 失效情况下的后备。这样即使 SW 因为某种原因没注册成功,HTTP 缓存还在兜底。
6. 常见问题排查与避坑技巧
6.1 Service Worker 注册失败到调试思路
从打开 Chrome DevTools -> Application -> Service Workers 面板开始排查。常见报错有这么几类:
code复制Error: could not register service worker: InvalidStateError
这个错误通常意味着页面本身处于一个不安全的状态,或 SW 文件路径超出 scope。SW 的 scope 默认是 sw.js 文件所在目录,也就是说如果 sw.js 放在 /static/sw.js,默认只能控制 /static/ 下的页面。想控制全站,就把 sw.js 放在站点的根目录,或者注册时显式传入 scope 参数(但也要受路径规则限制)。
另一类常见问题:注册成功,但 fetch 完全不触发。原因通常是你检查了 event.request.method === 'GET' 却没处理跨域请求的 mode。跨域图片、第三方 CDN 的请求也会触发 fetch 事件,但 caches.match 对跨域缓存有限制,容易出问题。
6.2 缓存更新不生效的 5 个排查方向
方向一:强缓存命中了。 打开 Network 面板看看这条资源的 Size 列,如果是 (from disk cache),说明根本没走到网络层。解决办法:地址栏勾选 Disable cache 再刷新,或者在 SW 文件里把对应 URL 加入网络优先策略。
方向二:SW 版本没更新。 打开 chrome://serviceworker-internals,看看 SW 的更新时间。如果你改了代码但 SW 文件没重新下载,浏览器是不会更新的。有时候是服务器给 sw.js 本身也加了强缓存头——sw.js 这个文件千万不能缓存,Cache-Control: no-cache 是底线。
方向三:页面还没被新 SW 接管。 新 SW 激活后,已经打开的页面还挂在旧 SW 上。用户必须刷新一次才能完成接管更新。这也是为什么线上发布完要等一段时间,有些用户会报告"刷新两次才看到新版"。
方向四:Cache Storage 里有旧缓存。 检查 Application -> Cache Storage 面板,看看缓存是否和当前版本匹配。如果你在 activate 里没有正确清理旧缓存,旧数据就会一直存在,SW 缓存优先策略会不断命中它。
方向五:跨标签页导致的 Install 事件被跳过。 浏览器会在所有同 scope 页面关闭后才推进生命周期,否则新 SW 停在 waiting 状态。测试时最省事的方法:关闭所有相关标签页,重新打开一个空页面。
6.3 数据类请求缓存的常见误区和安全红线
误区一:为了快,把用户维度的数据缓存了。 响应里加了 Cache-Control: private 不代表一定安全,代理不缓存但 SW 可以直接缓存。个人敏感数据不到万不得已别走 SW 缓存,或者必须做加密存储。
误区二:POST 请求也想缓存。 HTTP 缓存只适用于 GET/HEAD 等安全方法。POST 请求的响应语义不能被浏览器缓存,这也是规范明确规定的。想要复用 POST 的结果,可以用 SW 手动缓存,但一定要你自己设计好失效策略。
误区三:忽略了 206 Partial Content 响应。 视频、文件下载类的范围请求,不推荐用 SW 粗暴地 cache.put,因为浏览器缓存可能只缓存了部分片段。处理这类请求时,最好让 SW 直接放行,不做缓存干预。
我用一个速查表把这些问题和方案整理起来:
| 问题 | 现象 | 排查方法 | 解决方法 |
|---|---|---|---|
| 改了代码线上还是旧版 | Network 显示 from disk cache | 检查 Cache-Control/是否带 hash 文件名 | 文件名加 hash 或缩短 max-age |
| SW 注册失败 | InvalidStateError | 检查 HTTPS、sw.js 路径、scope | 把 sw.js 放根目录,重新指定 scope |
| SW 更新不生效 | 新代码没执行 | chrome://serviceworker-internals 查状态 | 确保 sw.js 无缓存,改 CACHE_NAME 版本号 |
| 新 SW 一直 waiting | 页面由旧 SW 控制 | Application 面板看 Active Worker | 调用 skipWaiting + clients.claim |
| 接口数据一直变旧 | 返回的数据不是最新 | 检查强缓存 max-age 太长 | 接口统一改 no-cache 走协商缓存 |
| sw.js 被 CDN 强缓存 | SW 永远不更新 | 检查响应头 Cache-Control | CDN 层强制 no-cache |
| SW 缓存导致内存暴涨 | Cache Storage 体积异常 | 看 Cache Storage 面板 | 定期清理旧版本缓存,设置缓存条数上限 |
6.4 调试缓存问题的几个高效技巧
技巧一:正确使用 DevTools。 Network 面板勾选 "Disable cache" 只能绕过 HTTP 缓存,绕不过 Service Worker。想每次全新调试 SW,需要在 Application 面板里点 "Unregister",再勾选 "Bypass for network",然后刷新。
技巧二:用 caches.match() 直接在控制台验证。 在页面 Console 里执行 caches.open('my-cache-v1').then(c => c.match('/static/app.js')),能直接看到缓存命中结果。排查缓存是否存在、内容是否过期非常快。
技巧三:给 SW 加一份调试日志。 开发环境下在 sw.js 里把每个 fetch 请求的 URL 和命中策略都 console.log 出来,但生产环境必须移除,或在 URL 上加一个 ?debug=1 参数才输出日志。
技巧四:关注 Performance 面板的加载瀑布图。 水到渠成的判断:一行 (from service worker) 说明 SW 命中;一行 (from disk cache) 说明 HTTP 缓存命中;没有这些标注又显示了 304 就说明协商缓存生效。
7. 组合策略的最终落地建议
我自己的项目里,最后成型的一套缓存配置大致长这样:
HTML 页面:Cache-Control: no-cache,用 ETag 走协商缓存,保证每次访问都能拿到最新页面结构,同时又能享受 304 快速返回。
带 hash 的 JS/CSS:Cache-Control: public, max-age=31536000, immutable,配合 SW 的缓存优先策略,让用户第二次访问时零请求。
接口数据:实时的走 no-store;可缓存的走 no-cache + ETag;完全静态的配置类数据可以 max-age=300 外加 SW 缓存优先兜底。
整套方案里最核心的思路不是"哪个缓存强就用哪个",而是搞清楚每一个资源请求在什么条件下可以用陈旧版本,什么条件下必须拿到最新。把这条边界想清楚,缓存策略就不会做错。
最后再分享一个经验:上线前一定要做一次"新用户视角"的验证。 开一个无痕窗口,禁用缓存,看首次加载是否正常;再刷新一次,看二次加载是否命中缓存;再手机模拟弱网,看离线时页面能不能打开。这三步走完,缓存体系基本就稳了。别在线上被用户报告了问题才想起测缓存,那会儿就晚了。
