缓存策略
🎯 引言
用户第一次打开你的网站,浏览器下载了 HTML、JS、CSS、图片等一堆资源。第二次打开时,这些资源还要重新下载一遍吗?如果每次都重新下载,页面加载慢、服务器压力大、用户流量也白白浪费。HTTP 缓存就是来解决这个问题的:把下载过的资源存在浏览器本地,下次直接用。
学完本篇,你将能:
- 说清楚强缓存和协商缓存分别是什么、如何配合工作。
- 看懂
Cache-Control、Expires、ETag、Last-Modified这些响应头的含义。 - 理解为什么构建工具要给 JS/CSS 文件名加内容哈希。
- 为自己的项目制定一套合理的缓存策略。
🧱 为什么缓存很重要
先看一组生活化的对比。你去图书馆借了一本书回家看,第二天想再看一遍,你会直接翻家里的那本,而不是再跑一趟图书馆。浏览器缓存就是「把书借回家」:资源下载过一次之后,存在本地,下次请求时先看本地有没有可用的副本。
没有缓存时,每次访问页面都要重新下载所有资源,网络请求多、等待时间长。有了缓存之后,重复访问时浏览器直接使用本地副本,很多请求根本不需要发出去,页面加载自然更快,服务器的压力也随之减轻。
HTTP 缓存分为两层,按优先级依次生效:
- 强缓存:本地副本还没过期,直接用,完全不发请求。
- 协商缓存:本地副本过期了,发个请求问服务器「内容变了吗」,没变就继续用本地的,变了才下载新内容。
下面我们分别来看这两层是怎么工作的。
🧱 强缓存:没过期就一言不发
强缓存的核心是一句话:只要缓存没过期,浏览器根本不会向服务器发请求,直接用本地副本。
Expires:旧的方案
Expires 是 HTTP/1.0 时代的响应头,服务器告诉浏览器「这份资源在这个时间点之前是新鲜的」:
HTTP/1.1 200 OK
Content-Type: text/css
Expires: Wed, 21 Jul 2027 02:45:43 GMT
浏览器看到 Expires 后,会记住这个过期时间点。过期之前再次请求这个资源,直接用本地缓存。
这个方案有个明显的缺陷:它依赖用户电脑的本地时间。如果用户的系统时间不准(比如手动调快了),浏览器判断的「是否过期」就是错的。因此它逐渐被更可靠的方案取代,现在主要作为兼容手段出现。
Cache-Control:主流方案
Cache-Control 是 HTTP/1.1 的响应头,用相对时长代替绝对时间点,不再受用户本地时间影响:
HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=31536000
max-age=31536000 表示:从这次响应开始算,这份资源在 31536000 秒(一年)内都是新鲜的。浏览器拿到响应后记下「收到时间 + max-age」,在有效期内再次请求,直接使用本地缓存,请求压根不会发出去。
两个响应头同时出现时,Cache-Control 的优先级高于 Expires。现代项目里通常只配置 Cache-Control。
这里先记住一个结论:Cache-Control 里带有效的 max-age=秒数,就是强缓存。它还有另外两个常见取值,含义正好相反,都是「不使用强缓存」:
no-cache:名字容易误导,它不是「不缓存」,而是「不走强缓存」,每次使用前都要先找服务器确认一次。至于怎么确认,正是下一节「协商缓存」要讲的内容。no-store:这才是「完全不缓存」,每次都要重新下载。
(disk cache) 或 (memory cache),Status 列显示灰色的 200,这是判断强缓存是否生效的直观办法。🧱 协商缓存:过期了就问一句
强缓存到期之后,本地副本不能直接用了,但也不代表内容一定变了。这时进入协商缓存:浏览器带着缓存的「身份证明」去问服务器,内容没变就继续用本地副本,变了才下载新的。
ETag 与 If-None-Match
ETag 是服务器给资源生成的一个标识串,可以理解成资源的「指纹」,内容一变指纹就变。服务器第一次响应时带上它:
HTTP/1.1 200 OK
Content-Type: text/css
ETag: "abc123"
缓存过期后,浏览器再次请求时,会把这个指纹放进 If-None-Match 请求头里一起发过去:
GET /style.css HTTP/1.1
Host: example.com
If-None-Match: "abc123"
服务器对比指纹:内容没变,就返回 304 Not Modified,响应里只有头部、没有正文,浏览器收到后继续用本地缓存:
HTTP/1.1 304 Not Modified
ETag: "abc123"
内容变了,则正常返回 200 和新内容,同时附上新的 ETag。由于 304 响应不携带正文,传输的数据量很小,比重新下载整个文件省得多。
Last-Modified 与 If-Modified-Since
这是另一组协商缓存的搭档,思路是用「修改时间」代替「指纹」。服务器响应时带上资源的修改时间:
HTTP/1.1 200 OK
Content-Type: text/css
Last-Modified: Wed, 21 Jul 2026 02:45:43 GMT
缓存过期后,浏览器带着这个时间去问:
GET /style.css HTTP/1.1
Host: example.com
If-Modified-Since: Wed, 21 Jul 2026 02:45:43 GMT
服务器发现资源在这个时间之后没改过,同样返回 304 Not Modified。
ETag 优先于 Last-Modified
两组搭档同时存在时,服务器优先用 ETag 判断。原因是时间的精度不够用:Last-Modified 只能精确到秒,如果文件在一秒内被修改了两次,第二次修改就识别不出来。另外有些文件会被重新保存但内容没变,修改时间更新了,会白白触发一次重新下载,而 ETag 按内容生成指纹,不受这个影响。
整个协商缓存的流程可以概括成一句话:缓存过期后先问一句,没变就 304 继续用,变了才下载新的。
⚡ 强缓存 vs 协商缓存
两种缓存各有分工,用一张表对比它们的差异:
| 对比项 | 强缓存 | 协商缓存 |
|---|---|---|
| 触发时机 | 缓存未过期时 | 缓存已过期时 |
| 是否发请求 | 不发,直接用本地副本 | 发请求,向服务器确认 |
| 相关响应头 | Cache-Control、Expires | ETag、Last-Modified |
| 相关请求头 | 无 | If-None-Match、If-Modified-Since |
| 服务器响应 | 无(请求未发出) | 内容没变返回 304,变了返回 200 |
| 传输的数据量 | 0 | 只传响应头(304 时),不传输资源正文 |
| 典型状态码 | 200(来自缓存) | 304 Not Modified |
可以看到,强缓存省掉了整个请求,代价是过期前拿不到更新;协商缓存每次都要问一次服务器,但能保证及时拿到新内容。实际项目里两者搭配使用,下一节就是具体怎么搭。
🛠 静态资源的缓存实践
理论讲完了,落到真实项目里,缓存策略该怎么定?关键在于:不同资源要区别对待。
带哈希的文件名:超长缓存的底气
现代构建工具(Webpack、Vite 等)打包 JS/CSS 时,会根据文件内容在文件名里加一段哈希,例如 app.a1b2c3d4.js。内容只要改一个字节,哈希就变,文件名也随之变成 app.e5f6g7h8.js。
这个机制带来一个很好用的性质:内容变了,文件名就是全新的,浏览器会把它当成一个新资源去下载,旧缓存自然失效,不需要任何人工干预。
所以对于这类带哈希的文件,可以放心设置超长的 max-age(注意这是服务器的响应头,配置方法见下一节):
Cache-Control: max-age=31536000
一年之内,用户再次访问都直接用本地缓存;而项目一发新版,新文件名的资源又会自动被下载。关于构建工具如何生成哈希文件名,Vite 课程里有更详细的介绍,这里不展开。
HTML 入口文件:不能久放
HTML 是页面的入口,里面引用了带哈希的 JS/CSS 地址。如果把 HTML 也缓存一年,用户拿到的一直是旧 HTML,引用的还是旧哈希的 JS,新版本就发不出去了。
所以 HTML 入口文件要用协商缓存或较短的缓存时间,保证用户能及时拿到新版本:
Cache-Control: no-cache
no-cache 表示每次使用前都向服务器确认一次:没变就 304 用本地的(开销很小),变了就拿新 HTML,进而引用新哈希的 JS/CSS。这样整个更新链路就通了。
用 Express 配置缓存响应头
理解了策略,写代码就不难了。下面是一个精简的 Express 示例(本地验证环境:Node.js v22 + express v4),给带哈希的静态资源设置超长缓存,给 HTML 设置协商缓存:
const express = require('express');
const app = express();
// 带哈希的静态资源:超长强缓存
app.use(
'/assets',
express.static('dist/assets', {
maxAge: '365d', // 等价于 max-age=31536000
}),
);
// HTML 入口文件:每次使用前都找服务器协商
app.use(
express.static('dist', {
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
},
}),
);
app.listen(3000);
如果你还不熟悉 Express 的静态资源托管,可以先看 Express 课程里的相关章节,这里只需要关注 maxAge 和 setHeaders 这两个配置点。
Cache-Control 是服务器返回的响应头,配置在部署项目的服务器上(Nginx、CDN 或 Express 这类服务),前端 JS 代码设置不了它。🧾 小节总结
- HTTP 缓存分两层:强缓存(未过期直接用本地副本,不发请求)和协商缓存(过期后问服务器,没变返回 304 继续用)。
- 强缓存用
Cache-Control: max-age=秒数,是主流方案;Expires是旧方案,依赖用户本地时间,容易判断失误。 - 协商缓存靠
ETag/If-None-Match和Last-Modified/If-Modified-Since两组搭档,ETag 按内容生成指纹,优先级更高,因为修改时间精度只到秒。 no-cache不是不缓存,而是每次使用前都先协商;no-store才是完全不缓存。- 实践策略:带内容哈希的 JS/CSS 设置超长
max-age,内容变了文件名就变、缓存自然失效;HTML 入口文件用no-cache走协商缓存,保证新版本及时生效。 Cache-Control是服务器响应头,要在部署的服务器(Nginx、CDN、Express 等)上配置,前端 JS 设置不了。
❓ 知识问答
Q1:强缓存生效时,刷新页面还会走缓存吗?
A:普通刷新(F5)通常仍会命中强缓存。强制刷新(Ctrl/Cmd + Shift + R)会绕过本地缓存,直接向服务器请求所有资源,排查缓存问题时经常用到。
Q2:no-cache 和 no-store 有什么区别?
A:no-cache 会把资源存下来,但每次使用前都要向服务器确认,相当于直接进入协商缓存。no-store 是完全不存储,每次都要重新下载,常用于包含敏感信息的响应。
Q3:既然有 ETag,为什么还要保留 Last-Modified?
A:Last-Modified 主要作为兜底和兼容手段存在。两组头同时出现时,服务器优先用 ETag 判断;有些场景下(比如只关心粗略的修改时间)Last-Modified 也够用。
Q4:HTML 为什么不设置 max-age=0 而要写 no-cache?
A:两者效果接近,都表示每次使用前要向服务器确认。no-cache 的语义更直白,是现代项目的常用写法。
Q5:用户一直拿到旧版本的 JS,可能是什么原因?
A:常见的嫌疑是 HTML 被缓存了:旧 HTML 里引用的是旧哈希的 JS 地址。检查 HTML 响应头是否设置了协商缓存,必要时在 HTML 的响应头里加上 Cache-Control: no-cache。
Q6:缓存响应头能在前端 JS 代码里设置吗?
A:不能。响应头是服务器返回资源时带上的,前端 JS 拿不到这个环节,要在部署项目的服务器(Nginx、CDN 等)上配置。
🧪 小练习
练习一:基于上面的 Express 示例,补充一个「图片资源缓存 30 天」的规则,并启动服务后,用 Chrome DevTools 的 Network 面板验证各资源的 Cache-Control 响应头是否符合预期。
const express = require('express');
const app = express();
app.use(
express.static('dist', {
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
// 请在这里编写代码:以 .png / .jpg 结尾的文件,设置 max-age 为 30 天(2592000 秒)
},
}),
);
app.listen(3000);
练习二:找一个你经常访问的网站,打开 Chrome DevTools 的 Network 面板刷新页面,观察几个请求的 Status 和 Size 列:找出一条命中强缓存的请求和一条返回 304 的请求,看看它们各自的响应头里带了哪些缓存相关字段。
🎉 恭喜你已经掌握 HTTP 缓存策略啦!现在你不仅知道强缓存和协商缓存怎么工作,还能为不同资源制定「静态资源超长缓存、HTML 走协商」的实用方案。下一篇我们进入构建优化,看看怎么从打包产物本身入手给页面提速。
