
先把结论说在前面如果你还在靠手动改文件名后缀、清浏览器缓存或者吼用户“CtrlF5刷新”来让页面更新生效那这篇关于HTML缓存策略的文章就是给你写的。我这次的项目背景是一个纯前端页面组成的轻量系统基本都是服务端渲染好的HTML再辅以少量CSS和JS。页面挂了缓存之后最核心的诉求只有两条静态资源在有效期内能尽量减少重复请求但HTML一旦变更又必须让用户立刻看到新内容。最后我没有直接用框架现成的缓存中间件而是从HTTP协议层面手搓了一套基于内容哈希的ETag校验逻辑配合Cache-Control做了完整的分层缓存控制效果很理想——强缓存期间几乎零请求内容变更后又能秒级失效。这篇记录一下我的完整设计思路、核心原理、完整代码和踩过的坑适合对HTTP缓存机制有兴趣的前端开发、Node.js后端初学者以及所有被缓存问题折腾过的人。1. 项目背景与缓存策略设计思路1.1 为什么我决定手搓一套ETag缓存策略这个项目起初是没有缓存策略的每次刷新页面浏览器都会老老实实向服务器请求HTML、CSS、JS等资源。项目体量不大开发时无所谓但部署到正式环境之后随着页面内容增多、访问量上来服务器日志里全是静态资源的请求记录峰值时段连接数也居高不下。我第一反应是“那就加缓存”然后遇到了一个更典型的困境。如果给整个目录设置一个很长的强缓存时间比如Cache-Control: max-age86400确实能显著减少请求但问题很快就会暴露HTML内容或脚本更新之后客户端仍然在用本地旧缓存用户看到的就是“页面没更新”。这时候再让用户手动清缓存体验非常糟糕。如果为了追求实时性把缓存时间设得很短比如几分钟甚至不缓存又等于把带宽和服务器压力原封不动还了回去。这个问题本质上就是HTTP缓存机制里服务端缺少一个能“精确判断资源是否真的变化”的手段。浏览器刷新时只能盲目地问“资源变了吗”服务器如果只回答“变了”或“没变”还好可实际处理起来往往是一团浆糊。ETag就是用来解决这个问题的标准答案而当时项目环境里没有现成的Node.js中间件可用我又想彻底搞懂缓存头在协议层面到底怎么工作于是决定自己造一套。手搓的好处是出了问题你能准确知道是哪一行逻辑导致了缓存失效而不是对着框架黑盒一通乱猜。1.2 缓存策略的三层设计目标在设计这套策略之前我把需求拆成了三个层次每一层解决一个明确问题。第一层是强缓存在Cache-Control规定的有效期内浏览器根本不会发请求到服务器直接使用本地副本这是省流量、降延迟的主力。第二层是协商缓存强缓存过期之后浏览器会带着之前拿到的ETag发请求给服务器做校验如果内容没变服务器只返回304状态码不返回响应体带宽开销极小。第三层是兜底策略对HTML这种入口文件用no-cache强制每次都要校验避免因为HTML被缓存住导致后续更新的JS/CSS文件都加载不到。这里有一个很重要的认知HTML本身不能轻易交给强缓存。因为HTML是整个页面的入口浏览器先拿到HTML才知道接下来要加载哪些JS、CSS。如果入口文件被强缓存卡住很久就算后面的静态资源改了、文件名变了用户看到的仍然是旧HTML里的旧引用更新永远不生效。所以我的设计原则就是入口HTML走“每次协商”的路线带hash指纹的静态资源才配享受“长时间强缓存”。这套思路也解释了我为什么要把ETag和Cache-Control放一起设计它们不是二选一的关系而是各管一层。2. ETag核心原理从计算到校验2.1 ETag到底是什么怎么生成才叫“智能”ETag全称是Entity Tag翻译成大白话就是“资源实体标签”。它是一个字符串用来标识资源内容的某个版本。服务器在响应头里返回ETag: a1b2c3...浏览器拿到后把它和资源一起存起来下一次请求时主动在请求头里带上If-None-Match: a1b2c3...。服务器拿到这个值和当前资源重新计算出的ETag做对比如果一致说明浏览器缓存的内容还是最新的直接响应304如果不一致就返回200和新内容同时带上新的ETag。那什么样的ETag生成方式才算“智能”我的标准是内容不变标签必须稳定不变内容一变标签必须跟着变整个计算过程不依赖任何人工干预。最满足这三个条件的做法就是内容哈希。对资源的字节内容做一次SHA-1或SHA-256把哈希值作为ETag。这样做的好处是零成本维护不需要手动升级版本号不需要记录最后修改时间所有逻辑都由内容本身驱动。我在项目里用的是SHA-256因为碰撞率更低尽管对普通HTML页面来说SHA-1已经足够安全但多几微秒的计算换一个心理踏实完全值得。用Node.js生成ETag的代码非常简洁const crypto require(crypto); const fs require(fs); function generateETag(filePath) { const content fs.readFileSync(filePath); const hash crypto.createHash(sha256).update(content).digest(hex); return ${hash}; }有一点必须提一下按RFC 7232规范ETag的值要用双引号包起来像ETag: a1b2c3这样。虽然很多服务器不写双引号浏览器也能兼容但既然要从协议层面手搓规范还是得遵守。格式不标准带来的问题通常很隐蔽比如某些代理服务器在解析ETag时会丢掉引号导致协商缓存链路判断异常排查起来特别费劲。2.2 强校验与弱校验ETag带不带W/前缀的区别ETag还有一个很多人忽略的细节就是在双引号前可以加W/前缀表示弱校验格式如W/a1b2c3。这个前缀的作用是告诉服务器和浏览器两边的内容只要在语义上等价就算匹配不需要字节级别完全一致。我一开始也没太把强校验和弱校验当回事直到有一次遇到服务器端对响应体做Gzip压缩压缩前和压缩后的字节流完全不同如果按强ETag严格比较每次压缩后的资源都会被判定为“已变化”协商缓存形同虚设流量直接被白白浪费掉。这里放一个对比表格平时选择时可以直接套用类型示例匹配规则推荐场景强ETagabc123资源字节必须完全一致图片、CSS、JS等二进制或精确资源弱ETagW/abc123语义等价即可允许字节不同HTML、文本内容、动态拼接但语义不变的页面需要注意弱ETag并不是“允许随便变”它的语义是“足够弱以允许语义等价的内容匹配又足够强以避免非等价内容匹配”。如果你手搓的服务端使用内容哈希生成ETag那实际上生成的是强ETag因为只要字节变了哈希就变了。在我的场景里页面没有做动态压缩所以我直接用强ETag逻辑更简单判定也更严谨。2.3 ETag与Last-Modified为什么现代项目更偏向ETag在ETag出现之前HTTP缓存校验主要靠Last-Modified和If-Modified-Since这对时间头。服务器返回资源时带上文件最后修改时间浏览器下次请求时把这个时间回传服务器对比时间判断资源是否变化。这套机制至今仍然有效但使用中很容易踩坑。Last-Modified的粒度是秒级如果你在同一个秒级时间窗口内连续修改并保存文件时间戳可能没有变化但内容已经变了校验就会漏判客户端拿到的还是旧内容。另外一个坑是文件时间戳非常容易因为各种原因被改动比如Git checkout、打包工具重新生成文件文件内容其实没变但mtime变了这会导致服务器误判资源已更新白白产生一次200全量传输。ETag和Last-Modified的对比可以这样看维度Last-ModifiedETag判断依据文件最后修改时间内容哈希或自定义版本标识时间粒度秒级内容级理论上可感知任意变化内容未变但误判变更常见mtime被改几乎不可能内容变更但时间未变可能秒级内多次修改不可能多实例部署一致性依赖各服务器文件时间一致内容相同则哈希必然相同所以我的做法是兼容保留Last-Modified响应头作为老客户端的兜底但核心校验逻辑完全交给ETag。按RFC规定如果请求头同时带了If-None-Match和If-Modified-Since服务器必须以If-None-Match为准所以两个头一起返回并不会造成逻辑冲突。3. 手搓实操用Node.js实现智能ETag与缓存控制3.1 服务端完整实现读取文件、算Hash、比对ETag理论铺垫完毕下面直接进入可复现的完整代码。我使用Node.js原生http模块不引入任何框架这样可以彻底看清缓存控制逻辑。服务端要干的事情很简单按URL找到对应文件读取内容计算SHA-256哈希作为ETag设置合适的Cache-Control头然后根据请求头里的If-None-Match判断返回304还是200。const http require(http); const fs require(fs); const path require(path); const crypto require(crypto); const ROOT __dirname; function getFileInfo(urlPath) { const filePath path.join(ROOT, decodeURIComponent(urlPath)); // 防止路径穿越最终路径必须仍然在ROOT目录内 if (!filePath.startsWith(ROOT)) return null; if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) return null; return filePath; } function getEtag(content) { const hash crypto.createHash(sha256).update(content).digest(hex); return ${hash}; } const server http.createServer((req, res) { const urlPath req.url / ? /index.html : req.url; const filePath getFileInfo(urlPath); if (!filePath) { res.writeHead(404); res.end(Not Found); return; } const content fs.readFileSync(filePath); const etag getEtag(content); res.setHeader(ETag, etag); if (path.extname(filePath) .html) { // HTML入口每次都要到服务器校验 res.setHeader(Cache-Control, no-cache); } else { // 静态资源强缓存1小时过期后必须校验 res.setHeader(Cache-Control, public, max-age3600, must-revalidate); } if (req.headers[if-none-match] etag) { // 校验通过304不带响应体 res.writeHead(304); res.end(); return; } res.writeHead(200, { Content-Type: text/html; charsetutf-8 }); res.end(content); }); server.listen(3000, () { console.log(server running at http://localhost:3000); });这份代码已经能完整跑通ETag协商缓存了。有一处容易遗漏getFileInfo里的路径穿越校验。用path.join拼接URL路径时如果请求/../../etc/passwd这种恶意路径可能拼出ROOT之外的绝对路径这条校验是安全底线千万别省。另一个细节是304响应不要带响应体res.end()不传任何参数即可因为304的语义就是“无内容”带了body反而会违反HTTP规范。配合测试的index.html可以随便写点什么比如一个带版本号的Hello World页面!DOCTYPE html html langzh-cn head meta charsetutf-8 title缓存测试页/title /head body h1ETag 缓存演示/h1 p当前版本v1.0/p /body /html3.2 请求头与响应头配置详解整个协商缓存过程涉及两组头字段一组是服务器发给浏览器的响应头一组是浏览器缓存后自动带出来的请求头。新手最容易搞混的就是这些头的方向和作用我这里直接列成对照表头字段方向示例作用Cache-Control响应no-cache禁止直接使用强缓存请求前必须校验Cache-Control响应max-age3600允许强缓存3600秒ETag响应a1b2c3...返回当前资源的内容指纹Last-Modified响应Wed, 21 Oct 2024 07:28:00 GMT返回资源的最后修改时间If-None-Match请求a1b2c3...浏览器把上次拿到的ETag回传给服务器If-Modified-Since请求Wed, 21 Oct 2024 07:28:00 GMT把上次的修改时间回传用于兜底校验关于Cache-Control的取值有几个特别容易混淆我要单独强调。no-cache的意思是“可以用缓存但使用前必须先向服务器验证”它不代表“禁止缓存”no-store才是真正的不缓存响应不能以任何形式存入本地must-revalidate表示一旦强缓存过期必须到服务器重新验证不能因为网络故障就退回使用过期缓存public和private分别控制代理服务器和浏览器是否能缓存public意味着中间的CDN、代理节点都可以存private代表只能存到单用户的浏览器里。对HTML入口我用的是no-cache这样浏览器每次刷新都会带If-None-Match来校验ETag匹配时只回304不下载body既保证了实时性又节省了流量。对带hash指纹的静态资源才推荐用max-age31536000, immutable因为文件名变了就相当于新的URL缓存一整天甚至一年都不会有问题。3.3 用curl实测两次请求的行为差异代码写完之后我习惯先用curl验证逻辑再开浏览器看效果。启动服务后第一次请求没有任何缓存信息服务端应该返回200和完整响应体curl -i http://localhost:3000/响应大致长这样HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 ETag: 3c3d0c1c4b6b0f9a6f6b0a9f7d192c0e0a9f1d9b Cache-Control: no-cache Content-Length: 151 !DOCTYPE html html langzh-cn ...然后第二次请求把上次拿到的ETag值原样放到请求头里curl -i http://localhost:3000/ -H If-None-Match: 3c3d0c1c4b6b0f9a6f6b0a9f7d192c0e0a9f1d9b这次响应应该是HTTP/1.1 304 Not Modified ETag: 3c3d0c1c4b6b0f9a6f6b0a9f7d192c0e0a9f1d9b没有响应体没有Content-Length浏览器收到304后会继续使用本地缓存。接下来我再修改一下index.html里的版本号重新带旧ETag请求curl -i http://localhost:3000/ -H If-None-Match: 3c3d0c1c4b6b0f9a6f6b0a9f7d192c0e0a9f1d9b此时ETag已经变成新内容的哈希和请求头里的旧值不匹配服务端返回200和新的HTML内容。这就是协商缓存“内容变则全量返回内容不变则空转校验”的完整闭环。浏览器里的验证方法也不复杂打开开发者工具的Network面板点一次页面请求如果Size列显示304说明走的是协商缓存如果显示from memory cache或from disk cache说明命中强缓存只有200才是真正从网络拉取了完整内容。这套判断方式几乎适用于所有浏览器调试场景。3.4 不用Node.js怎么搞Nginx与其它语言的大致思路如果你不想写Node服务或者项目本身就挂在Nginx后面直接用Nginx的缓存配置也能实现类似效果。Nginx默认会给静态资源自动生成Last-Modified和ETag后者默认基于文件的修改时间和大小计算。手动指定缓存头很简单location / { root /var/www/html; etag on; add_header Cache-Control no-cache; }Nginx的etag on会开启基于mtime和size的ETag生成逻辑够用但不够“内容驱动”。如果希望完全按内容哈希来就需要借助第三方模块或Lua脚本这是另一个话题。另外有个坑是Nginx反向代理到源站时默认不一定会透传源站的ETag头这会导致客户端和服务端之间协商链路断裂需要在代理层显式配置头字段透传。如果你用的是Python标准库hashlib同样能完成哈希计算逻辑完全一样读写文件、算哈希、比对请求头、返回200或304。核心思想跨语言是通用的关键点在于ETag计算范围必须稳定不能因为语言环境或拼接方式的差异导致同一个资源在不同进程里算出不同的值。4. 常见问题与排查技巧实录4.1 强缓存不更新明明改代码了页面还是旧的这是我接手过最多的缓存问题十次里至少有七次是同一个原因HTML页面被设置了过长的强缓存时间。假设服务器返回了Cache-Control: max-age86400浏览器在24小时内压根不会向服务器发请求服务器这边改得热火朝天用户那边看到的始终是旧页面。更麻烦的是有时候页面里引用的JS和CSS已经改成了新文件名但因为HTML是旧缓存浏览器根本不会去请求新资源所有努力都变成零。解决思路很明确HTML一律走Cache-Control: no-cache配合ETag做协商缓存。每次访问会多一次带If-None-Match的校验请求但正常情况下只是304空响应资源体积不大时几乎没有额外负担。如果条件允许静态资源文件名加入内容hash比如app.1a2b3c4d.js然后给这类资源设置超长强缓存public, max-age31536000, immutable文件名不变说明内容没变文件名一变浏览器就会请求新的URL天然避免了缓存更新问题。4.2 ETag一直变化或一直不变Hash算法与计算范围的坑在实操中ETag最容易出的问题就是“每次算出来都不一样”或者“改动后还是一样”。第一种情况我遇到过典型的例子原本对文件内容计算哈希后来因为某些业务需要把页面做成了服务端动态拼接每次响应里都会带上不同的请求ID或时间戳导致ETag每次都变。要看懂这个坑得明白ETag的含义是“当前响应实体内容的标签”而不是“源文件的标签”。如果响应体每次都动态变化那ETag跟着变是完全正常的此时应该考虑是否只缓存静态部分或者对动态响应干脆用no-store。第二种情况通常发生在哈希计算范围选错时。有些文件本身很小比如只有几十字节的HTML看起来内容变了但如果你只截取了文件的前几个字节来计算哈希后面的变更自然感知不到。也有一种情况是服务器对大文件的ETag使用文件大小和mtime组合而成如果修改发生在同一秒内mtime没变ETag就没变老旧的协商缓存判断就漏了。我的建议是小文件直接整文件内容哈希大文件做流式哈希计算或者用“文件大小 最后修改时间”的复合值作为折中方案但要做好秒级修改场景下的检测误差心理准备。4.3 多实例部署下ETag不一致内容哈希为什么天然合适如果你的服务部署在负载均衡后面同一份资源可能由多个服务器实例响应。如果每个实例各自生成一套ETag比如用进程启动时间、随机数或自增版本号来当标签那么同一份文件在两个实例上会得到不同的ETag值。第一个请求落到实例A缓存了ETag A第二次请求被负载均衡转发到实例BB算出的ETag是A对比失败返回200重新下载。结果就是缓存永远命中不了流量白费页面加载速度还随着实例数量增加而下降。解决这个问题的正规姿势就是内容哈希因为哈希值只取决于内容本身和文件存储位置、服务器实例、进程启动时间完全无关。只要每个实例读取到的文件内容一致算出来的ETag就一定一致。这也是我在前面反复强调“智能ETag”的根本原因它不是一个你看不到的版本号而是一个可以跨实例、跨进程复现的计算结果。如果你用CDN还要确认边缘节点的ETag生成逻辑是否与源站一致否则用户命中的是边缘节点生成的ETag回源时源站又判不匹配协商链路就断了。4.4 缓存策略速查表HTML、静态资源、接口到底怎么配我把项目里总结出来的缓存配置规范整理成了一张速查表可以直接复制到自己项目里作为基础模板资源类型Cache-ControlETag说明HTML入口页面no-cache必用每次访问都校验内容变了立即生效带hash文件名的JS/CSSpublic, max-age31536000, immutable可用文件名变化代表内容变化可放心长缓存不带hash文件名的JS/CSSpublic, max-age3600, must-revalidate必用折中方案一小时内实时性仍有保障图片、字体public, max-age86400可用变更频率低一天强缓存足够动态接口数据no-cache或private, no-store按需看数据敏感性私密数据不要走代理缓存其中immutable指令表示在强缓存有效期内资源不会发生变化Chrome等浏览器收到后连重新验证的请求都不会发效率最高但它必须配合hash文件名才能使用否则一旦内容变更用户只能等缓存时间过了才能看到新版本。这个指令目前在部分老版本浏览器里不支持不支持时浏览器会退化为普通的max-age处理不会有灾难性后果。结尾的几句经验项目走到这里最核心的收获不是那几百行缓存代码而是彻底搞懂了一件事ETag和Cache-Control从来不是互斥方案它们一个管校验、一个管时效配合起来才能形成完整的缓存体系。我后来在好几个项目里复制这套策略HTML一律no-cache走协商静态资源带hash走一年强缓存再也没被“用户看到旧页面”的问题找过麻烦。如果你刚上手建议先别急着上CDN和框架中间件用Node或Python手搓一次用curl把200、304两种响应都打出来看一遍很多疑惑会瞬间清楚。最后提醒一个细节如果你前面有Nginx反代记得检查它有没有把源站的ETag头透传下去这一步漏了前面的活全白干。