关于浏览器缓存你知道多少?
Category(分类): Browser, HTTP Cache Status: 已更新
原文主要介绍 HTTP 强缓存、协商缓存、
Expires、Cache-Control、Last-Modified和ETag。这些概念仍然是前端面试和性能优化的基础,本文保留原来的学习路径,同时补充私有缓存/共享缓存、Vary、内容指纹、Service Worker、BFCache 和现代缓存策略。
一、先区分“浏览器存储”和“HTTP 缓存”
HTML5 的 Web Storage(localStorage、sessionStorage)是 JavaScript 可读写的字符串键值存储;HTTP 缓存保存请求和响应,主要由浏览器、代理、CDN 或 Service Worker 管理。它们都能减少重复工作,但 API、生命周期和安全边界不同。
旧的 HTML Application Cache(manifest/.appcache)已经被主流浏览器移除,不要为新项目使用。离线应用应使用 Service Worker 和 Cache API,并自行设计版本、更新和失效策略。
HTTP 缓存的目标是:在不牺牲正确性和隐私的前提下,复用仍然新鲜的响应,或只向服务器询问“资源是否变化”,避免重复传输完整响应。
二、HTTP 缓存的两种基本模型
按照 HTTP 缓存标准,可以先区分:
- 私有缓存(private cache):通常是当前用户浏览器的缓存,可以保存个性化响应;
- 共享缓存(shared cache):位于客户端和源站之间,例如代理、CDN 和反向代理,可能被多个用户复用。
“强缓存”和“协商缓存”是前端常用的学习模型:
- 响应仍然新鲜时,缓存可以直接复用,不访问源站;
- 响应变为陈旧后,缓存可以携带验证器向服务器重新验证;
- 未变化时服务器返回
304 Not Modified,变化时返回新的200响应。
真实浏览器还会考虑请求方法、请求头、Vary、凭证、缓存模式、Service Worker、重定向、历史恢复和缓存分区,因此不能把它理解成永远固定的两步判断。
三、强缓存:新鲜响应直接复用

响应的 Cache-Control 和 Expires 等字段决定新鲜度。命中时,Network 面板可能显示 memory cache、disk cache 或类似的缓存命中信息;这不等于每个浏览器都会展示完全相同的文字。
1. Expires
Expires 使用绝对时间:
Expires: Wed, 27 Oct 2027 07:55:30 GMT
客户端将当前时间与该时间比较。它是 HTTP/1.0 时代的字段,容易受到时钟偏差和解析兼容性影响。现代服务应优先设置 Cache-Control: max-age=...,但为了兼容旧客户端,仍可能同时返回 Expires。
如果同时存在 Cache-Control: max-age 和 Expires,现代 HTTP 缓存规则优先采用 max-age。HTTP 日期使用 GMT,不应使用本地时区字符串。
2. Cache-Control
Cache-Control: public, max-age=600
响应中的 max-age=600 表示响应从生成起在 600 秒内通常保持 fresh。共享缓存还要考虑 Age 和 s-maxage。
常用响应指令:
| 指令 | 含义 |
|---|---|
max-age=秒数 | 响应在指定时间内可视为新鲜 |
s-maxage=秒数 | 只控制共享缓存的新鲜时间,通常覆盖共享缓存的 max-age |
public | 允许共享缓存保存;不是所有响应都必须显式写它 |
private | 只能存入私有缓存,适合个性化响应 |
no-cache | 可以存储,但复用前必须向源站验证;不是“禁止缓存” |
no-store | 要求缓存不要存储该响应;不会自动删除已经存在的旧缓存 |
must-revalidate | 变陈旧后必须验证,不能在与源站断开时随意复用 |
immutable | 在 fresh 期间声明内容不会改变,适合内容指纹资源 |
stale-while-revalidate | 在指定窗口内允许复用陈旧响应,同时后台验证 |
public、private、no-cache 和 no-store 不能简单当成一条优先级链,它们控制的是不同维度。个性化内容应至少考虑:
Cache-Control: private, no-cache
敏感响应如果完全不应被存储,可以使用:
Cache-Control: no-store
但不要为了“保险”给所有响应都设置 no-store,否则会失去 HTTP 缓存、离线能力和部分性能收益。no-store 也不会清除已经存储的旧响应。
3. 内容指纹和长缓存
不会改变 URL 的静态资源不适合永久缓存。现代构建工具通常为文件名加入内容哈希:
<script src="/assets/app.8d31c2.js"></script>
<link rel="stylesheet" href="/assets/style.4fa91e.css">
当内容改变时生成新 URL,旧 URL 仍然可以安全长缓存:
Cache-Control: public, max-age=31536000, immutable
HTML 入口通常需要更快感知新版本,可以使用:
Cache-Control: no-cache
这允许浏览器保存 HTML,但每次复用前向服务器验证;服务器没有变化时仍可返回 304。
四、协商缓存:验证资源有没有变化

当响应需要验证时,浏览器会把之前响应中的验证器放入请求头。服务器确认资源没有变化时返回 304 Not Modified,通常不带响应体;浏览器继续使用本地响应,并根据新的响应头更新缓存元数据。
1. Last-Modified / If-Modified-Since
首次响应:
HTTP/1.1 200 OK
Last-Modified: Fri, 27 Oct 2027 07:55:30 GMT
Cache-Control: no-cache
...resource body...
验证请求:
GET /assets/app.js HTTP/1.1
If-Modified-Since: Fri, 27 Oct 2027 07:55:30 GMT
资源没有变化时:
HTTP/1.1 304 Not Modified
Last-Modified: Fri, 27 Oct 2027 07:55:30 GMT
Cache-Control: no-cache
这个方案容易实现,但 HTTP 日期精度通常为秒:同一秒内多次修改、文件时间同步问题或内容改回原样时,单靠修改时间可能不够准确。
2. ETag / If-None-Match
首次响应:
ETag: "app-8d31c2"
Cache-Control: no-cache
验证请求:
If-None-Match: "app-8d31c2"
服务器根据当前资源的 ETag 比较:
GET/HEAD的 ETag 匹配:返回304;- 不匹配:返回新的
200和新的响应体; - 修改类请求还可以使用
If-Match防止覆盖其他人的更新,失败时返回412 Precondition Failed。
ETag 可以是内容哈希、版本号、文件元数据或其他服务器生成的标识,不强制要求使用 MD5。ETag 应包含双引号,弱验证器可能以 W/ 开头。
如果请求同时带有 If-None-Match 和 If-Modified-Since,支持 ETag 的服务器应优先处理 If-None-Match。实际项目通常同时返回 ETag 和 Last-Modified,以兼容不同客户端并支持其他 HTTP 工具。
五、一个可运行的 Express 缓存示例
1. 推荐:静态资源使用 express.static
Express 静态中间件本身可以提供 ETag 和 Last-Modified。Node.js 18+ 的 Express 5 示例:
const express = require('express')
const path = require('node:path')
const app = express()
const port = 8080
// 内容指纹资源:文件内容变化时文件名也变化,可以长时间缓存。
app.use('/assets', express.static(path.join(__dirname, 'static'), {
etag: true,
lastModified: true,
maxAge: '1y',
immutable: true
}))
// HTML 入口:允许缓存,但访问时验证新版本。
app.get('/', (req, res) => {
res.set('Cache-Control', 'no-cache')
res.type('html').send(`<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>HTTP Cache Demo</title></head>
<body><script src="/assets/demo.js"></script></body>
</html>`)
})
app.listen(port, () => {
console.log(`http://localhost:${port}`)
})
不要给会经常变化的 demo.js 直接加一年缓存,除非它的 URL 会随内容指纹变化。
2. 手动演示验证器
如果想观察请求头和 304,可以写一个简化版本。示例仅用于学习,真实服务应处理文件不存在、并发读取、压缩变体和多值 ETag:
const crypto = require('node:crypto')
const fs = require('node:fs')
app.get('/demo.js', (req, res, next) => {
const filePath = path.join(__dirname, 'static', 'demo.js')
fs.stat(filePath, (statError, stat) => {
if (statError) return next(statError)
fs.readFile(filePath, (readError, body) => {
if (readError) return next(readError)
const lastModified = stat.mtime.toUTCString()
const etag = `"${crypto.createHash('sha256').update(body).digest('hex')}"`
const headers = {
'Cache-Control': 'no-cache',
'Content-Type': 'text/javascript; charset=utf-8',
ETag: etag,
'Last-Modified': lastModified
}
// 教学版只处理一个 ETag;生产环境应解析逗号分隔的值和 W/ 前缀。
if (req.headers['if-none-match'] === etag) {
return res.status(304).set(headers).end()
}
if (
!req.headers['if-none-match'] &&
req.headers['if-modified-since'] === lastModified
) {
return res.status(304).set(headers).end()
}
return res.status(200).set(headers).send(body)
})
})
})
304 不是“服务器再次发送了缓存内容”,而是服务器确认浏览器已有的表示仍可使用。304 响应不应包含新的响应体。
六、Vary:缓存还要看哪些请求头?
同一个 URL 的响应可能因 Accept-Encoding、Accept-Language、Origin 等请求头不同而不同。此时需要明确告诉缓存区分方式:
Vary: Accept-Encoding, Origin
例如服务端根据请求的 Origin 动态返回 Access-Control-Allow-Origin,就应考虑 Vary: Origin,避免共享缓存把一个来源的 CORS 响应错误地复用给另一个来源。
不要轻易使用 Vary: User-Agent,它可能产生大量缓存变体,显著降低命中率。能用特性检测解决的问题,不要按 User-Agent 做大量分支。
七、刷新、强制刷新和 BFCache
开发者工具中的“刷新”“强制刷新”、地址栏重新导航和后退前进不是同一个缓存动作:
- 普通刷新可能发送
Cache-Control: max-age=0并携带条件请求头; - 强制刷新通常会发送
Cache-Control: no-cache,可能同时带Pragma: no-cache; Pragma是 HTTP/1.0 时代的兼容字段,不能简单说它永远比Cache-Control优先;- 后退前进可能命中 BFCache,直接恢复页面快照,不一定重新验证 HTTP 响应;
- JavaScript 中
fetch(url, { cache: 'no-cache' })表示复用前验证,cache: 'reload'才更接近要求重新从网络获取。
如果要测试缓存,打开 Network 面板并注意“Disable cache”只在 DevTools 打开期间生效,不能把开发工具行为当成线上用户行为。
八、Service Worker 和 Cache API
Service Worker 可以拦截受其 scope 控制的 Fetch,并使用 Cache API 建立自定义缓存:
self.addEventListener('fetch', event => {
const request = event.request
if (request.destination !== 'script') return
event.respondWith(
caches.match(request).then(cached => {
const network = fetch(request).then(response => {
const copy = response.clone()
caches.open('assets-v1').then(cache => cache.put(request, copy))
return response
})
return cached || network
})
)
})
Cache API 不是 HTTP 缓存的简单别名:
- Service Worker 可以自定义缓存优先、网络优先、过期回源等策略;
Responsebody 是流,缓存前通常需要response.clone();- 需要设计缓存版本和旧缓存清理;
- 不要缓存包含用户敏感数据的响应;
- Service Worker 的缓存策略可能覆盖开发者对 HTTP 缓存的直觉判断。
九、常见缓存策略
| 资源 | 推荐示例 | 原因 |
|---|---|---|
| 内容指纹 JS/CSS/字体 | public, max-age=31536000, immutable | URL 变化代表内容变化 |
| 图片 | 按是否指纹化设置较长 max-age | 兼顾带宽和更新速度 |
| HTML 入口 | no-cache | 可以验证并及时获取新资源引用 |
| 个性化 API | private, no-cache 或按需求 no-store | 避免共享缓存泄露 |
| 密码、支付、一次性响应 | 通常 no-store | 不希望客户端或中间缓存保存 |
| CDN 公共响应 | public + s-maxage + 合适 Vary | 将共享缓存策略说清楚 |
不要只看到“命中缓存”就认为一定更快:缓存命中依然可能有存储读取、Service Worker 执行、解压、解析和主线程成本;响应也必须与当前请求上下文匹配。
十、把原文中的字段错误统一修正
Expire应写作响应头Expires;If-Modified-Sice应写作If-Modified-Since;If-no-match应写作If-None-Match;Last-Modified和ETag是响应验证器,If-*字段是客户端后续请求携带的条件;no-cache不是“不缓存”,no-store才是“不存储”;ETag不一定是文件 MD5,服务器可以使用版本号或其他标识;- 不存在适用于所有浏览器、所有场景的“Pragma > Cache-Control > Expires > ETag > Last-Modified”固定优先级表;
- 服务器返回
304时通常不带实体正文,浏览器复用已有响应体。
总结
- HTTP 缓存分为私有缓存和共享缓存,强缓存/协商缓存是理解缓存行为的常用模型;
Cache-Control: max-age是现代新鲜度控制核心,Expires主要用于兼容;no-cache要求复用前验证,no-store要求不存储,二者不能混为一谈;ETag与Last-Modified都有用,If-None-Match在验证时优先级更高;- 内容指纹 + 长缓存适合静态资源,HTML 入口通常使用
no-cache; Vary、Cookie、CORS、Service Worker、CDN 和 BFCache 都会影响最终结果;- 缓存配置必须结合资源是否个性化、是否可版本化和实际 Network 记录验证。