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 的时间比真
