(: 因为一手文章在掘金,只换了 gif ,剩下的原图有水印,懒得换了,gif 有些模糊,自行脑补。

蓦然回首,那“缓存”却在灯火阑珊处~

Situation(情景)

作为日常的知识复习,重温一下浏览器缓存。

知识不止于眼前的文字,更在当下的实操。

Task(任务)

  • 通过查阅权威资料整理关键知识点
  • 通过实操,重温缓存场景

Action(动作)

我们可以先来聊一个概念,就是一个东西的出现必然是有意义的。为什么要缓存?

简单讲,节省不必要的流量开支,提升客户端访问速度。

更宏观意义上来讲:

蝴蝶效应:大量网络请求会消耗资源,产生热量,导致全球变暖。

举个例子:当 Client 每次向 Server 请求资源,相同的资源只有首次加载会耗费流量,后面统一从本地缓存中拿。


开始之前先介绍一个概念:新鲜度(Freshness)

摘录自:HTTP 规范

A fresh response is one whose age has not yet exceeded its freshness lifetime. Conversely, a stale response is one where it has.

新鲜响应是指其年龄尚未超过其新鲜期的响应。反之,陈旧的响应是指它已经超过了。

A response’s freshness lifetime is the length of time between its generation by the origin server and its expiration time. An explicit expiration time is the time at which the origin server intends that a stored response can no longer be used by a cache without further validation, whereas a heuristic expiration time is assigned by a cache when no explicit expiration time is available.

响应的保鲜期是指从源服务器生成到过期时间之间的时间长度。明确的过期时间是指源服务器打算让缓存在没有进一步验证的情况下不再使用存储的响应的时间,而启发式过期时间是由缓存在没有明确过期时间的情况下指定的。

确定新鲜度的主要机制是源服务器使用Expires标头字段(第 5.3 节)或 max-age 响应指令(第 5.2.2.8 节)提供未来的明确到期时间。

公式:response_is_fresh = (freshness_lifetime > current_age)

  • freshness_lifetime

    • 如果缓存是共享的并且存在 s-maxage 响应指令(第 5.2.2.9 节),则使用它的值,或者
    • 如果存在 max-age 响应指令(第 5.2.2.8 节),请使用它的值,或者
    • 如果存在Expires响应头字段(第 5.3 节),则使用其值减去Date响应头字段的值,或者
    • 否则,响应中不存在明确的到期时间。启发式的新鲜度生命周期可能适用;请参阅第 4.2.2 节
  • current_age

    • 由于规范内容过多,并非本文的重点,这里不再做赘述,详细可查看:传送门

总结一句话:通过 Cache-ControlExpires ,原始服务器可以对资源定义其保质期。在保质期之内,缓存就认为该资源是新鲜的,可以直接传回给客户端,如果过期那么就需要进行再验证。

HTTP 缓存策略:

  • 强缓存(Response Header 中的 ExpriesCache-Control 头)
  • 协商缓存(Res & Req Header 中的 Last-Modify/If-Modify-SinceEtag/If-None-Match

下面展开讲一讲

Expries需要固定格式的绝对日期

表示缓存过期的时间,相对于主机而言的绝对时间。

先简单用 nest 随便搭建个静态资源服务器,并设置 Expires 头:

app.useStaticAssets(join(__dirname, '..', 'public'), {
  setHeaders: (res) => {
    res.setHeader(
      'Expires',
      dayjs('2022-08-19 13:00:15').add(15, 'seconds').toDate(),
    );
    res.setHeader('Cache-Control', 'private');
    res.setHeader('Date', Date());
  },
  lastModified: false,
  etag: false,
});

来个动画

Kapture 2022-08-19 at 12.46.04.gif

image.png

首次请求时,浏览器进行了数据缓存,再次请求就不再向服务器发送请求,而是直接从 disk cache 中拿了。

Cache-Control

下面是头可选的几个值:

Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

先在静态资源服务器的 Response 上塞个 Cache-Control 头:

app.useStaticAssets(join(__dirname, '..', 'public'), {
  setHeaders: (res) => {
    // res.setHeader(
    //   'Expires',
    //   dayjs('2022-08-19 13:00:15').add(15, 'seconds').toDate(),
    // );
    res.setHeader('Cache-Control', 'max-age=3000');
    res.setHeader('Date', Date());
  },
  lastModified: false,
  etag: false,
});

依旧再看动画:

Kapture 2022-08-19 at 13.01.30.gif

image.png

max-age=3000 的意思就是 3000 秒后过期。这个 3000 秒是相对于 ResponseDate 头而言的。

image.png

为了说明根本机时间是无关的,我截图了最新一次请求和我本机时间的截图拼接。

那么如果两个头都存在呢?会做何种决策?实践的结果可能并不能说服我们,一样的思路,先翻规范,后实践证实。

If a response includes a Cache-Control field with the max-age directive (Section 5.2.2.8), a recipient MUST ignore the Expires field. Likewise, if a response includes the s-maxage directive (Section 5.2.2.9), a shared cache recipient MUST ignore the Expires field. In both these cases, the value in Expires is only intended for recipients that have not yet implemented the Cache-Control field.

如果一个响应包括一个带有max-age指令的Cache-Control字段(第5.2.2.8节),接收者必须忽略 Expires 字段。同样,如果一个响应包括 s-maxage 指令(第5.2.2.9节),共享缓存接收者必须忽略 Expires 字段。在这两种情况下,Expires 中的值只针对还没有实现 Cache-Control 字段的接收者。

规范结论:如果同时存在,那么以 max-age 为准。

下面,我们同时设置这两个头:

app.useStaticAssets(join(__dirname, '..', 'public'), {
  setHeaders: (res) => {
    res.setHeader(
      'Expires',
      dayjs('2022-08-20 13:00:15').add(15, 'seconds').toDate(),
    );
    res.setHeader('Cache-Control', 'public,max-age=0');
    res.setHeader('Date', Date());
  },
  lastModified: false,
  etag: false,
});

接着看动画:

Kapture 2022-08-19 at 20.48.32.gif

可以看到,每次请求都是原始的 4.8m。小提示:max-age=0 等同于 no-cache

小 Tip:每次请求如何要求浏览器不拿内存呢?前提是不使用 Response Header 做文章

    1. Dev Tools 里开启 Disable cache

    image.png

    1. 借助 Chrome 插件, Requesty 来进行手动设置

image.png

这里可以再提一个概念:重新加载和强制重新加载,见MDN

重新加载的实现方式:据 MDN 描述:

For recovering from window corruption or updating to the latest version of the resource, browsers provide a reload function for users.

That behavior is also defined in the Fetch standard and can be reproduced in JavaScript by calling fetch() with the cache mode set to no-cache (note that reload is not the right mode for this case):

上文借助 chrome 插件就是用了后者去实现重新加载的。

强制重新加载的实现方式:

  • 开启 Disable cache

  • 在浏览器中操作

    image.png


接着上面的内容,我们来继续扩展讲讲剩下的几个值,应用到 Cache-Control 上有什么效果。这里只列举常用的。

  • private / public

    • private 代表私有缓存,例如近用户浏览器本地缓存等;
    • public 代表共享缓存;
      image.png
      —- 来自Stackoverflow
  • no-cache

    • 要求缓存重新验证与源服务器的每个请求。
  • no-store

    • 禁止缓存

到这里,强缓存就结束了。

那么思考一个问题,缓存失效了,它还能用吗?我想“续命”再用怎么办?跟服务端 token 续命相仿。带着这个问题,我们开始协商缓存的介绍。


同样的形式,跟上文一样,我们开始从 http 1.0 的标准到 http 1.1

Last-Modified & If-Modified-Since

Last-Modified: 2022-08-19 22:00:00
Cache-Control: max-age=20

当请求资源后,浏览器收到这个相应头后开始缓存,并且标注“这个资源”最后更改的时间是:2022-08-19 22:00:00,并且在 20 秒后过期。

等这 20s过期后,浏览器带着客户端的 If-Modified-Since 头说:老弟,我帮你问问,服务器的资源有没有变更过,问完之后,发现就没变过,返回 304,继续从缓存里拿。

如果是新的,那么就执行下类似 no-cache 的操作,给到客户端最新的资源。

我们开始操作静态资源服务器的设置:

app.useStaticAssets(join(__dirname, '..', 'public'), {
  setHeaders: (res, path, stat) => {
    // res.setHeader(
    //   'Expires',
    //   dayjs('2022-08-20 13:00:15').add(15, 'seconds').toDate(),
    // );
    // res.setHeader('Cache-Control', 'private');
    res.setHeader('Last-Modified', dayjs('2022-08-19 19:38:15').toDate());
    res.setHeader('Date', Date());
  },
  // lastModified: false,
  etag: false,
  redirect: false,
  index: false,
  maxAge: 2000,
});

nestmax-age 是以 ms 为单位的,也就是在 2s 后进行对比:Last-Modified & If-Modified-Since

开始看动画:

Kapture 2022-08-19 at 20.55.52.gif

image.png

可以看到,第一次请求是 4.8MB, 第二次是 290B(因为 2 秒很快,所以已经过了缓存,并且比对完了,此时的 200 等同于 304 效果),可以仔细观察下第5次,恰好命中了缓存,所以是 disk cache

Etag & If-None-Match

细心的同学可能已经发现个问题,根据修改时间去做对比很容易漏掉一种场景:就是打开了文件又保存了一下,没做任何变更,但是此时,计算机已经给文件打上了最新的时间

这个时候,后面的这一对头就派上用场了。


所谓:“年年岁岁花相似,岁岁年年人不同”。本质来讲这岁岁年年其实还是这个人,但是花可就不是了,时间也不是。

同理,Etag 是根据内容产生的 hash ,跟一个人的基因序列一样,除非基因序列变了,那这个人就变了,至于变成啥样子,咱也不知道。所以内容变了,Etag 也就变了。

跟上面的标签对是一样的,缓存前,本地记下原始的 Etag,等到缓存过期后,把原始的 Etag 赋给 RequestIf-None-Match 头。带着这个头,去服务端对比。

静态服务器代码:

app.useStaticAssets(join(__dirname, '..', 'public'), {
  setHeaders: (res, path, stat) => {
    // res.setHeader(
    //   'Expires',
    //   dayjs('2022-08-20 13:00:15').add(15, 'seconds').toDate(),
    // );
    // res.setHeader('Cache-Control', 'private');
    // res.setHeader('Last-Modified', dayjs('2022-08-19 19:38:15').toDate());
    res.setHeader('Date', Date());
  },
  lastModified: false,
  // etag: false,
  redirect: false,
  index: false,
  maxAge: 2000,
});

继续看动画:

Kapture 2022-08-19 at 21.17.10.gif

image.png

还是老对话了,客户端缓存过期了,浏览器带着原始 Etag (If-None-Match)去请求服务器,服务器发现 Etag 没变过,说:“浏览器,你可以继续用原来的缓存”,于是,客户端此时拿到的资源还是原来缓存里的。

Result(结论)

HTTP 缓存体系

都看到这里了,剩下的总结就交给读者吧,这里只列举大纲,实际详细的内容上文已经有了。

  • 存储策略
  • 过期策略
  • 对比策略

参考资料


最后,本次文章的内容到这里就全部结束了,我是不换,浪子回头金不换的那个不换哟~,如果本文对你有所帮助,可以帮忙点个小赞,如果有不对的地方,可以评论帮我斧正。