起因
最近团队里有一个项目需要实现资源的预加载,因为之前我维护过这个项目,所以负责实现的同学在出了方案之后,跟我说了一下。但是他在开发的过程中发现了一个问题:
我们有一个环境,这个环境的资源访问会打到路由对应的容器内,而容器内是一个 nginx,服务了特定路径下的所有静态资源,如 ⬇️ 图:
容器里的 nginx 配置比较简陋,只配置了协商缓存的响应头(Last-Modified、ETag),没有配置强缓存的响应头(Cache-Control、Expires)。
而这个配置下的表现是:
在这个环境发布之后,页面请求 a/javascripts/xxx1.js,第一次会走 200,短时间内再次刷新,这个资源却走了 memory/disk cache,状态码 200,再过一段时间刷新,这个资源走了协商缓存,服务端返回 304,之后再次刷新,又走了 memory/disk cache,像下面这样:
第一次理解
我不理解啊!This is not right!
协商缓存之所以叫协商缓存,就是因为客户端在拿到资源之后,可以缓存,但是需要在每次使用缓存之前和服务端做一次沟通,确定缓存是否有效,如果有效,才可以使用。
协商协商,我(🐷🐔)没有给你(💻)强缓存的响应头,打算让你每次都和我协商,那你为何不与我协商了呢?
复现问题
无奈,这个环境配置略微复杂,先尝试造一个最小可复现案例,最终造出了一个这样的结构:
.
├── app1.js
├── app2.js
├── index.html
└── server.js
index.html
<!DOCTYPE html>
<html lang="en">
<body>
<script src="./app1.js"></script>
<script src="./app2.js"></script>
</body>
</html>
server.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const Koa = require('koa');
// 获取文件路径的 hash 作为 tag
function getHash(f) {
const hash = crypto.createHash('sha256');
hash.update(f);
return hash.digest('hex');
}
const app = new Koa();
// 对 js 请求发送 Last-Modified 和 ETag 响应头,其余请求 fallback 到 index.html
app.use((ctx, next) => {
const filePath = path.join(__dirname, ctx.path);
if (filePath.endsWith('.js')) {
ctx.res.setHeader('Last-Modified', 'Thu, 25 Nov 2021 06:55:09 GMT')
ctx.res.setHeader('ETag', getHash(filePath).slice(0, 12))
}
const isIllegalPath = !fs.existsSync(filePath) || filePath.endsWith('/')
if (!isIllegalPath) {
const content = fs.readFileSync(filePath, 'utf-8')
ctx.body = content;
} else {
const content = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8')
ctx.body = content;
}
return next();
});
app.listen(8080, () => console.log('server is listening on 8080...'));
在这个配置下,浏览器的行为与我们特殊环境的表现完全一致。而如果在 JavaScript
请求之后加上:
ctx.res.setHeader('Cache-Control', 'max-age=0')
那浏览器每次都会走协商缓存,这才是(我们以为的)正确的表现。
翻阅资料
复现了问题,但又完全不知道问题在哪儿,这个时候最好的办法就是上网翻文档。
关键词:
- 协商缓存 200
- 协商缓存 返回 200
- 协商缓存 memory cache
- 协商缓存 返回 memory cache
- Cache-Control
- negotiate cache but memory cache is used(忽略错词)
- strong cache header is not present
- ......
一顿操作猛如虎,结果什么也没搜到,脑子也迷糊了。
后退一步
🧘♂️ 静下心来仔细想想,现在 Chrome 表现是这样,不如换 Safari 试试,如果 Safari 表现与 Chrome
- 不一致,那可能就是 Chrome 的问题
- 一致,说明浏览器实现是相同的,可以去看标准里有没有相关的文档
试了一下发现,Safari 和 Chrome 的表现一致,那就说明 2 可能是正确的,于是去 MDN 上找文档。
柳暗花明
果然在 Caching 相关的文档里找到了一条貌似可疑的条目:Heuristic freshness checking,中文为启发式新鲜度检测。
文档里是这么写的:
简单翻译一下:
如果服务端没有通过 Cache-Control
或 Expires
等方式指定缓存有效时间,那么浏览器就去看是否存在 Last-Modified
响应头,如果存在,就根据以下公式:
// responseTime: 响应时间
// freshnessLifetime: 缓存有效时间,为 (响应头 Date - 响应头 Last-Midified) / 10
// currentAge: 当前时间
expirationTime = responseTime + freshnessLifetime - currentAge
算出缓存过期时间,并对这个资源应用这个过期时间。
按照这个公式,如果服务端不设置强缓存响应头,那么缓存过期时间大约等于资源修改时间到访问时间的 10%,意思就是浏览器可以认为,如果在我访问到这个资源的时候,你已经有 n 长时间没有修改过资源了,那么你大概率在 n 的 1.1 倍的时间内也不会修改。
完美解释了我们这个环境的访问行为!
文档里还给出了 HTTP 协议规范的地址:rfc7234#section-4.2.2,有兴趣的可以读一下,跟 MDN 里描述得差不多。
chromium 实现
突然想起来我本地有 chromium 的源码,既然如此,就去翻翻,看 chromium 里是不是真的这样。
在 net/http/http_response_headers.cc
文件里翻到了对应的实现:
这里还备注了 implement a smarter heuristic,不知道 Darin 同学还有没有想起来这个事情。
总结
这个行为可以理解吗?其实仔细想想是可以的。
如果服务端没有告诉浏览器缓存的有效时间,那浏览器就可以根据服务端返回的资源状态向前推一个合理时间(10%),在这段时间之内应用强缓存。如果短时间内用户访问频繁的话,对网站来说节约了很多流量和非必要请求,对客户来说网站的加载速度更快了,是一个很好的低成本高收益的案例。