likes
comments
collection

再看一次HTTP缓存,下次一定会!

作者站长头像
站长
· 阅读数 16

1. 前言

公司业务群里经常反馈某个功能突然失效了,前端开发儿回复:清理浏览器缓存,重新访问。客户乖乖地操作后,反馈可以使用了!

我也不清楚到底哪里缓存了,缓存了什么,反正万能的重启/清理缓存真真好使~dog。

今天就一起来扒拉下浏览器缓存究竟怎么回事。

本文使用nodejs开启本地服务,html文件设置一个img标签来访问图片,研究静态资源缓存。全文案例代码见Github仓库

2. HTTP缓存标头

浏览器缓存设置需要靠HTTP头部控制缓存策略,所以咱们先了解下涉及到的HTTP头部。

正所谓:“工欲善其事,必先利其器”。

2.1 Expires

HTTP/1.0时通过Expires头部来告诉浏览器将资源缓存下来。该值是服务端设置的绝对时间,告诉浏览器资源的过期时间,即在xx时间缓存过期,在过期时间内可以采用本地缓存资源,过期后需要重新请求服务器获取资源。

Expires: new Date('2022/10/18 20:30:00').toUTCString(); // http时间戳

由于Expires的值是服务端的绝对时间,若客户端与服务端时间不一致,则会导致资源缓存更新不及时或缓存失效问题。因此在HTTP1.1时提出了Cache-Control: max-age=xx头部来替代Expires

2.2 Cache-Control

HTTP/1.1时通过Cache-Control头告诉浏览器采用缓存,而不同的值对应不同的缓存策略,它可以作为请求头和响应头。

Cache-Control值含义
public表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容(例如:1.该响应没有max-age指令或Expires消息头;2. 该响应对应的请求方法是 POST
private表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。私有缓存可以缓存响应内容
no-cache在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证 (协商缓存验证)
no-store缓存不应存储有关客户端请求或服务器响应的任何内容,即不使用任何缓存
max-age=5设置缓存存储的最大周期,超过这个时间缓存被认为过期 (单位秒)。与Expires相反,时间是相对于请求的时间
s-maxage=5覆盖max-age或者Expires头,但是仅适用于共享缓存 (比如各个代理),私有缓存会忽略它
must-revalidate一旦资源过期(比如已经超过max-age),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求

Cache-Control的全部值可查看MDN Cache-Control

  1. 通过设置max-age的值来替代Expires,时间是相对于客户端的请求时间,即在xx秒后缓存过期。
  2. 这2种缓存策略是属于强缓存,在过期时间内都采用本地缓存,只有当缓存过期后,才重新请求服务器。
  3. 如果同时设置了这2个头部字段,会忽略Expires
const http = require('http');
const fs = require('fs');
const url = require('url');

http.createServer((req, res) => {
  const {
    pathname
  } = url.parse(req.url)
  if (pathname === '/') {
    const data = fs.readFileSync('./index.html');
    res.end(data);
  } else if (/.*?\.jpe?g/.test(pathname)) {
    res.setHeader('Cache-Control', 'max-age=5');
    const data = fs.readFileSync('./images/02.jpeg');
    res.end(data)
  } else {
    res.end('Not Found')
  }
}).listen(8000, () => {
  console.log('服务已在8000端口启动:localhost:8000')
})

在5秒内,刷新浏览器重复访问图片都是复用的本地缓存资源。

再看一次HTTP缓存,下次一定会!

5秒后,图片缓存过期,将重新请求服务器,刷新本地缓存。

由于max-age值单位是,若缓存资源在1秒内已更新,则会导致客户端的资源无法及时得到更新,访问的仍是旧版本资源问题,因此提出了协商缓存策略,Cache-Control: no-cache表示采用协商缓存,配合一对Last-Modified/If-Modified-Since头优化此问题。

2.3 Last-Modified / If-Modified-Since

服务器通过响应Last-Modified头告诉浏览器将资源缓存起来,客户端需要在下一次访问资源时请求服务器,并且带上If-Modified-Since请求头信息,问问资源是否过期,由服务器响应通过是否过期,是否需要重新发送资源给客户端。

Last-Modified值是资源修改时间,由服务器响应给客户端,下次客户端访问资源时,带上If-Modified-Since请求头,值为Last-Modified值。服务器收到客户端请求时,校验If-Modified-Since值是不是和资源的修改时间值一致。若一致,则表示资源未过期,返回给客户端304 Not Modified响应码;若不一致,则表示资源过期,返回更新后的资源给客户端。

if (/.*?\.jpe?g/.test(pathname)) {
    // 设置头字段大小写无所谓,浏览器会统一转小写处理,获取头采用全小写
    res.setHeader('Cache-Control', 'no-cache');
    // 获取资源修改时间
    const stat = fs.statSync('./images/02.jpeg');
    // 设置GMT时间格式
    const lastModified = stat.mtime.toUTCString();
    res.setHeader('Last-Modified', lastModified);
    
    // 获取请求头,判断是否和服务器修改时间一致
    const ifModifiedSince = req.headers['if-modified-since'];
    if (ifModifiedSince === lastModified) {
      res.writeHead(304, 'Not Modified');
      res.end();
      return;
    }

    const data = fs.readFileSync('./images/02.jpeg');
    res.end(data)
  }

我们可以通过修改图片名来更新修改时间,来验证浏览器是否会重新请求服务器,答案是肯定会重新请求的,因为服务器的Last-Modified值已经变更,与客户端的If-Modified-Since值已不同。

再看一次HTTP缓存,下次一定会!

由于修改资源名字也会刷新资源的修改时间,导致资源实际内容未修改浏览器却要重新获取资源,浪费网络流量等问题,因此提出了Etag/If-None-Match来优化此问题。

2.4 Etag / If-None-Match

服务器通过响应Etag头告诉浏览器将资源的指纹信息,客户端需要在下一次访问资源时请求服务器,并且带上If-None-Match请求头信息,问问资源是否过期,由服务器响应资源是否过期,是否需要重新发送资源给客户端。

Etag值是根据资源的二进制数据计算出来的hash值,因此资源有变动则Etag值也会更新,由服务器响应给客户端,下次客户端访问资源时,需要带上If-None-Match请求头,值为Etag值。服务器收到客户端请求时,校验If-None-Match请求头的值是不是和服务器上资源的指纹一致。若一致,则表示资源未过期,返回给客户端304 Not Modified响应码;若不一致,则表示资源过期,返回更新后的资源给客户端。

if (/.*?\.jpe?g/.test(pathname)) {
    res.setHeader('Cache-Control', 'no-cache');
    // 测试使用第3方etag包来计算资源的hash值。其实这里测试,可以手动设置etag值,例如res.setHeader('Etag', ‘1234’);
    const etagContent = etag(data);
    res.setHeader('Etag', etagContent);
    
    const ifNoneMatch = req.headers['if-none-match'];
    if (ifNoneMatch === etagContent) {
      res.writeHead(304, 'Not Modified');
      res.end();
      return;
    }

    const data = fs.readFileSync('./images/02.jpeg');
    res.end(data)
  }

第二次访问时,Etag / If-None-Match值一致,服务器直接返回304,告诉浏览器采用缓存。

再看一次HTTP缓存,下次一定会!

如果我们将资源内容进行更改,浏览器再次访问图片时,服务器会重新返回资源,并刷新客户端缓存。

Last-ModifiedEtag同时存在时,优先采用Etag,因为Etag判断资源是否过期更精准。

由于Etag是根据资源计算而来,如果资源较大,并且每次条件请求时都要计算Etag值,也是一个不小的开销,因此我们在实际开发中根据不同资源,设置不同的缓存策略。

3. 缓存策略

  1. Cache-Control: max-age=0; 表示资源立刻过期,效果和设置no-cache一致,然后采用协商缓存。
  2. Cache-Control: no-cache; Last-ModifiedEtag同时存在的情况,根据Etag优先原则。但是如果服务器没有明确响应缓存方式,则会激活浏览器启发式缓存
  3. 若浏览器强制刷新或者在控制台☑️禁用缓存,则浏览器会自动在请求头添加Cache-Control: no-cachePragma: no-cache,表明应向服务器请求资源。

Pragma 是一个在 HTTP/1.0 中规定的通用首部,这个首部的效果依赖于不同的实现,所以在“请求 - 响应”链中可能会有不同的效果。它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器,那时候 HTTP/1.1 协议中的 Cache-Control 还没有出来。

再看一次HTTP缓存,下次一定会!

4. 启发式缓存

如果一个可以缓存的请求没有设置Expires / Cache-Control,但是响应头有设置Last-Modified标头,这种情况下浏览器会有一个默认的缓存策略:(当前时间 - Last-Modified)*0.1,在此期间浏览器会一直重用本地缓存,这就是启发式缓存

再看一次HTTP缓存,下次一定会!

启发式缓存是在Cache-Control被广泛采用之前出现的一种解决方法,基本上所有响应都应明确指定Cache-Control标头。否则会导致客户端在一定时间内采用本地缓存,而服务器无计可施,只能要求客户手动清理浏览器缓存或者等待缓存过期。

5. 最佳实践

使用前端框架开发的项目,默认打包项目生成的js,css等资源文件,在每次打包时会计算hash值作为文件名,像这种我们可以确定的资源可以设置一个较长时间的强缓存,毕竟每次打包后文件名不一致客户端会当作新资源请求服务器,可以避免协商缓存请求服务器的步骤。

6. 缓存流程图

未过期
已过期/不存在协商缓存
未过期
已过期
访问资源
是否命中缓存
强缓存是否过期
服务器返回200和资源实体
重用本地缓存
协商缓存是否过期
服务器返回304采用本地缓存