前端也要懂 HTTP 缓存机制
Category(分类): Browser, HTTP Cache Status: 已更新
原文通过 Express 手动添加响应头,观察
Expires、Cache-Control、Last-Modified和ETag的效果。这个实验方法很适合学习,本文保留实验路线和示意图,并修正原文中的字段拼写、缓存优先级和“每次都会重新请求”等不准确表述。
一、HTTP 请求和响应
浏览器与服务器通过 HTTP 请求-响应进行通信。请求和响应都由起始行、头部字段和可选消息内容组成:

GET /assets/demo.js HTTP/1.1
Host: localhost:8080
Accept: */*
HTTP/1.1 200 OK
Content-Type: text/javascript; charset=utf-8
Cache-Control: no-cache
ETag: "demo-v1"
console.log('demo')
与缓存相关的常见字段:
| 字段 | 方向 | 作用 |
|---|---|---|
Cache-Control | 请求/响应 | 控制缓存新鲜度、验证和存储策略 |
Expires | 响应 | HTTP/1.0 风格的绝对过期时间 |
Last-Modified | 响应 | 资源最近修改时间 |
If-Modified-Since | 请求 | 使用上次的 Last-Modified 进行条件验证 |
ETag | 响应 | 服务器生成的实体标签 |
If-None-Match | 请求 | 使用上次的 ETag 进行条件验证 |
Vary | 响应 | 声明哪些请求头会影响缓存变体 |
Pragma | 请求/历史响应 | HTTP/1.0 时代的兼容字段,现代项目不应依赖它控制全部缓存 |
二、HTTP 缓存的基本分类
HTTP 标准更常用“私有缓存”和“共享缓存”描述缓存位置:
- 私有缓存:通常是用户浏览器自己的缓存,可以保存个性化响应;
- 共享缓存:代理、CDN、反向代理等可能为多个用户复用响应,不能保存未隔离的个性化内容。
前端资料常把行为分为:
- 强缓存:响应仍 fresh 时直接复用,不访问源站;
- 协商缓存:响应需要验证时发送条件请求,未变化返回
304。
这是学习模型,不是浏览器内部唯一的固定算法。Service Worker、Cache API、BFCache、缓存分区、重定向、请求 cache 模式和 Vary 都可能改变最终结果。
三、先运行一个没有显式缓存策略的服务器
const express = require('express')
const fs = require('node:fs')
const path = require('node:path')
const app = express()
const port = 8080
const filePath = path.join(__dirname, 'static', 'demo.js')
app.get('/', (req, res) => {
res.type('html').send(`<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>HTTP Cache Demo</title></head>
<body>
<p>HTTP Cache Demo</p>
<script src="/demo.js"></script>
</body>
</html>`)
})
app.get('/demo.js', (req, res, next) => {
fs.readFile(filePath, (error, content) => {
if (error) return next(error)
return res.type('js').send(content)
})
})
app.listen(port, () => {
console.log(`http://localhost:${port}`)
})

没有显式 Cache-Control 时,HTTP 规范允许缓存根据启发式规则存储和复用某些响应;浏览器还可能因为刷新、开发者工具设置、请求上下文和缓存策略重新验证。因此不能绝对说“第二次访问一定重新读取磁盘”,也不能只凭代码判断浏览器是否命中缓存,应以 Network 面板和响应头为准。
生产项目通常直接使用 express.static,它可以提供 ETag 和 Last-Modified;是否设置长缓存应根据 URL 是否内容指纹化决定。
四、强缓存实验
1. Expires
Expires 使用 GMT 绝对时间:
app.get('/expires.js', (req, res, next) => {
fs.readFile(filePath, (error, content) => {
if (error) return next(error)
const expiresAt = new Date(Date.now() + 2 * 60 * 1000)
res.set('Expires', expiresAt.toUTCString())
return res.type('js').send(content)
})
})

如果当前响应仍然 fresh,浏览器可能直接从 memory cache 或 disk cache 读取。两分钟后,缓存变陈旧,浏览器可能重新请求或进行协商验证,具体还取决于是否存在其他验证器。
Expires 的缺点是依赖绝对时间,客户端和服务器时钟不一致可能造成判断偏差。它并没有“失效”,只是现代 HTTP/1.1 应优先使用 Cache-Control: max-age。
2. Cache-Control: max-age
app.get('/cache-control.js', (req, res, next) => {
fs.readFile(filePath, (error, content) => {
if (error) return next(error)
res.set('Cache-Control', 'public, max-age=120')
return res.type('js').send(content)
})
})

max-age=120 表示响应从生成起在 120 秒内通常可直接复用。响应中的 max-age 与请求中的 Cache-Control: max-age=0 含义不同:前者控制缓存响应的新鲜度,后者是客户端对可接受响应年龄的要求。
常见指令:
# 内容指纹静态资源
Cache-Control: public, max-age=31536000, immutable
# HTML 入口或经常更新的 API
Cache-Control: no-cache
# 个性化数据,只允许浏览器缓存但每次验证
Cache-Control: private, no-cache
# 不希望任何缓存保存
Cache-Control: no-store
no-cache 不是“禁止缓存”,而是允许存储、复用前必须验证;no-store 才是要求不要存储响应。public 也不表示“所有内容默认必须 public”,个性化响应应考虑 private。
五、协商缓存实验
强缓存变陈旧后,浏览器可以用验证器询问服务器。如果资源未变化,服务器返回 304 Not Modified,通常不带响应体,浏览器继续使用本地响应。

1. Last-Modified / If-Modified-Since
app.get('/last-modified.js', (req, res, next) => {
fs.stat(filePath, (statError, stat) => {
if (statError) return next(statError)
const lastModified = stat.mtime.toUTCString()
const headers = {
'Cache-Control': 'no-cache',
'Last-Modified': lastModified
}
if (req.headers['if-modified-since'] === lastModified) {
return res.status(304).set(headers).end()
}
fs.readFile(filePath, (readError, content) => {
if (readError) return next(readError)
return res.status(200).set(headers).type('js').send(content)
})
})
})

它的缺点包括:HTTP 日期通常只能精确到秒;文件可能在短时间内多次变化;文件可能被修改但内容又恢复原样。对于动态服务和分布式服务器,文件修改时间还需要保持一致。
2. ETag / If-None-Match
ETag 是服务器生成的实体标签,可以是内容哈希、版本号或其他稳定标识。示例使用 SHA-256 生成强 ETag:
const crypto = require('node:crypto')
app.get('/etag.js', (req, res, next) => {
fs.readFile(filePath, (error, content) => {
if (error) return next(error)
const etag = `"${crypto.createHash('sha256').update(content).digest('hex')}"`
const headers = {
'Cache-Control': 'no-cache',
ETag: etag
}
// 教学版只比较单个 ETag;生产环境应支持多值和 W/ 弱验证器。
if (req.headers['if-none-match'] === etag) {
return res.status(304).set(headers).end()
}
return res.status(200).set(headers).type('js').send(content)
})
})

标准请求头的拼写是 If-None-Match,不是原文中的 If-no-match。ETag 和 Last-Modified 可以同时返回;如果条件请求同时包含 If-None-Match 和 If-Modified-Since,支持 ETag 的服务器应优先处理 ETag。
六、Pragma:保留它的历史位置

Pragma: no-cache 是 HTTP/1.0 时代的兼容字段,历史上常用于要求缓存验证。现代 HTTP 应使用 Cache-Control 表达缓存策略:
Cache-Control: no-cache
浏览器强制刷新时可能同时发送:
Pragma: no-cache
Cache-Control: no-cache
这不意味着存在一个适用于所有浏览器的固定“Pragma > Cache-Control > Expires > ETag > Last-Modified”优先级表。响应缓存新鲜度、请求缓存意图和条件验证是不同层面的规则,必须结合具体请求和响应分析。
七、如何观察实验结果?
1. 浏览器 Network 面板
打开 DevTools 的 Network 面板后观察:
Size是否显示 memory cache、disk cache 或实际传输;- 请求是否带
If-None-Match、If-Modified-Since; - 响应是
200、304还是被 Service Worker 返回; Cache-Control、Expires、ETag、Last-Modified和Vary的具体值;- 是否因重定向、认证、Cookie 或请求头变化导致缓存不能共享。
DevTools 中的 “Disable cache” 只在开发者工具打开时影响当前页面调试,不是服务器配置,也不代表真实用户一定禁用缓存。
2. 使用 curl 验证
# 查看首次响应头
curl -i http://localhost:8080/etag.js
# 从上一次响应复制 ETag 后验证
curl -i \
-H 'If-None-Match: "复制的etag"' \
http://localhost:8080/etag.js
3. 使用 Fetch 的缓存模式
// 默认由浏览器根据 HTTP 缓存规则决定
fetch('/api/data')
// 允许使用缓存,但复用前向源站验证
fetch('/api/data', { cache: 'no-cache' })
// 要求从网络重新获取(仍受浏览器和网络策略影响)
fetch('/api/data', { cache: 'reload' })
Fetch 的 cache 选项不是关闭所有浏览器、Service Worker、CDN 和服务器缓存的万能开关。
八、实际项目的缓存策略
| 资源 | 推荐策略 |
|---|---|
| 文件名带内容哈希的 JS/CSS/字体 | public, max-age=31536000, immutable |
| HTML 入口 | no-cache,需要及时验证新版本 |
| 个性化 API | private, no-cache,或按隐私要求使用 no-store |
| 密码、支付和一次性响应 | 通常 no-store |
| 图片 | 根据是否指纹化和更新频率设置 max-age |
| CDN 公共资源 | public/s-maxage,并正确设置 Vary |
还应注意:
Vary: Accept-Encoding区分 gzip、br 等压缩变体;- 根据
Origin动态返回 CORS 头时,通常需要Vary: Origin; - 不要用
Vary: User-Agent产生大量低命中率变体,能特性检测就不要按 UA 分支; - 不要给未版本化的 HTML、JS 文件设置一年 immutable;
- Service Worker 的 Cache API 是独立的可编程缓存层,需要版本和清理策略;
- 后退前进可能命中 BFCache,
no-cache也不保证历史恢复一定重新请求。
九、原文中需要修正的字段和结论
Expire应为Expires;If-Modified-Sice应为If-Modified-Since;If-no-match应为If-None-Match;Etag在文档中通常写作ETag,大小写不影响字段语义,但建议使用标准写法;- 没有缓存头不代表浏览器一定每次从服务器重新取资源,可能发生启发式缓存;
Expires没有被“完全废弃”,只是Cache-Control: max-age更现代、更可靠;no-cache不是禁止缓存,no-store也不会删除已经存在的旧响应;304是条件验证成功的无正文响应,不是把完整文件再次传给浏览器;- 不存在可机械套用到所有场景的缓存头优先级链。
总结
- 先区分浏览器私有缓存、CDN 共享缓存、Service Worker Cache API 和 BFCache;
Cache-Control: max-age控制新鲜度,Expires是兼容性的绝对时间;no-cache要求复用前验证,no-store要求不存储;Last-Modified精度有限,ETag 可以更准确地表示资源版本;If-None-Match、If-Modified-Since是请求条件,匹配时服务器返回304;- 内容指纹文件可以长缓存,HTML 入口通常需要
no-cache; - 用 Network 面板和实际响应头验证,不要只背固定的缓存优先级。