1. 浏览器缓存到底在缓存什么——先建立一个整体认知
我做前端性能优化做了这么多年,发现很多同学一提到缓存就只知道“刷新页面”“清缓存”“强缓存和协商缓存”,但对它们之间的层级关系、底层逻辑和实际使用边界,其实是模糊的。每次面试问“浏览器缓存机制有哪些”,能答上强缓存、协商缓存的人不少,但能把 Service Worker 也放进来讲清楚的人,寥寥无几。
这篇文章我打算把这三兄弟放在一起做个横向对比,顺便把我实际项目中踩过的坑一起倒出来。先说结论,避免你看完一头雾水:
- 强缓存:浏览器说“不用问了,直接用我本地存的”,速度最快,但服务器无法实时感知资源更新。
- 协商缓存:浏览器说“我问一下服务器,这个还能不能用”,服务器说“能用,你继续用本地版”,资源能更新,但每次都需要一次网络请求。
- Service Worker:浏览器说“我不光给你缓存,还能在离线时给你兜底,甚至能拦截请求做各种花活”,完全由前端代码掌控,灵活性和复杂度同步上升。
一句话总结:强缓存是“省事模式”,协商缓存是“对称模式”,Service Worker 是“自定义模式”。三者不是互相替代的关系,而是配合使用的关系。一个成熟的站点,往往是三层都有,按资源类型和更新频率分别配置。
再说一个容易忽略的点:缓存的本质是“空间换时间”,但空间是有代价的——存储占满、版本不一致、用户看到旧页面,这些都是缓存的副作用。真正的高手不是“能用缓存”,而是“知道哪些能用、哪些不能、什么时候必须失效”。
接下来我一个个拆开讲,最后给你一张可以直接抄的选型表和配置模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 强缓存:最快的缓存,没有之一
强缓存的概念很简单:浏览器在本地直接命中缓存,压根不会向服务器发送请求,状态码显示为 200 (from memory cache) 或者 200 (from disk cache)。它之所以“强”,是因为没有网络往返,加载速度严格来说是个位数毫秒级别的。
2.1 强缓存靠什么控制——Cache-Control 与 Expires
HTTP 协议提供了两个响应头来控制强缓存:Expires 和 Cache-Control。
Expires 是 HTTP/1.0 时代的东西,指定的是一个绝对时间,比如 Expires: Wed, 21 Oct 2025 07:28:00 GMT。问题在于:它依赖客户端时间。如果用户电脑时间不对,缓存就直接失效了。所以现在的主流实践基本不用它,只把它当作兼容旧环境的兜底。
Cache-Control 是 HTTP/1.1 引入的,用相对时间或指令来控制,优先级高于 Expires。常用指令我整理一下:
| 指令 | 含义 | 使用场景 |
|---|---|---|
max-age=31536000 |
从响应生成起,缓存 1 年 | 带 hash 的静态资源 |
no-cache |
强制每次请求都发送给服务器验证 | HTML 页面、需要实时确认的资源 |
no-store |
禁止一切缓存,包括内存和磁盘 | 用户隐私信息、支付接口、动态数据接口 |
public |
允许浏览器和中间代理缓存 | CDN、公共静态资源 |
private |
只允许浏览器私有缓存,禁止代理缓存 | 含用户信息的响应 |
s-maxage |
表示“在代理层生效的 max-age”,比 max-age 优先级更高 | CDN 节点缓存策略 |
一个关键点:max-age=0 和 no-cache 不完全等价。max-age=0 表示缓存有效期为零秒,语义上就是说“请立刻过期”,但缓存的资源和信息仍然被保留,只是每次都必须去服务器校验;no-cache 更准确的说法是“可以用缓存,但必须先向服务器验证”。实测中两者的行为很接近,都走协商流程,但语义上我建议:如果你明确不想用过期缓存,直接用 no-cache。
2.2 强缓存的实际配置示例
静态资源,比如 JS、CSS、图片,常规做法是文件名或路径里带上内容 hash,然后配上超长的 max-age。这样内容一变,URL 就变,自然请求新文件;内容不变,就走缓存。
我用 Nginx 举个例子:
nginx复制location /static/ {
# 带 hash 的静态资源,一年缓存
add_header Cache-Control "public, max-age=31536000, immutable";
}
这里有个小知识点:immutable 指令是告诉浏览器“这个资源一秒钟内不会变,即便用户手动刷新也别去验证”。实测对带 hash 的资源体验很好,能避免用户刷新页面时打断缓存。但注意:immutable 只对“URL 会跟着内容变化”的资源有效,HTML 页面千万别用。
HTML 页面本身一般不缓存或短缓存,通常这样:
nginx复制location / {
add_header Cache-Control "no-cache";
}
no-cache 的意思是“缓存但每次都用协商缓存问一下服务器”,而不是“不缓存”。这样能兼顾首屏速度和资源更新。
2.3 强缓存的核心限制——服务器无法主动失效
这是最让前端头疼的地方。强缓存一旦命中,服务器端做任何操作都影响不到已经存了缓存的浏览器。资源更新了?用户那边还是旧版本。你清不掉用户的缓存,除非改 URL。
所以工程上的应对方案就是“文件名哈希化”:webpack 的 [contenthash]、Vite 的 [hash],都是让文件名跟着内容走。内容不变哈希不变,内容变化哈希变化,自然绕开强缓存失效问题。
注意:如果你在使用构建工具的
html-webpack-plugin或等效方案,一定要保证 HTML 文件本身不缓存或只做协商缓存,否则就会出现“页面还是老 HTML,里面引用还是老 JS 文件”的问题。这个坑我见得太多了。
3. 协商缓存:每次都要问服务器,但内容只传增量
协商缓存的核心逻辑是:浏览器把本地缓存的资源的“标记”带给服务器,服务器根据标记判断资源是否变化。没变,服务器返回 304 Not Modified,响应体为空,浏览器继续用本地缓存;变了,返回 200 和新资源。
3.1 两组标记:Last-Modified / If-Modified-Since 与 ETag / If-None-Match
第一组是 Last-Modified 和 If-Modified-Since,基于“最后修改时间”来判断。服务器返回资源时带上 Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT,浏览器下一次请求就在请求头里带上 If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT,服务器比对时间即可。
这套逻辑简单,但有个先天毛病:时间精度是秒级。如果资源在 1 秒内被修改多次,或者内容变了但碰巧修改时间没变(比如服务器同步、程序自动生成),就可能漏报。
第二组 ETag 和 If-None-Match 就是为了解决这个问题。服务器给每个资源生成一个唯一标识,相当于“指纹”,内容变化指纹就变。浏览器带上 If-None-Match: "abc123" 请求,服务器比对当前资源的 ETag,不一致就返回新资源,一致就返回 304。
实际项目中,服务器常常两组都返回,但 ETag 优先级更高。Nginx 默认就同时启用 Last-Modified 和 ETag,所以你在实践里通常不需要额外配置。
3.2 协商缓存的请求链路与性能损耗
协商缓存虽然不传响应体,但它毕竟是真实的网络请求,必然产生网络延迟。举个例子:用户在弱网环境下打开页面,即便所有资源都命中协商缓存返回了 304,整个页面的资源加载时间也是“每个资源一次网络往返 + 服务器判定时间”的总和。真的不算快。
所以我的建议是:协商缓存适合“不确定是否会变,但不能接受用户一直看旧内容”的资源,比如普通图片、文章配图、非哈希命名的文件。它提供的是精准性和安全性,代价是速度。
3.3 什么时候优先选择协商缓存
很多新手会把所有资源都配置成强缓存,这其实是一种偷懒。有一个分类标准我用到现在都觉得够用:
- 文件名带 hash 的静态资源:强缓存 1 年。
- 文件不带 hash,但内容更新不频繁:协商缓存。
- 文件不带 hash,内容更新频繁:不要缓存(
no-store)或短时间强缓存(max-age=60)。 - HTML 入口文件:强制协商缓存或者不缓存。
在实际项目里,我把不常变的 logo、背景图、公共 CSS(非构建产物)配置成 max-age=86400, public 外加 ETag,这样日常访问走强缓存,资源变更后用户在一天内能通过协商缓存拿到更新。
4. Service Worker:在前端代码里手写一套缓存
Service Worker(下称 SW)是这三者里最特殊的一个。它本质上是一个独立于页面 JavaScript 线程的脚本文件,由浏览器后台运行,可以监听和拦截当前作用域下所有页面的网络请求,并且可以通过 Cache API 自己管理缓存。
强缓存和协商缓存是“HTTP 协议交给浏览器处理”的被动机制,而 Service Worker 是“前端主动编程干预”的主动机制。这意味着:你想怎么缓存、缓存哪些、过期规则是什么,全都由代码说算。
4.1 Service Worker 的生命周期与注册流程
SW 的使用流程概括起来是“注册→安装→激活→监听”。我用一个最小可用示例来拆解。
在主线程里注册 sw.js 文件:
javascript复制// 在主 JavaScript 文件中
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration.scope);
})
.catch(err => {
console.log('SW registration failed: ', err);
});
});
}
然后在根目录放一个 sw.js,包含安装和激活逻辑:
javascript复制// sw.js
const CACHE_NAME = 'my-cache-v1';
self.addEventListener('install', event => {
// 安装时预缓存一些核心资源
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/main.js'
]);
})
);
// 让新 SW 立即生效,而不需要等待旧页面关闭
self.skipWaiting();
});
self.addEventListener('activate', event => {
// 清理旧版本缓存
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
);
})
);
self.clients.claim();
});
这里有两个细节:
skipWaiting()是“跳过等待”阶段,让新版本 SW 立刻接管控制权。clients.claim()是让新激活的 SW 能够立即控制页面,而不需要用户刷新。
两者配合,才能做到“每次部署新 SW 后,用户在下一次访问时就能用上新版本”。
4.2 用 Cache API 实现请求拦截与自定义缓存策略
SW 最核心的能力是 fetch 事件监听:每一个发出去的请求,都会先经过 SW,SW 可以决定是直接返回缓存、还是走网络、还是两者结合。这里就诞生了各种缓存策略,我挑三种最常用的:
Stale While Revalidate(后台更新)
javascript复制self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
// 先返回缓存,同时后台发起网络请求,成功后更新缓存
const fetchPromise = fetch(event.request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
return response;
});
return cached || fetchPromise;
})
);
});
这个策略是我长期做个人项目的首选:用户秒开页面看旧内容,后台静默更新,下次打开就是新内容。适合页面骨架、文章列表这类不严格需要实时性的接口。
Cache First(缓存优先,离线兜底)
javascript复制self.addEventListener('fetch', event => {
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(网络优先,超时回退缓存)
javascript复制self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
fetch(event.request).catch(() => {
return caches.match('/offline.html');
})
);
});
适合页面导航和需要实时数据的接口。我一般搭配 Promise.race 做超时控制,比如 3 秒没响应就回退到缓存:
javascript复制function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))
]);
}
4.3 Service Worker 能做什么,不能做什么
能做的:拦截请求、自定义缓存策略、离线页面、消息推送(配合 Web Push)、后台同步(Background Sync)、本地通知。
不能做的:
- 无法访问 DOM:SW 运行在独立线程,没有
window和document,只能通过postMessage和页面通信。 - 必须有 HTTPS 或 localhost:出于安全原因,SW 只在安全上下文里生效。生产环境如果没配 HTTPS,SW 根本无法注册。
- 作用域受限于 sw.js 所在目录:
/sw.js默认只能控制根目录下的请求,如果你想控制子路径,需要用Scope参数调整。 - 无法绕过某些浏览器限制:比如 iOS Safari 对持久化存储有严格限制,SW 缓存可能会被系统回收。
4.4 Service Worker 的版本更新与缓存淘汰策略
SW 是一个“有状态”的东西,版本更新比普通静态资源更讲究。常规做法是每次发布时把 CACHE_NAME 改成新版本号,比如 my-cache-v1 → my-cache-v2,然后在 activate 阶段删除旧缓存。
关键问题:你怎么知道 sw.js 本身更新了?浏览器会在页面加载时去请求 sw.js,如果在地址栏命中了强缓存,就不会重新拉取。所以务必要给 sw.js 配置 Cache-Control: no-cache,甚至可以直接 no-store:
nginx复制location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
另外,sw.js 文件内容只要有任何字节变化,浏览器都会认定这是一个新版本 SW。所以别在 sw.js 里写动态内容,比如注释带时间戳,否则会导致所有用户都在重复拉取更新。
5. 三者实战对比:什么场景该用谁
单人讲完技术细节,还是得落到“实战选型”上。我用过很多项目,从个人博客到日活百万的产品,缓存策略在它们的框架下其实高度一致,差异只在参数的微调。
5.1 整体对比一览表
| 维度 | 强缓存 | 协商缓存 | Service Worker |
|---|---|---|---|
| 是否发出网络请求 | 否 | 是(304) | 由策略决定,可缓存命中时无请求 |
| 加载速度 | 最快 | 快(省响应体) | 最快(能力可覆盖前两者) |
| 离线支持 | 不支持 | 不支持 | 支持 |
| 资源更新实时性 | 差 | 好 | 优,但需手动控制 |
| 作用范围 | HTML 响应头 | HTML 响应头 | 代码控制(JS 拦截请求) |
| 实现复杂度 | 低(配置头即可) | 低 | 较高 |
| 对后端依赖 | 依赖响应头 | 依赖服务器验证逻辑 | 不依赖,纯前端 |
在项目架构里,三者的关系是“强缓存兜底静态资源 → 协商缓存兜底改动较频繁的资源 → Service Worker 做精细化控制与离线能力”。
5.2 静态资源、SPA 入口、接口数据、离线页面的选型公式
我列出四类最常见的缓存对象,给出我实际用的配置模板:
静态资源(JS/CSS/图片)
如果构建产物带 hash,用强缓存:
code复制Cache-Control: public, max-age=31536000, immutable
如果资源不带 hash,用协商缓存:
code复制Cache-Control: no-cache
ETag: "xxx"
这里有个细节:很多情况下,图片虽然不带 hash,但内容更新频率很低,你也可以给它 max-age=604800(7天),配合 CDN 的刷新能力来做可控更新。CDN 能主动刷新节点缓存,这是浏览器端做不到的,所以实践中“CDN 强缓存 + 源站协商缓存”是常见组合。
SPA 入口 HTML
必须 no-cache 或 no-store,不要贪恋强缓存。否则你发版后用户永远看到旧页面白屏,连 JS 新路由都加载不到。
接口数据
真实接口建议:
- 幂等 GET 接口:可用 SW 的 stale-while-revalidate 策略做即时响应和后台更新。
- 非幂等接口(POST、PUT、DELETE):一律
no-store,绝对不要碰缓存。
离线页面
这只能靠 Service Worker,强缓存和协商缓存都做不了。PWA 项目里 Network First 策略保证在线优先,网络失败时回退到预缓存的离线页。
5.3 缓存策略的组合拳
我在一个真实项目中就这么组合的:
一个 React 单页应用,部署到 Nginx + CDN。构建产物全部带 contenthash,配 1 年强缓存;入口 HTML 配 no-cache;后端接口分两类——文章列表走 SW 的 stale-while-revalidate,用户信息接口走 no-store;SW 本身注册了 network-first 页面导航策略,并缓存了 /offline.html。
这个组合的实测效果是:重复访客首屏基本能压进 1 秒,弱网环境下页面骨架秒开,完全断网时用户能看到离线页面而不是浏览器错误页。缓存不是单一机制,想要最好的体验,就得把不同层级的特性都利用起来。
6. 踩坑笔记:Service Worker 注册失败与缓存不生效
在真正动手之前,先泼几盆冷水。SW 的报错和“不生效”问题,是开发者最容易卡壳的地方。我把自己和团队踩过的坑归类整理一下。
6.1 浏览器提示 Service Worker 注册失败,怎么排查
我先列一个从大概率到小概率的排查顺序:
第一优先级:是否 HTTPS/localhost 环境
SW 只在安全上下文可用。如果你在局域网 IP 或纯 HTTP 环境下测试,注册必然失败。调试阶段用 localhost 或 127.0.0.1,生产环境必须配 HTTPS。
第二优先级:sw.js 文件路径和作用域
navigator.serviceWorker.register('/sw.js') 的路径是相对于页面 origin 的。如果页面 URL 是 https://example.com/admin/page,而你注册的是相对于当前路径的 sw.js,实际上请求的是 https://example.com/admin/sw.js,作用域就被限制在 /admin/ 下了。我的建议是始终使用绝对路径 /sw.js,放到站点 root 下,控制全站。
第三优先级:注册时机
load 事件后再注册是标准做法,因为 SW 注册下载脚本、启动线程可能影响首屏渲染。有些项目为了追求极致性能,会在 DOMContentLoaded 时注册,也可以,但必须保证页面主资源已经加载完。
第四优先级:缓存策略配置干扰
有的同学会顺手给 sw.js 配置强缓存,结果改动代码后浏览器永远拿不到新 SW 文件。这就是我在上文强调“sw.js 必须 no-cache”的原因。
6.2 处理“Could not register service worker: InvalidStateError”
这个报错我在项目里遇到过几次,翻译成人话就是“Service Worker 在当前的 state 下无法完成注册”。
常见触发场景:
- 浏览器在
unload或beforeunload阶段还在尝试注册 SW,导致状态异常。 - 同一作用域下已经有一个 SW 正在安装或激活中,你重复调用了
register且没有做失败处理。 - 页面是 iframe 嵌入的,内部文档的 SW 支持受限。
处理办法:把注册逻辑包在一个“安全时机”执行,并且避免重复注册。
javascript复制let registrationPromise = null;
function registerSW() {
if (!('serviceWorker' in navigator)) return;
if (registrationPromise) return registrationPromise;
registrationPromise = navigator.serviceWorker.register('/sw.js');
return registrationPromise;
}
window.addEventListener('load', () => {
registerSW().catch(err => {
// 报错不阻断页面,记日志即可
console.warn('SW registration failed:', err);
});
});
如果是在 iframe 或子应用环境中,先检查 window.top !== window.self,非顶层页面就不要注册 SW,交给顶层页面统一管理。
6.3 缓存“不生效”不等于代码有问题
很多时候你以为“缓存不生效”,其实是命中方式判断错了。浏览器开发者工具 Network 面板里:
disk cache代表磁盘缓存,强缓存命中。memory cache代表内存缓存,强缓存命中,比磁盘更快。304 Not Modified代表协商缓存命中。- 如果你看到请求实际走到服务器处理了,才是“没有命中任何缓存”。
另外,“刷新页面”和“从地址栏重新访问”的缓存策略不同。强制刷新(Ctrl+F5 / Cmd+Shift+R)会绕过强缓存,直接走协商缓存或重新请求,但不会绕过 SW 的 fetch 拦截。所以遇到 SW 策略下的资源不更新,强制刷新不一定好用,正确方式是在 Application 面板里点“Unregister SW”,然后清站点数据。
6.4 Service Worker 影响开发调试的解决方案
SW 在我日常开发里的确会“捣乱”。因为一旦注册成功,它会在后台持续拦截请求。改代码后画面不刷新、接口被缓存、状态不对,全是 SW 导致的。
解决思路很粗暴:开发环境(localhost 或 vite/webpack dev server)默认禁用 SW。我在项目里用一个环境变量控制:
javascript复制if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(...);
});
}
只有生产环境构建时才注册。如果你需要在生产环境里验证,手机上打开 Chrome 的“应用 → Service Workers”,有“Update”和“Unregister”两个按钮,非常有用。
6.5 SW 检测不到的常见误区
SW 有几个容易让人误判的“隐形行为”:
caches.match()匹配不上带 query 的 URL,因为完整 URL 是?a=1和?b=2是不同请求。解决:手动忽略 query 或者用ignoreSearch参数。- 跨域请求(比如 CDN 资源)默认不会被 SW 缓存,除非请求和响应允许 CORS。所以在
cache.addAll()里加入跨域 CDN 资源时,控制台可能报错。 - iOS Safari 的 SW 在锁屏后可能被系统冻结或回收,离线能力不稳定。实测 PWA 在线体验最佳的平台还是桌面端的 Chrome / Edge。
7. 最后一层经验:我平时怎么定缓存策略
以上是机制和实操,最后讲点不写在文档里的个人习惯。
我在做任何项目的缓存方案时,会先画一张“资源清单”,把所有资源分三类:核心页面骨架、静态资源、接口数据。然后按更新频率给每个资源打一个“变更风险”标签:
- 高风险(每次发版都可能变):HTML、JS/CSS 主文件、当前版本接口。
- 中风险(偶尔变):静态图片、运营配置、非关键接口。
- 低风险(基本不变):字体文件、图标库、第三方库。
对应的策略就出来了:高风险不缓存或短缓存,中风险协商缓存或 SW 更新,低风险强缓存一年。
这比纯技术选型更重要——你先判断资源的“生命预期”,再决定用哪种缓存。顺序别搞反,否则技术配置再完备,也会频繁出现用户看到残缺页面的情况。
另外说个常见的受众误解:强缓存不等于“不更新”,协商缓存不等于“每次下载完整文件”,SW 也不等于“只能做离线页面”。它们都只是“帮浏览器做本地资源复用”的手段,区别在于你能控制的精细度。
如果只记一句话,我会选择这句:带 hash 的静态资源,放开手强缓存;不带 hash 的入口和接口,用协商缓存兜底;想要离线或者全局拦截器,再用 Service Worker。 这样配置下来,既稳又省事,日常开发不会被缓存折腾到怀疑人生。
