HTTP Cache
什么是 HTTP Cache
- 我们知道通过网络获取资源缓慢且耗时,需要三次握手等协议与远程服务器建立通信,对于大点的数据需要多次往返通信大大增加了时间开销,并且当今流量依旧没有理想的快速与便宜。对于开发者来说,长久缓存复用重复不变的资源是性能优化的重要组成部分。
- HTTP 缓存机制就是,配置服务器响应头来告诉浏览器是否应该缓存资源、是否强制校验缓存、缓存多长时间;浏览器非首次请求根据响应头是否应该取缓存、缓存过期发送请求头验证缓存是否可用还是重新获取资源的过程。下面我们就来结合简单的 node 服务器代码(文末)来介绍其中原理。
关键字
响应头 | (常用)值 | 说明 |
---|---|---|
Cache-Control | no-cache, no-store, must-revalidate, max-age, public, private | 控制浏览器是否可以缓存资源、强制缓存校验、缓存时间 |
ETag | 文件指纹(hash码、时间戳等可以标识文件是否更新) | 强校验 |
Last-Modified | 请求的资源最近更新时间 | 弱校验 |
Expires | 资源缓存过期时间 | 与响应头中的 Date 对比 |
请求头 | 值 | 说明 |
---|---|---|
If-None-Match | 缓存响应头中的 ETag 值 | 发送给服务器比对文件是否更新(精确) |
If-Modified-Since | 缓存响应头中的 Last-Modified 值 | 发送给服务器比对文件是否更新(不精确) |
简单流程图
代码准备
- index.html
- img.png
server.js
为了不影响阅读代码贴在页尾,注意需要自行安装
mime
npm包。
不设置
- 设置响应头,则浏览器并不能知道是否应该缓存资源,而是每次都发情新的请求,接受新的资源。
// strategy['no-cache'](req, res, filePath, stat);
// strategy['no-store'](req, res, filePath, stat);
// strategy['cache'](req, res, filePath, stat);
strategy['nothing'](req, res, filePath, stat);
$ node server.js
浏览器里输入:localhost:8080/index.html
- 首次加载
- 刷新,每次和上面一样的效果,都是重新获取资源。
禁止缓存
- 设置响应头
Cache-Control: no-store
或 Cache-Control: no-cache, no-store, must-revalidate
strategy['no-store'](req, res, filePath, stat);
效果和不设置一样,只是明确告诉浏览器禁止缓存资源。
private与public
Cache-Control: public
表示一些中间代理、CDN等可以缓存资源,即便是带有一些敏感 HTTP 验证身份信息甚至响应状态代码通常无法缓存的也可以缓存。通常 public 是非必须的,因为响应头 max-age 信息已经明确告知可以缓存了。Cache-Control: private
明确告知此资源只能单个用户可以缓存,其他中间代理不能缓存。原始发起的浏览器可以缓存,中间代理不能缓存。例如:百度搜索时,特定搜索信息只能被发起请求的浏览器缓存。
缓存过期策略
一般缓存机制只作用于 get
请求
1、三种方式设置服务器告知浏览器缓存过期时间
设置响应头(注意浏览器有自己的缓存替换策略,即便资源过期,不一定被浏览器删除。同样资源未过期,可能由于缓存空间不足而被其他网页新的缓存资源所替换而被删除。):
- 1、设置
Cache-Control: max-age=1000
//响应头中的Date
经过1000s
过期 - 2、设置
Expires
//此时间与本地时间(响应头中的 Date )对比,小于本地时间表示过期,由于本地时钟与服务器时钟无法保持一致,导致比较不精确 - 3、如果以上均为设置,却设置了
Last-Modified
,浏览器隐式的设置资源过期时间为(Date - Last-Modified) * 10%
缓存过期时间。
2、两种方式校验资源过期
设置请求头:
- 1、
If-None-Match
如果缓存资源过期,浏览器发起请求会自动把原来缓存响应头里的ETag
值设置为请求头If-None-Match
的值发送给服务器用于比较。一般设置为文件的 hash 码或其他标识能够精确判断文件是否被更新,为强校验。 - 2、
If-Modified-Since
同样对应缓存响应头里的Last-Modified
的值。此值可能取得 ctime 的值,该值可能被修改但文件内容未变,导致对比不准确,为弱校验。
下面以常用设置了 Cache-Control: max-age=100
和 If-None-Match
的图示说明:
1、(以下便于测试,未准确设置为 100s 。)浏览器首次发起请求,缓存为空,服务器响应:
浏览器缓存此响应,缓存寿命为接收到此响应开始计时 100s 。
2、10s 过后,浏览器再次发起请求,检测缓存未过期,浏览器计算 Age: 10 ,然后直接使用缓存,这里是直接去内存中的缓存,from disk 是取磁盘上的缓存。(
这里不清楚为什么,同样的配置,index.html 文件即便有缓存也 304。
)
- 3、100s 过后,浏览器再次发起请求,检测缓存过期,向服务器发起验证缓存请求。如果服务器对比文件已发生改变,则如 1;否则不返回文件数据报文,直接返回 304。
返回 304 时设置 Age: 0 与不设置效果一样, 猜测是浏览器会自动维护。
强制校验缓存
有时我们既想享受缓存带来的性能优势,可有时又不确认资源内容的更新频度或是其他资源的入口,我们想此服务器资源一旦更新能立马更新浏览器的缓存,这时我们可以设置
Cache-Control: no-cache
再次发起请求,无论缓存资源有没有过期都发起验证请求,未更新返回 304,否则返回新资源。
性能优化
现在一些单页面技术,构建工具十分流行。一般一个 html 文件,每次打包构建工具都会动态默认把众多脚本样式文件打包成一个 bundle.hashxxx.js 。虽然一个 js 文件看似减少了 HTTP 请求数量,但对于有些三方库资源等长期不变的资源可以拆分出来,并设置长期缓存,充分利用缓存性能优势。这时我们完全可以对经常变动的 html 设置 Cache-Control: no-cahce
实时验证是否更新。而对于链接在 html 文件的资源名称均带上唯一的文件指纹(时间戳、版本号、文件hash等),设置 max-age 足够大。资源一旦变动即标识码也会变动,作为入口的 html 文件外链改变,html 变动验证返回全新的资源,拉取最新的外链资源,达到及时更新的效果。老的资源会被浏览器缓存替换机制清除。流程如下:
总结:HTTP 缓存性能检查清单
- 确保网址唯一:一般浏览器以
Request URL
为键值(区分大小写)缓存资源,不同的网址提供相同的内容会导致多次获取缓存相同的资源。常见的误用形式:在网址后面来加个 v=1,例如 https://xxx.com?v=1 来更新新的资源,明显是对 HTTP 缓存的不理解或配置不当造成的滥用
。 - 确保服务器提供了验证令牌
ETag
:提供资源对比机制。 - 确定中间代理可以缓存哪些资源:对于个人隐私信息可以设置
private
,对于公共资源例如 CDN 资源可以设置public
。 - 为每个资源设置最佳的缓存寿命:
max-age 或 Expires
,对于不经常变动或不变的资源设置尽可能大的缓存时间,充分利用缓存性能。 - 确认网站的层次机构:例如单页面技术,对于频繁更新的主入口 index.html 文件设置较短的缓存周期或
no-cache
强制缓存验证,以确保外链资源的及时更新。 - 最大限度减少文件搅动:对于要打包合并的文件,应该尽量区分频繁、不频繁变动的文件,避免频繁变动的内容导致大文件的文件指纹变动(后台服务器计算文件 hash 很耗性能),尽量频繁变动的文件小而美,不经常变动例如第三方库资源可以合并减少HTTP请求数量。
参考
听说你用webpack处理文件名的hash?那么建议你看看你生成的hash对不对
附代码
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>HTTP Cache</title>
</head>
<body>
<img src="img.png" alt="流程图">
<!-- <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> -->
</body>
</html>
server.js
let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');// 非 node 内核包,需 npm install
let crypto = require('crypto');
// 缓存策略
const strategy = {
'nothing': (req, res, filePath) => {
fs.createReadStream(filePath).pipe(res);
},
'no-store': (req, res, filePath, stat) => {
// 禁止缓存
res.setHeader('Cache-Control', 'no-store');
// res.setHeader('Cache-Control', ['no-cache', 'no-store', 'must-revalidate']);
// res.setHeader('Expires', new Date(Date.now() + 30 * 1000).toUTCString());
// res.setHeader('Last-Modified', stat.ctime.toGMTString());
fs.createReadStream(filePath).pipe(res);
},
'no-cache': (req, res, filePath, stat) => {
// 强制确认缓存
// res.setHeader('Cache-Control', 'no-cache');
strategy['cache'](req, res, filePath, stat, true);
// fs.createReadStream(filePath).pipe(res);
},
'cache': (req, res, filePath, stat, revalidate) => {
let ifNoneMatch = req.headers['if-none-match'];
let ifModifiedSince = req.headers['if-modified-since'];
let LastModified = stat.ctime.toGMTString();
let maxAge = 30;
new Promise((resolve, reject) => {
// 生成文件 hash
let out = fs.createReadStream(filePath);
let md5 = crypto.createHash('md5');
out.on('data', function (data) {
md5.update(data)
});
out.on('end', function () {
let etag = md5.digest('hex');
resolve(etag);
});
}).then( etag => {
if ( ifNoneMatch ) {
if (ifNoneMatch == etag) {
console.log('304');
// res.setHeader('Cache-Control', 'max-age=' + maxAge);
// res.setHeader('Age', 0);
res.writeHead('304');
res.end();
} else {
// 设置缓存寿命
res.setHeader('Cache-Control', 'max-age=' + maxAge);
res.setHeader('Etag', etag);
fs.createReadStream(filePath).pipe(res);
}
}
/*else if ( ifModifiedSince ) {
if (ifModifiedSince == LastModified) {
res.writeHead('304');
res.end();
} else {
res.setHeader('Last-Modified', stat.ctime.toGMTString());
fs.createReadStream(filePath).pipe(res);
}
}*/
else {
// 设置缓存寿命
console.log('首次响应!');
res.setHeader('Cache-Control', 'max-age=' + maxAge);
res.setHeader('Etag', etag);
// res.setHeader('Last-Modified', stat.ctime.toGMTString());
revalidate && res.setHeader('Cache-Control', [
'max-age=' + maxAge,
'no-cache'
]);
fs.createReadStream(filePath).pipe(res);
}
});
}
};
http.createServer((req, res) => {
console.log( new Date().toLocaleTimeString() + ':收到请求')
let { pathname } = url.parse(req.url, true);
let filePath = path.join(__dirname, pathname);
// console.log(filePath);
fs.stat(filePath, (err, stat) => {
if (err) {
res.setHeader('Content-Type', 'text/html');
res.setHeader('404', 'Not Found');
res.end('404 Not Found');
} else {
res.setHeader('Content-Type', mime.getType(filePath));
strategy['no-cache'](req, res, filePath, stat);
//strategy['no-store'](req, res, filePath, stat);
// strategy['cache'](req, res, filePath, stat);
// strategy['nothing'](req, res, filePath, stat);
}
});
})
.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
})
.listen(8080);