调试前端缓存的时候,我见过太多人栽在HTML缓存上:静态资源老老实实带hash、设max-age,一切井然有序;一到HTML文件本身,要么全量禁用缓存导致每次刷新都白等,要么无脑长缓存导致用户看到十天前的旧页面。HTML是所有页面的入口,它一旦被浏览器缓存住了,后续只有两条路——要么304握手确认没变,要么老老实实重新拉一遍。这篇就聊怎么手搓一套适合HTML的缓存策略,自己实现ETag生成与校验、自己控制Cache-Control的取值,让浏览器与服务器之间的每一次缓存协商都透明可控。
这套方案面向的场景很明确:有一台自己能控制响应头的Web服务器,或者用Node、Python、Go写后端服务,想在不引入框架中间件的前提下自己掌握缓存决策逻辑。读完你不仅能用代码手写实现,还能理解HTTP缓存协议里几个关键字段为什么这么设计、怎么组合才能不出乱子,排查线上缓存Bug时也能有个清晰的底。
1. 内容整体设计与思路拆解
1.1 为什么HTML的缓存策略要和静态资源分开谈
市面上常见的缓存教程,默认套路是“永久缓存静态资源,带hash命名”。这个思路没错,但它天然地绕开了HTML:带hash的资源文件名一变,旧文件就自动失效,浏览器根本不需要向服务器发任何请求;而HTML是入口文档,没有hash命名机制,它被缓存后一旦内容更新,浏览器拿到的还是旧壳,里面的资源引用自然也是老一套。
有人会问,那给HTML也设置一个很长的max-age不就行了?如果页面常年不更新,这个方案确实可以,代价是更新上线后,已经缓存了旧HTML的用户要继续看旧页面,直到缓存过期。对于博客文章、公告页这类时效性敏感的页面,长缓存等于把发布节奏绑定到了用户端的缓存周期上。反过来,干脆不缓存HTML,每次请求都让服务器重新生成并全量返回——这种方式对动态页面是合理的,但对那些内容不怎么变的HTML页面,每次刷新都重传几百KB,纯属浪费。
所以HTML页面的缓存策略核心诉求是两个:内容没变时,客户端能用极低成本确认“没变”;内容真变了时,客户端能立刻拿到新版。ETag配合Cache-Control: no-cache(注意这个值的意思是“可以缓存,但每次使用前必须验证”,不是禁止缓存)正好构成这套机制。验证如果确认没变,服务器返回304空响应体,浏览器继续用本地缓存;验证发现变了,返回200全量内容,浏览器更新缓存。既省带宽又不牺牲新鲜度。
1.2 手搓方案的选型思路:ETag为主、Last-Modified为辅
不少后端框架自带缓存协商逻辑,比如Express里的etag选项,Nginx里也有etag on。但框架封装好的东西,一旦碰上特殊需求就容易抓瞎:比如同一份HTML文件有多台服务器,各自生成ETag的规则不一致,会导致304永远不生效;又比如页面内容由接口动态拼接,ETag应该基于哪个数据源来算,框架可猜不到。
自己动手设计这套逻辑,最直接的好处是所有的判定规则都在自己手里。我给HTML选定了两套判定维度,互为补充:
- 内容维度(ETag):基于响应体的实际内容计算哈希。内容没变,ETag就稳定不变;内容变了哪怕一个字节,ETag立刻不同。这是最精准的校验方式。
- 时间维度(Last-Modified):记录资源的最后修改时间。某些场景(比如内存里动态渲染的页面)没有真实文件可供取mtime,这时用时间戳作为弱校验也能兜底。
两者在HTTP协议里是配合关系,不是替代关系:ETag的优先级高于Last-Modified。只要客户端同时给出了If-None-Match和If-Modified-Since,服务器只需判断ETag,不需要再去比较时间。这个设计很有讲究,后面实现时我会具体展开验证逻辑的先后顺序。
1.3 缓存控制指令的关键组合:no-cache与must-revalidate
真正决定“浏览器拿到响应后如何对待这份缓存”的,是Cache-Control响应头。它有很多指令,但HTML入口文档只需要关心几个:
| 指令 | 含义 | 适用场景 |
|---|---|---|
max-age=0 |
缓存存活0秒,立即过期 | 老写法,和no-cache类似,但不那么语义明确 |
no-cache |
可以缓存,但每次使用前必须回源验证 | 需要“每次协商但可以复用”的页面 |
no-store |
完全禁止缓存,每次全量拉取 | 含敏感信息的页面 |
must-revalidate |
缓存过期后必须回源验证,不允许用过期缓存兜底 | 与no-cache搭配,防止缓存过期后继续被使用 |
我给普通HTML页面推荐的组合是:
code复制Cache-Control: public, no-cache, must-revalidate
拆开解释一下:public允许CDN和中间代理缓存这份HTML;no-cache要求浏览器和代理在使用缓存前必须向源站发起条件请求;must-revalidate则堵住了某些浏览器或代理在缓存过期后自作主张继续用本地副本的路径。三者的合力是——协商一定发生、结果由服务器说了算,无论中间有多少层缓存代理,最终都只认源站的判定。
1.4 先厘清一个容易踩晕的概念:304到底算什么
写这段之前我必须把304的语义讲透。很多人误以为304是个“错误”,看到Network面板里一堆304就觉得页面出了问题。实际上304的准确定义是Not Modified,它不是一个失败状态,而是服务器对客户端“这内容变没变”的回答:没变,继续用你本地那份。
在这个机制里,服务器返回的304响应体是空的,真正被浏览器使用的数据来自本地缓存。所以如果你观察网络请求,会看到请求照发、网络往返照走、304响应也回来了,但传输体积比200小好几个数量级。原来要下载几十上百KB,现在只需要几百字节的响应头。这就是ETag策略节省带宽的核心方式。
顺带提醒:千万别给304响应设置离谱的响应头,很多服务端在返回304时会把完整响应头一股脑带过去,其中可能包含Content-Encoding之类的字段,浏览器处理这类不规范响应时可能出现奇怪现象。正确做法是304只保留必要的验证相关头和安全头。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 ETag的生成规则:同样是哈希,讲究可不少
ETag本质上是服务器给响应体贴的一个标签,长得像"33a64df551425fcc55e4d42a148795d9f25f89d4"这样一串带双引号的字符串。它的生成规则HTTP协议不做强制,唯一的要求是:资源内容变化时ETag必须变化;资源内容不变时ETag必须稳定。
实践中最容易掉坑的是“哈希过了头”。比如有人用文件修改时间加上文件大小拼成一个ETag,这在单机部署时没问题,但文件内容被重新部署后mtime变了,即使内容相同ETag也会变,导致缓存失效;更典型的是多台服务器各自生成ETag,算法不统一,同一个资源在不同机器上算出不同的标签——负载均衡一转发,304立刻失效。所以我的原则是:ETag尽量只基于响应体的内容哈希,不掺入时间、机器、进程等一切变量。
对于HTML来说,我推荐直接用SHA-256。它有两个好处:一是碰撞概率可以忽略,二是哈希结果足够长,作为强ETag(内容变了ETag必变)的可信度极高。实现时不需要自己造轮子,Node.js的crypto模块、Python的hashlib、Go的crypto/sha256都能算。加引号也是必须的,因为ETag的值在协议里要求用引号包裹,否则格式不对。
2.2 Cache-Control的四种取值定夺:静态页、动态页、登录页、接口页
我给HTML页面总结了一套“四级缓存策略”,根据页面性质决定Cache-Control的取值。这比所有页面一刀切要靠谱得多:
| 页面类型 | Cache-Control 推荐取值 | 理由 |
|---|---|---|
| 完全静态的HTML(帮助文档、公告) | public, max-age=600, must-revalidate |
允许10分钟缓存,过期后回源验证,兼顾速度和新鲜度 |
| 内容偶发变化的静态HTML(活动页) | public, no-cache, must-revalidate |
每次请求回源验证,确保靠实时性吃饭的页面不出错 |
| 含用户个性化信息的页面(个人中心) | private, no-cache |
private禁止代理缓存,只能存浏览器本地;每次验证保证用户信息准确 |
| 需要强制刷新的敏感页面(支付结果) | no-store |
告知所有层级的存储设备不得缓存,避免任何残留 |
实际操作中,绝大多数团队维护不了这么细的规则,我的简化建议是:默认给no-cache, must-revalidate,对时效性不敏感的页面手动放宽到max-age=300或max-age=600。宁可每5分钟多一次304协商,也不要让用户停留在一个过期页面上。304协商本身极便宜,它一定比你推送错误缓存、然后等用户手动Ctrl+F5强刷要划算得多。
2.3 条件请求头的语义与优先级
浏览器拿到含ETag的响应后,会在后续请求中带上两个与验证相关的请求头:
If-None-Match:值是之前存下来的ETag,告诉服务器“我之前拿到的版本是这个标签,你看变没变”。If-Modified-Since:值是之前记录下来的Last-Modified时间,问服务器“这个时间之后它改过没”。
服务器端的判定逻辑优先级如下:
- If-None-Match存在时,以它为准,直接对比当前资源的ETag。如果一致,返回304;如果不一致,返回200和最新内容。
- If-None-Match不存在、但If-Modified-Since存在时,用时间作为弱校验,资源最后修改时间不晚于请求头里的时间就返回304。
- 两个条件请求头都不存在,说明是首次请求或缓存已被强制跳过,正常返回200。
为什么ETag的优先级更高?因为ETag是强校验,内容不变就是不变,内容变了就是变了,时间戳反而可能因为一些不相关操作(比如权限变更、文件空洞)而误报“修改”。协议作者显然知道这一点,所以要求服务器只要收到If-None-Match就直接忽略If-Modified-Since。
2.4 与CDN和中间代理协作时的特殊注意事项
HTML页面有时候会经过CDN一层,CDN缓存策略与浏览器不太一样。如果CDN节点按max-age缓存了HTML,那么用户请求可能连CDN都不出,直接命中节点缓存——304协商根本不会发生。这本身不是问题,问题在于CDN节点缓存的更新时机。我踩过的坑是:CDN节点缓存了旧HTML后,源站已经更新了,但CDN迟迟不回源,导致边缘节点一直吐旧页面。
用no-cache指令能避免这个大坑。CDN看到no-cache后会回源验证,源站返回304或200它都会同步最新状态。另一个需要注意的点是,当CDN回源验证时,源站看到的请求IP是CDN节点,不是用户IP。因此如果页面里做了基于IP的个性化渲染,CDN缓存会导致所有用户拿到同一份内容。这时要么把页面标记为private(但要接受用户每次都回源),要么在渲染逻辑里允许CDN节点IP的差异。
所以我的最终建议是:HTML页面能否上CDN,取决于页面内容是否所有人一致。一致的上CDN,配合public, no-cache, must-revalidate没问题;不一致的,就别让中间层碰它,直接private, no-cache。
3. 实操过程与核心环节实现
3.1 环境准备与最小代码骨架
我用Node.js实现这套逻辑,但它同样适合用Python Flask、Go net/http改写,核心思路完全一致。需要准备的环境就一个Node.js运行时,版本无所谓,14以上都行,不需要任何第三方依赖,全用标准库。
我实现的这个服务有两个能力:一是托管一个静态HTML目录,二是渲染少量动态页面。为了演示完整流程,代码中会包含读取文件、计算ETag、处理条件请求、设置响应头这几个环节。
先看一个最基础的文件版服务骨架:
javascript复制const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT_DIR = path.join(__dirname, 'public');
const PORT = 3000;
function calculateEtag(content) {
const hash = crypto.createHash('sha256').update(content).digest('hex');
return `"${hash}"`;
}
http.createServer((req, res) => {
const urlPath = decodeURIComponent(req.url.split('?')[0]);
const filePath = path.join(ROOT_DIR, urlPath === '/' ? 'index.html' : urlPath);
// 这里先只处理存在性,后续再补完整逻辑
fs.readFile(filePath, (err, content) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 Not Found</h1>');
return;
}
const etag = calculateEtag(content);
const lastModified = fs.statSync(filePath).mtime.toUTCString();
// 核心:条件请求判断
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, {
'Cache-Control': 'public, no-cache, must-revalidate',
'ETag': etag
});
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'public, no-cache, must-revalidate',
'ETag': etag,
'Last-Modified': lastModified
});
res.end(content);
});
}).listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
这段代码约50行,实现了一个具备基本条件的缓存协商服务。我第一次跑通它的时候,用curl验证了一把流程。curl -I看首次响应的响应头,能看到ETag和Cache-Control;把ETag粘到If-None-Match再发一次请求,状态码变成了304。整个过程非常直观。
3.2 完整实现:文件型HTML的ETag与条件请求处理
上面的骨架有几个缺陷:路径穿越没防住、目录请求没有默认找index.html、304响应丢失了Last-Modified、错误处理太粗糙。下面补全一个更稳健的版本:
javascript复制const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT_DIR = path.join(__dirname, 'public');
const PORT = 3000;
function calculateEtag(content) {
return `"${crypto.createHash('sha256').update(content).digest('hex')}"`;
}
function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml'
};
return map[ext] || 'application/octet-stream';
}
http.createServer((req, res) => {
let urlPath;
try {
urlPath = decodeURIComponent(req.url.split('?')[0]);
} catch (e) {
res.writeHead(400);
res.end('Bad Request');
return;
}
// 防止路径穿越
const normalizedPath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
let filePath = path.join(ROOT_DIR, normalizedPath);
// 目录默认取 index.html
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
fs.readFile(filePath, (err, content) => {
if (err) {
// 区分404和500
const status = err.code === 'ENOENT' ? 404 : 500;
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(status === 404 ? '<h1>404 Not Found</h1>' : '<h1>Internal Server Error</h1>');
return;
}
const etag = calculateEtag(content);
const stat = fs.statSync(filePath);
const lastModified = stat.mtime.toUTCString();
// 缓存协商核心逻辑
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, {
'Cache-Control': 'public, no-cache, must-revalidate',
'ETag': etag,
'Last-Modified': lastModified
});
res.end();
return;
}
res.writeHead(200, {
'Content-Type': getContentType(filePath),
'Cache-Control': 'public, no-cache, must-revalidate',
'ETag': etag,
'Last-Modified': lastModified
});
res.end(content);
});
}).listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
这段代码里有几个细节值得展开:
路径穿越防护。用户请求/../secret.txt时,path.join会把路径拼到目录之外。我这里的处理方式比较简单粗暴——先normalize再剔除所有前缀../。更稳的做法是构建出完整路径后,再检查其是否以ROOT_DIR开头。上面的写法在日常够用,但生产环境建议用更严格的path.resolve方案。
同步stat与readFile并存。代码里既用readFile异步读内容,又用statSync同步拿状态。这在性能上不是最优的,但胜在逻辑清晰。实际生产建议改用fs.promises统一成异步,避免阻塞事件循环。
304时也回传Last-Modified。很多实现会忽略这一点,导致浏览器端如果只用If-Modified-Since做协商,缓存的时间信息会在304后丢失。这里一并带上是更稳妥的做法。
3.3 让ETag生成“与时俱进”:应对动态拼接的HTML
上面的实现只能处理磁盘静态文件。实际项目里,很多HTML是由模板引擎动态拼接的,比如用户登录后页面里会渲染用户名和头像地址。这类页面的内容每次都变,直接计算整页哈希作为ETag会带来一个麻烦:页面里只要有任何动态部分变化(比如一个时间戳、一段随机推荐位),整个ETag就会变,304永远不会命中,缓存形同虚设。
解决思路可以分两层:
第一层,区分公共部分和个性化部分。把页面拆成“壳”和“芯”:壳是人人相同的导航、页脚、样式表;芯是个性化内容。ETag用“壳的哈希 + 用户标识哈希”来生成。用户A的页面壳没变,芯没变,ETag自然稳定;用户A换了个头像,头像变了ETag变。这样既能享受缓存,又不会串味。
第二层,用组件哈希拼合页面ETag。类似于构建工具里的contenthash思路,把每个区块的哈希拼成一个整体。Node里实现的话,可以维护一个区块哈希列表,页面更新时只需要重算受影响区块的哈希,再合并生成新的ETag,比整页从零哈希快得多。
下面给出一个动态页面ETag生成的代码片段,展示区块拼合的思路:
javascript复制function generatePageEtag(staticShellHash, userIdentityHash, dynamicBlocksHash) {
const combined = [staticShellHash, userIdentityHash, dynamicBlocksHash].join(':');
return `"${crypto.createHash('sha256').update(combined).digest('hex')}"`;
}
// 示例:页面由 通用导航 + 用户信息 + 推荐位 组成
const staticShellHash = crypto.createHash('sha256')
.update(navHtml + footerHtml)
.digest('hex');
const userIdentityHash = crypto.createHash('sha256')
.update(String(user.id))
.digest('hex');
const dynamicBlocksHash = crypto.createHash('sha256')
.update(recommendationHtml)
.digest('hex');
const etag = generatePageEtag(staticShellHash, userIdentityHash, dynamicBlocksHash);
这个方案的核心特点,就是让ETag的粒度足够细,把“页面变了”的判定下沉到区块级,而不是整页级。实践中不建议再往下拆——如果单个区块内还有实时变化,就说明这个区块本身不该被缓存,给它单独设置no-store更合适。
3.4 实操命令:如何用curl完整验证缓存逻辑
代码写完必须验证。我惯用的验证方式是一组curl命令,能完整覆盖“首次请求、命中缓存、内容更新、缓存失效”四个阶段。
1. 首次请求,拿到200和响应头:
bash复制curl -i http://localhost:3000/
响应里能看到:
code复制HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, no-cache, must-revalidate
ETag: "a1b2c3..."
Last-Modified: Wed, 21 Aug 2024 08:00:00 GMT
2. 用ETag模拟浏览器缓存验证,期望得到304:
bash复制curl -i http://localhost:3000/ \
-H 'If-None-Match: "a1b2c3..."'
响应应返回HTTP/1.1 304 Not Modified,且响应体为空。
3. 修改index.html,重新请求,期望得到200和新的ETag:
bash复制echo '<html>updated</html>' > public/index.html
curl -i http://localhost:3000/ \
-H 'If-None-Match: "a1b2c3..."'
由于内容变了,ETag变化,服务器返回200,浏览器会替换旧缓存。
4. 用Last-Modified验证弱校验路径:
bash复制curl -i http://localhost:3000/ \
-H 'If-Modified-Since: Wed, 21 Aug 2024 08:00:00 GMT'
如果文件在这之后没改过,返回304。这套命令排下来,基本能确认缓存策略是不是真在起作用。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
| 问题现象 | 可能原因 | 排查手段与对策 |
|---|---|---|
| 刷新页面从不发304,永远返回200 | 服务器没识别If-None-Match请求头,或ETag生成规则不稳定 |
抓包打印请求头,确认条件头带没带;检查ETag值前后是否变化 |
| 页面更新后用户还是旧的 | Cache-Control设置成了max-age超时优先,而非no-cache |
改成no-cache, must-revalidate;历史遗留的旧响应头需要等缓存过期 |
| 同一资源多台服务器ETag不一致 | 各机器哈希算法不同或加了时间戳因素 | 统一ETag生成算法,只依赖响应体内容,剔除机器变量 |
| 304响应后页面出现乱码 | 304响应带了Content-Encoding或非空body |
304只回验证相关头和安全头,清掉其他响应头 |
| 动态页面中某用户的缓存串到另一个用户 | ETag范围过粗,或Cache-Control忘了加private |
按用户维度生成ETag,响应头加private, no-cache |
| 内网代理/CDN怎么刷新都在旧缓存 | 中间层没有遵守源站的Cache-Control | 检查代理/CDN的缓存策略配置,确认是否强制覆盖源站头 |
| ETag在本地看起来正确,移动端缓存失效 | 移动端App的自定义网络库可能不缓存304响应 | 确认App的HTTP客户端是否实现了标准缓存语义,必要时在请求头里手动带验证字段 |
4.2 最容易被忽视的细节:响应头大小写与格式
HTTP响应头字段名不区分大小写,Node.js里的res.setHeader会自动做规范化处理,这个一般没问题。要命的是ETag的引号。我见过不止一次,有人手写ETag字符串时忘了加两边的双引号,结果浏览器端对比时永远匹配不上,协商机制形同虚设。
另外响应头里的Last-Modified必须是合法的GMT格式。如果你直接塞一个new Date().toString()进去,浏览器没法正确解析,条件请求也就不会触发。标准做法是用date.toUTCString()。
4.3 调试工具与观察技巧
我常用的调试工具是Chrome DevTools的Network面板和curl的组合。DevTools里勾选“Disable cache”后刷新,能看到绕过缓存的完整请求链路;不勾选的状态下刷新,观察请求的Status是200还是304。
有个细节容易被忽略:在DevTools的Network面板里,304请求在Size列会显示“from memory cache / from disk cache / from ServiceWorker”等字样,这代表数据的实际来源。真正走了网络请求的304会显示网络传输大小,没走网络的纯缓存命中根本不发请求。要区分这两者,看请求是否出现在Network列表里即可。出现的是网络往返+304验证,没出现的是纯本地缓存命中。
另外,我推荐在服务器日志里打印条件请求的相关头,这样能直观看到哪些客户端在发If-None-Match、哪些没有发。很多缓存Bug的根因,就是某个用户代理压根没缓存响应,每次都在裸请求。
4.4 一套稳定排障的步骤模板
遇到缓存疑似出问题时,我按下面的步骤来走,基本能在十分钟内定位:
- 用curl直接请求资源,记录首次响应的
Cache-Control、ETag、Last-Modified取值。 - 带
If-None-Match重新请求,记录状态码。如果是200,检查服务器端ETag生成规则是否因内容变化而变化;如果是304,说明协商链路OK。 - 确认页面是否真的更新了。有时候是发布流程没生效,文件根本没换,ETag自然不变。
- 检查中间层。看请求是否经过了CDN或代理,如果有,对比源站响应头和客户端实际拿到的响应头是否一致。
- 拿一个真实浏览器做对照。有些浏览器插件、安全软件会修改请求头,导致缓存行为异常。
按这个顺序排查,绝大多数“缓存不生效”“缓存不更新”的问题都能落到一个具体的环节上。
5. 关于ETag与缓存控制的设计取舍思考
5.1 强校验与弱校验的边界
ETag在协议层面有两种形式:强ETag和弱ETag。强ETag要求内容逐字节相等才算没变,弱ETag允许内容有语义等价时保持不变(用W/"..."前缀标记)。对HTML这种文本型资源,绝大多数情况应该用强ETag——HTML的字节变化几乎都意味着渲染结果变化。弱ETag适合那些实际输出会动态变化但语义上可等价的内容,比如压缩算法导致的字节差异。
有些实现为了性能,用“文件大小+mtime”拼出ETag,这在语义上其实是一种弱校验。我建议在做文件型资源时别嫌算哈希开销大,直接上SHA-256。对于几百KB的HTML,一次哈希也就微秒级,和磁盘IO、网络传输相比可以忽略。
5.2 什么时候该激进缓存,什么时候必须回源验证
“激进缓存”和“回源验证”并不矛盾,它们对应不同层级的资源。我最终采用的策略矩阵是这样的:
- HTML入口文件:默认
no-cache, must-revalidate,允许HTTP缓存存储,但每次必须回源验证。 - CSS/JS等静态资源:文件名带hash,
max-age=31536000,永久缓存。文件名一变请求就变了,不需要验证。 - 图片/字体等媒体资源:相对稳定,
max-age=86400, must-revalidate,一天以内直接用缓存,过期后条件验证。 - 接口数据:按接口语义单独定,建议
private, no-cache或no-store。
这套矩阵的好处是,把“缓存存不存”和“缓存能不能直接使”拆成了两个维度。过去很多团队只设一个max-age,等于把两个维度混在一起处理,才会频繁出现改了代码但用户侧迟迟看不到变化的问题。
5.3 自动化更新缓存的扩展思路
对于有一定规模的站点,手动管理ETag很快会到瓶颈。我见过一些团队引入了内部的“缓存版本号”机制:在HTML页面或接口响应里携带一个全局版本号,每次发版让版本号递增,所有的ETag都会在版本号变化后失效。这个思路对发布流程的侵入很小,但收益很大——不需要逐个文件计算内容哈希,只要版本号一变,整个站点的缓存体系就能在同一时刻集体更新。
另一种更精细的做法是维护一张“资源指纹表”,发版工具生成所有资源的contenthash,然后同时更新HTML里的引用和服务器端的ETag规则。这其实是很多大型前端工程化方案在做的,只是我们手搓版用最小代码量也能模拟:先算好hash,再套ETag逻辑。
扩展代码示例:版本号参与ETag计算
javascript复制const VERSION = 'v20240821';
function generateVersionedEtag(content, version = VERSION) {
const hash = crypto.createHash('sha256')
.update(version)
.update(content)
.digest('hex');
return `"${hash}"`;
}
发版时全局替换VERSION常量,所有HTML的ETag立刻失效,用户侧会在下一次访问时强制回源拿新版本。这种思路适合“宁可全部刷新也不逐个精准判断”的业务,代价是牺牲一些缓存命中率,但带来的部署确定性非常强。
6. 实操总结与避坑心得
整套方案从设计到落地,可以说踩过的坑每一个都比看书来得深刻。最后记几条自己最值钱的体会。
第一,ETag的核心是“稳定”,不是“唯一”。很多新手以为ETag越复杂越好,把IP、进程号、随机数全塞进去,结果每次请求的ETag都在变,缓存策略直接报废。稳定的ETag要像人的指纹——每个人都不同,但同一个人的手指,每次按下去纹路不会变。所以尽量只保留内容维度的信息。
第二,304不是免费午餐。它节省的是“响应体传输”,但没有节省“网络往返”。真正零往返的缓存命中,是Cache-Control: max-age允许浏览器直接读本地副本且不发请求。如果你的API或页面连毫秒级延时的条件请求都承担不起,那就要重新考虑是缓存策略的问题,还是渲染链路本身绕不开的瓶颈。
第三,所有缓存规则都要靠日志说话。我习惯在服务器打印条件请求头和响应状态,很多缓存问题在裸眼观察时千奇百怪,一旦看日志,原因通常很简单——要么ETag没带引号,要么If-None-Match的key名称拼错了。HTTP缓存语义并不复杂,复杂的往往是分布式场景里各种代理、客户端对协议的理解不一致。日志是调解矛盾的最好裁判。
第四,永远留一条强制刷新的后路。无论缓存策略设计得多完善,总会有用户遇到奇怪的状态。一个兜底方案是:在URL后面加查询参数绕过缓存(?v=timestamp)。这类参数会让大部分HTTP缓存把URL视为一个全新资源,绕过旧的缓存副本。但它不应该成为常规访问路径,只是应急手段。
其实手搓这套HTML缓存策略的过程,最大的收获不是代码本身,而是对HTTP协议里“协商”这个概念的理解。现代Web性能优化的很多手段,从Service Worker到CDN,从预加载到增量传输,底层都是同样的逻辑:尽量少传重复数据,尽量缓存可复用数据,尽量让每次传输都有价值。理解了ETag,就理解了这套逻辑的基石。后面如果再去做Service Worker缓存策略或者HTTP/2的缓存探测,你会发现很多原理是相通的。
