一文彻底搞懂强缓存和协商缓存

1,415 阅读35分钟

juejin.im/post/5df5bc…

github.com/amandakelak…

1、 关于浏览 器缓存 你需要知道的一切

juejin.im/post/5e2d7d…

  • 强缓存
    • HTTP/1.0 时期使用的是 Expires ; 一个死八爷死
    • HTTP/1.1 使用的是 Cache-Control
  • 协商缓存
    • Last-Modified If-Modified-Since

    • ETag If-None-Match**

      **

浏览器缓存分为强缓存和协商缓存

本质区别在于 强缓存是不需要发送HTTP请求的, 而协商缓存需要.

就是在发送HTTP请求之前, 浏览器会先检查一下强缓存, 如果命中直接使用,否则就进入下一步。

强缓存

是否强缓存主要检查什么

浏览器检查强缓存的方式主要是判断这两个字段:

  • HTTP/1.0时期使用的是Expires;
  • HTTP/1.1使用的是Cache-Control

(expires中文意思有效期, cache-control中文意思缓存管理)

Cache-Control: max-age=300

表示的是这个资源在响应之后的300s内过期, 也就是5分钟之内再次获取这个资源会直接使用缓存.


max-age 可设置强缓存周期,在该周期内将直接从客户端缓存获取资源,而不会向服务器发送请求

Cache-Control: max-age=300

表示的是这个资源在响应之后的300s内过期, 也就是5分钟之内再次获取这个资源会直接使用缓存.

Expires Cache-control 的对比

  • Expires产于HTTP/1.0,Cache-control产于HTTP/1.1;
  • Expires设置的是一个具体的时间,Cache-control 可以设置具体时常还有其它的属性;
  • 两者同时存在,Cache-control的优先级更高;
  • 在不支持HTTP/1.1的环境下,Expires就会发挥作用, 所以先阶段的存在是为了做一些兼容的处理.

若是设置了 Expires , 但是服务器的时间与浏览器的时间不一致的时候(比如你手动修改了本地的时间), 那么就可能会造成缓存失效, 因此这种方式强缓存方式并不是很准确, 它也因此在 HTTP/1.1 中被摒弃了


juejin.cn/post/684490…

  • public和private
    • 意味着包括 CDN、代理服务器之类的任何缓存都可以存储响应的副本。public 指令经常是冗余的,因为其他指令的存在(例如 max-age)已经隐式表示响应是可以缓存的
    • 相比之下,private 是一个显式指令,表示只有响应的最终接收方(客户端或浏览器)可以缓存文件。虽然 private 本身并不具备安全功能,但它意在有效防止公共缓存(如 cdn)存储包含用户个人信息的响应。
  • max-age
    • max-age 定义了一个确保响应被视为“新鲜”的时间单位(相对于请求时间,以秒计)。
    • Cache-Control: max-age=60
    • max-age 本身有一个巨坑,它告诉浏览器相关资源已经过期,但没有告诉这个过期版本绝对不能使用。浏览器可能使用它自己的机制来决定是否在不经验证的情况下释放文件的过期副本。这种行为有些不确定性,想确切知道浏览器会怎么做有点困难。为此,我们有一系列更为明确的指令,用来增强 max-age,感谢 Andy Davies 帮我澄清了这一点。
    • s-maxage
      • s-maxage(注意 max 和 age 之间没有 -)会覆盖 max-age 指令,但只在公共缓存中生效。max-age 和 s-maxage 结合使用可以让你针对私有缓存和公共缓存(例如代理、CDN)分别设定不同的刷新时间。
  • no-store
    • no-store 是一个非常高优先级的指令,表示不会将任何信息持久化到任何缓存中,无论是私有与否。任何带有 no-store 指令的资源都将始终命中网络,没有例外。
  • no-cache
    • 它意味着“在你和服务器验证过并且服务器告诉你可以使用缓存的副本之前,你不能使用缓存中的副本”。没错,听起来应该叫 must-revalidate!不过其实也没听起来这么简单。
    • 事实上 no-cache 一个可以确保内容最新鲜的非常智能的方式,同时也可以尽可能使用更快的缓存副本。no-cache 总是会命中网络,因为在释放浏览器的缓存副本(除非服务器的响应的文件已更新)之前,它必须与服务器重新验证,不过如果服务器响应允许使用缓存副本,网络只会传输文件报头:文件主体可以从缓存中获取,而不必重新下载。
    • 所以如我所言,这是一个兼顾文件新鲜度与从缓存中获取文件可能性的智能方式,缺点是它至少会为了一个 HTTP 报头响应而触发网络。
    • no-cache 一个很好的使用场景就是动态 HTML 页面获取。想想一个新闻网站的首页:既不是实时的,也不包含任何敏感信息,但理想情况下我们希望页面始终显示最新的内容。我们可以使用 cache-control: no-cache 来让浏览器首先回访服务器检查,如果服务器没有更新鲜的内容提供(304),那我们就重用缓存的版本。如果服务器有更新鲜的内容,它会返回(200)并且发送最新的文件。
    • 提示:max-age 指令和 no-cache 指令一起发送是没用的,因为重新验证的时间限制是零秒。
  • must-revalidate
    • Cache-Control: must-revalidate, max-age=600 must-revalidate 需要一个关联的 max-age 指令;上文我们把它设置为 10 分钟。
    • 如果说 no-cache 会立即向服务器验证,经过允许后才能使用缓存的副本,那么 must-revalidate 更像是一个具有宽期限的 no-cache。情况是这样的,在最初的十分钟浏览器不会(我知道,我知道......)向服务器重新验证,但是就在十分钟过去的那一刻,它又到服务器去请求,如果服务器没什么新东西,它会返回 304 并且新的 Cache-Control 报头应用于缓存的文件 —— 我们的十分钟再次开始。如果十分钟后服务器上有了一个新的文件,我们会得到 200 的响应和它的报文,那么本地缓存就会被更新。
    • must-revalidate 一个很适合的场景就是博客(比如我这个博客):静态页面很少更改。当然,最新的内容是可以获取的,但考虑到我的网站很少更改,我们不需要 no-cache 这么下重手的东西。相反,我们会假设在十分钟内一切都好,之后再重新验证。
    • proxy-revalidate
      • 和 s-maxage 一脉相承,proxy-revalidate 是公共缓存版的 must-revalidate。它被私有缓存简单地忽略掉了。
  • immutable
    • immutable 是一个非常新而且整洁的指令,它可以把更多有关我们所送出文件类型的信息告知浏览器
    • 如果服务器上有一个更新鲜的内容可用,我们当然想下载它。这样我们将得到一个 200 响应,一个新文件,并且 —— 希望是 —— 问题已经修复了。而如果服务器上没有新文件,我们将返回 304 报头,没有新文件,只有整个往返请求的延迟。如果我们重新验证了大量文件且都返回 304,这会增加数百毫秒的不必要开销。
    • immutable 就是一种告诉浏览器一个文件永远都不会改变的方法 —— 它是不可变的 —— 因此不要再费心重新验证它。我们可以完全减去造成延迟的往返开销。那我们说的一个可变或不可变的文件是什么意思呢?
    • style.css:当我们更改文件内容时,我们不会更改其名称。这个文件始终存在,其内容始终可以更改。这个文件就是可变的。
    • style.ae3f66.css:这个文件是唯一的 —— 它的命名携带了基于文件内容的指纹,所以每当文件修改我们都会得到一个全新的文件。这个文件就是不可变的。
    • 如果我们能够以某种方式告诉浏览器我们的文件是不可变的 —— 文件内容永远不会改变 —— 那么我们也可以让浏览器知道它不必检查更新版本:永远不会有新的版本,因为一旦内容改变,它就不存在了。
    • 这正是 immutable 指令所做的事情: Cache-Control: max-age=31536000, immutable
    • 在支持 immutable 的浏览器中,只要没超过 31,536,000 秒的新鲜寿命,用户刷新也不会造成重新验证。这意味着避免了响应 304 的往返请求,这可能会节约我们在关键路径上(CSS blocks rendering)的大量延迟。在高延迟的场景里,这种节约是可感知的。
    • 注意:千万不要给任何非不可变文件应用 immutable。你还应该有一个非常周全的缓存破坏策略,以防无意中将不可变文件强缓存。
  • stale-while-revalidate 死 l带ou 挖油 瑞歪理带的
    • 关于重新验证我们已经讲了很多了:浏览器启程返回服务器以检查是否有新文件可用的过程。在高延迟的场景里,重新验证的过程是可以被感知的,并且在服务器回应我们可以发布一个缓存的副本(304)或者下载一个新文件(200)之前,这段时间简直就是死时间。
    • stale-while-revalidate 提供的是一个宽限期(由我们设定),当我们检查新版本时,允许浏览器在这段宽限期期间使用过期的(旧的)资源。
    • Cache-Control: max-age=31536000, stale-while-revalidate=86400
    • 这就告诉浏览器,“这个文件还可以用一年,但一年过后,额外给你一天你可以继续使用旧资源,直到你在后台重新验证了它”。
    • 对于非关键资源来说 stale-while-revalidate 是一个很棒的指令,我们当然想要更新鲜的版本,但我们知道在我们检查更新的时候,如果我们依然使用旧资源不会有任何问题。
  • stale-if-error
    • 和 stale-while-revalidate 类似的方式,如果重新验证资源时返回了 5xx 之类的错误,stale-if-error 会给浏览器一个使用旧的响应的宽限期。
    • Cache-Control: max-age=2419200, stale-if-error=86400
    • 这里我们让缓存的有效期为 28 天(2,419,200 秒),过后如果我们遇到内部错误就额外提供一天(86,400 秒),此间允许访问旧版本资源。
  • no-transform
    • no-transform 和存储、服务、重新验证新鲜度之间没有任何关系,但它会告诉中间代理不得对该资源进行任何更改或转换
    • 中间代理更改响应的一个常见情况是电信提供商代表开发者用户做优化:电信提供商可能会通过他们的堆栈代理图片请求,并且在他们移动网络传递给最终用户前做一些优化。
    • 这里的问题是开发人员开始失去对资源展现的控制,而电信服务商所做的图像优化可能过于激进甚至不可接受,或者可能我们已经将图像优化到了理想程度,任何进一步的优化都没必要。
    • 这里,我们是想要告诉中间商:不要转换我们的内容。
    • Cache-Control: no-transform
    • no-transform 可以与其他任何报头搭配使用,且不依赖其他指令独立运行。
    • 当心:有的转换是很好的主意:CDN 为用户选择 Gzip 或 Brotli 编码,看是需要前者还是可以使用后者;图片转换服务自动转成 WebP 等。
    • 当心:如果你是通过 HTTPS 运行,中间件和代理无论如何都不能改变你的数据,因此 no-transform 也就没用
  • Cache Busting
    • 讲缓存而不讲缓存破坏(Cache Busting)是不负责任的。我总是建议甚至在考虑缓存策略之前就先要解决缓存破坏策略。反过来做就是自找麻烦了。
    • 缓存破坏解决这样的问题:“我只是告诉过浏览器在接下来的一年使用这个文件,但后来我改动了它,我不想让用户拿到新副本之前要等一整年!我该怎么做?!”
    • 无缓存破坏 —— style.css
      • 这是最不建议做的事情:完全没有任何缓存破坏。这是一个可变的文件,我们真的很难破坏缓存。
      • 缓存这样的文件你要非常谨慎,因为一旦在用户的设备上,我们就几乎失去了对他们的所有控制。
      • 尽管这个例子是一个样式表,HTML 页面也纯属这个阵营。我们不能更改一个网页的文件名,想象一下这破坏力!—— 这正是我们倾向于从不缓存它们的原因。
    • 查询字符串 —— style.css?v=1.2.14
      • 这里依然是一个可变的文件,但是我们在文件路径后加了个查询字符串。聊胜于无,但不尽完美。如果有什么东西把查询字符串删掉了,我们就完全回到了之前讲的没有缓存破坏的样子。很多代理服务器和 CDN 都不会缓存查询字符串,无论是通过配置(例如 Cloudflare 官方文档写到:“......从缓存服务请求时,‘style.css?something’将会被标准化成‘style.css’”)还是防御性忽略(查询字符串可能包含请求特定响应的信息)。
    • 指纹 —— style.ae3f66.css
      • 添加指纹是目前破坏文件缓存的首选方法。每次内容变更,文件名都会随之修改,严格地讲我们什么都不缓存:我们拿到的是一个全新的文件!这很稳健,并且允许你使用 immutable。如果你能在你的静态资源上实现这个,那就去干!一旦你成功实现了这种非常可靠的缓存破坏策略,你就可以使用最极致的缓存形式:
      • Cache-Control: max-age=31536000, immutable
    • 实施细节
      • 这种方法的要点就是更改文件名,但它不非得是指纹。下面的例子都有同样的效果:
        1. /assets/style.ae3f66.css:通过文件内容的 hash 破坏。
        2. /assets/style.1.2.14.css:通过发行版本号破坏。
        3. /assets/1.2.14/style.css:改变 URL 中的目录。
      • 然而,最后一个示例意味着我们要对每个版本进行版本控制,而不是独立文件。这反过来意味着如果我们只想对我们的样式表做缓存破坏,我们也不得不破坏了这个版本的所有静态文件。这可能有点浪费,所以推荐选项(1)或(2)。
    • Clear-Site-Data
    • 本文我不想深入探究 Clear-Site-Data,毕竟它不是一种 Cache-Control 指令,事实上它是一个全新的 HTTP 报头。
    • Clear-Site-Data: "cache"
    • 给你的域下任何一个静态文件应用这个报头,就会清除整个域的缓存,而不仅是它附着的这个文件。也就是说,如果你需要给你整个网站的所有访客的缓存来个大扫除,你只需把上面这个报头加到你的 HTML 上即可。

案例

银行系统

Request URL: /account/

Cache-Control: private, no-cache, no-store

这将明确指示不得在公共缓存(例如 CDN)中存储任何信息、始终提供最新的副本并且不要持久化任何东西。

实时列车时刻表页面

Cache-Control: no-cache
这个简单的指令会让浏览器不直接未经服务器验证通过就从缓存显示响应。这意味着用户将绝不会看到过期的信息,而如果服务器上有最新信息与缓存中的相同,他们也会享受从缓存中抓取文件的好处

FAQ 页面

像 FAQ 这样的页面可能很少更新,而且其内容不太可能对时间敏感。它当然没有实时运动成绩或航班状态那么重要。我们可以将这样的 HTML 页面缓存一段时间,并强制浏览器定期检查新内容,而不用每次访问都检查。我们这样设置:
Request URL: /faqs/

Cache-Control: max-age=604800, must-revalidate

静态 JS(或 CSS)App Bundle

比方说们的 app.[fingerprint].js,更新非常频繁 —— 几乎每次发布版本都会更新 —— 而我们也投入了工作,在文件每次更改时对其添加指纹,然后这样使用:

Cache-Control: max-age=31536000, immutable
无所谓我们有多频繁的更新 JS:因为我们可以做到可靠的缓存破坏,我们想缓存多久就缓存多久。这个例子里我们设置成一年。之所以是一年首先是因为这已经很久了,而且浏览器无论如何也不可能把一个文件保存这么久(浏览器用于 HTTP 缓存的存储空间是限量的,他们会定期清空一部分;用户也可能自己清空缓存)。超过一年的配置大概率没什么用。

进一步讲,因为这个文件内容永不改变,我们可以指示浏览器这个文件是不可变的。一整年内我们都无须重新验证它,哪怕用户刷新页面都不需要。这样我们不仅获得了使用缓存的速度优势,还避免了重新验证造成的延迟弊端。

装饰性图片

想象一个伴随文章的纯装饰性照片。它不是信息图表,也不含影响页面其他部分阅读的关键内容。甚至如果它完全不见了用户都关注不到。

图片往往是要下载的重量级资源,所以我们想要缓存它;因为它在页面中没有那么关键,所以我们不需要下载最新版本;我们甚至可以在这张照片过时一点后继续使用。看看怎么做:

Cache-Control: max-age=2419200, must-revalidate, stale-while-revalidate=86400
这里我们告诉浏览器缓存 28 天(2,419,200 秒),28 天期限过后我们想向服务器检查更新,如果图片没有超过一天(86,400 秒)的过期时间,那么我们就在后台请求到最新版本后再替换它。

  • 缓存破坏极其极其极其重要。开始做缓存策略之前,先解决好缓存破坏策略。
  • 一般来说,缓存 HTML 内容是个馊主意。HTML URL 不能被破坏,毕竟 HTML 页往往是访问页面其他子资源的入口点,你会把通往静态文件的引用声明也缓存下来。这会让你(和你的用户)......一言难尽。
  • 缓存 HTML 时,如果一类页面从不缓存而其他类页面有时要用缓存,这种同站不同类型的 HTML 页的不同缓存策略会导致不一致性。
  • 如果你能够给你的静态资源可靠地做缓存破坏(使用指纹),那你最好一次性把所有的东西都缓存好几年,以求最优。
  • 非关键内容可以用 stale-while-revalidate 之类的指令给一个不新鲜宽限期。
  • immutable 和 stale-while-revalidate 不仅能带来缓存的传统效益,还让我们在重新验证时降低延迟成本。

最简单的网页

html 只走协商缓存

js css走强缓存 利用hash命名来更新

图片强缓存 每次更改图片不要覆盖图片

协商缓存

  • 协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程
    协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified 和 ETag对比:ETag更精确,性能上Last-Modified好点
    • Last-Modified:
      http1.0
      原理:浏览器第一次访问资源时,服务器会在response头里添加Last-Modified时间点,这个时间点是服务器最后修改文件的时间点,然后浏览器第二次访问资源时,检测到缓存文件里有Last-Modified,就会在请求头里加If-Modified-Since,值为Last-Modified的值,服务器收到头里有If-Modified-Since,就会拿这个值和请求文件的最后修改时间作对比,如果没有变化,就返回304,如果小于了最后修改时间,说明文件有更新,就会返回新的资源,状态码为200
    • ETag:
      http1.1
      原理:与Last-Modified类似,只是Last-Modified返回的是最后修改的时间点,而ETag是每次访问服务器都会返回一个新的token,第二次请求时,该值埋在请求头里的If-None-Match发送给服务器,服务器在比较新旧的token是否一致,一致则返回304通知浏览器使用本地缓存,不一致则返回新的资源,新的ETag,状态码为200

协商缓存概括来说就是浏览器会携带缓存标识(tag)向服务器发送请求, 服务器会根据缓存标识(tag)来决定是否使用缓存.

所以对于服务器的返回结果会有这两种情况:

  • 协商缓存生效, 返回304和Not Modified(空的响应体)
  • 协商缓存失效, 返回200和请求结果

而刚刚提到的这个缓存标识(tag)也是有两种.

分为Last-ModifiedETag.

从字面意思上我们可以看出, Last-Modified表示的是资源的最后修改时间, 因此其中一种协商缓存判断的就是最后修改时间.

其实使用Last-Modified进行协商缓存会经过以下几步:

  1. 浏览器第一次向服务器请求这个资源

  2. 服务器在返回这个资源的时候, 在response header中添加Last-Modified的header, 值为该资源在服务器上最后的修改时间

  3. 浏览器接收到后缓存文件和这个header

  4. 当下次浏览器再次请求这个资源的时候, 检测到有Last-Modified这个header, 就会在请求头中添加If-Modified-Since这个header, 该值就是Last-Modified

  5. 服务器再次接收到该资源的请求, 则根据If-Modified-Since与服务器中的这个资源的最后修改时间做对比

  6. 对比结果相同则返回304和一个空的响应体, 告诉浏览器从自己(浏览器)的缓存中拿

  7. 对比结果不同(If-Modified-Since< 服务器资源最后修改时间), 则表示资源被修改了, 则返回200和最新的资源文件(当然还包括最新的Last-Modefied)

ETag其实与Last-Modefied的原理差不多, 不过它不是根据资源的最后修改时间来判断的, 而是通过一个唯一的标识😊.

在浏览器请求服务器资源的时候, 服务器会根据当前文件的内容, 给文件生成一个唯一的标识, 若是文件发生了改变, 则这个标识就会改变.

服务器会将这个标识ETag放到响应体的header中与请求的资源一起返回给浏览器, 而浏览器同样也会缓存文件与这个header.

在下一次再次加载该资源时, 浏览器会将刚刚缓存的ETag放到请求体头部(request header)的If-None-Match里发送给服务器.

同样的服务器接收到了之后与该资源自身的ETag做对比, 如果一致, 则表示该资源未发生改变, 则直接返回304知会客户端直接使用本地缓存即可. 若是不一致, 则返回200和最新的资源文件(当然还包括最新的ETag)

协商缓存两种方式的比较

首先对于Last-Modified:

  • 若是本地打开了缓存文件, 并没有进行修改, 也还是会改变最后修改时间, 导致缓存失败;
  • 由于Last-Modified是以秒来计时的, 若是某个文件在一秒内被修改了很多次, 那么这时候的Last-Modified并没有体现出修改了.

然后对于ETag:

  • 性能上的不足,只要文件发生改变,ETag就会发生改变.ETag需要服务器通过算法来计算出一个hash值.

总结, 所以对于两种协商缓存:

  • 准确度上ETag更强;

  • 性能上Last-Modified更好;

  • 两者都支持的话,ETag优先级更高.

若是命中了强缓存或者服务器返回了304之后, 要浏览器从缓存中获取资源, 那这些缓存具体是存储在哪里呢?

从优先级上来说分为以下四种:

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

如果什么缓存策略都不设置
这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间

网页获取缓存(三级缓存)

三级缓存原理(大白话)

最后总结一下浏览器的三级缓存原理:

  1. 先去内存看,如果有,直接加载
  2. 如果内存没有,择取硬盘获取,如果有直接加载
  3. 如果硬盘也没有,那么就进行网络请求
  4. 加载到的资源缓存到硬盘和内存

详细解析如下:

从浏览器发起HTTP请求到获得请求结果, 可以分为以下几个过程:

  1. 浏览器第一次发起HTTP请求, 在浏览器缓存中没有发现请求的缓存结果和缓存标识
  2. 因此向服务器发起HTTP请求, 获得该请求的结果还有缓存规则(也就是Last-Modified或者ETag)
  3. 浏览器把响应内容存入Disk Cache, 把响应内容的引用存入Memory Cache
  4. 把响应内容存入Service Worker的Cache Storage(如果Service Worker的脚本调用了cache.put())

下一次请求相同资源的时候:

  1. 调用Service Worker的fetch事件响应
  2. 查看memory Cache
  3. 查看disk Cache. 这里细分为:
    • 有强缓存且未失效, 则使用强缓存, 不请求服务器, 返回的状态码都是200
    • 有强缓存且已失效, 使用协商缓存判断, 是返回304还是200(读取缓存还是重新获取)

Service Worker

Service Worker是运行在浏览器背后的独立线程, 也就是说它脱离了浏览器的窗体, 无法直接访问DOM.功能上主要是能实现:离线缓存、消息推送、网络代理等.比如离线缓存就是Service Worker Cache.

简单来说, 它有以下几个特点:

  • 借鉴了Web Worker是思路
  • 使用Service Worker会涉及到请求拦截, 所以需要用HTTPS协议来保证安全, 传输协议必须是HTTPS
  • 与浏览器其它内建的缓存机制不同, 它可以让我们自由的控制缓存哪些文件、如何匹配读取缓存, 且缓存是持续性的
  • Service Worker同时也是PWA的重要实现机制

Memory Cache

从命名上来说,Memory Cache就是内存中的缓存, 存储的主要是当前页面已经抓取到的资源, 比如页面上已经下载的样式、脚本、图片等.

Memory Cache的特点:

  • 读取效率快, 可是缓存持续时间短, 会随着进程的释放而释放(一旦关闭Tab页面, 就被释放了, 还有可能在没关闭之前, 排在前面的缓存就失效了, 例如一个页面的缓存占用了超级多的内存)
  • 几乎所有的请求资源都能进入memory Cache, 细分来说主要分为preloader和preload这两块.
  • 在从memory Cache读取缓存时, 浏览器会忽视Cache-Control中的一些max-age、no-cache等头部配置, 除非设置了no-store这个头部配置.

preloader

上面👆提到的preloader是页面优化的常见手段之一, 它的作用主要是用于在浏览器打开一个网页的时候,能够一边解析执行js/css, 一边去请求下一个资源, 而这些被preloader请求来的的资源就会被放入memory Cache中,供之后的解析执行操作使用。

preload

preload与preloader仅两个字母之差, 它能显式指定预加载的资源, 这些资源也会被放进memory Cache中, 例如

Disk Cache

Disk Cache, 也叫做HTTP Cache, 是存储在硬盘上的缓存, 所以它是持久存储, 是实际存在于文件系统中的.

从存储效率上说, 它比内存缓存慢, 但是优势在于存储容量更大, 且存储时长更长.

在所有浏览器缓存中,Disk Cache是覆盖面最大的. 它会根据前面我们提到的HTTP header中的缓存字段来判断哪些资源需要缓存, 哪些资源不需要请求而直接使用, 哪些已经过期了需要重新请求获取.

若是命中了缓存之后, 浏览器会从硬盘中直接读取资源, 虽然没有从内存中读取的快, 但是却是比网络缓存快.

前面提到的强缓存和协商缓存也是属于Disk Cache, 它们最终都存储在硬盘里.

Memory Cache与Disk Cache两者的对比:

  • 比较大的JS、CSS文件会被丢硬盘中存储, 反之则存储在内存中
  • 当前系统内存使用率比较高的时候,文件优先进入磁盘

Push Cache

Push Cache(推送缓存), 它是浏览器缓存的最后一段防线, 当以上三种缓存都没有命中的时候, 它才会被使用.

我所知道的, 它只会在会话(Session)中存在, 一旦会话结束它就会被释放, 并且缓存时间也很短暂, 在Chrome浏览器中只有5分钟.

另外由于它是 HTTP/2 中的内容, 因此在国内不是很普及, 这里贴上一个比较好的总结:

  • 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差

  • 可以推送 no-cache 和 no-store 的资源

  • 一旦连接被关闭,Push Cache 就被释放

  • 多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。

  • Push Cache 中的缓存只能被使用一次

  • 浏览器可以拒绝接受已经存在的资源推送

  • 你可以给其他域名推送资源

实际应用

不常变化的资源

对于不常变化的资源:

Cache-Control: max-age=31536000

通常是给Cache-Control设置成一个很大的值, (31536000, 一年). 这个也很好理解, 不常变化的资源, 直接让它使用缓存就是了.

但是有时候为了解决更新的问题, 我们需要在文件名中添加上hash, 版本号等动态字段, 这样就达到了更改引用URL的目的.

常变化的资源

经常变化的资源, 我们进行以下配置:

Cache-Control: no-cache

设置成以上配置, 使得浏览器每次都请求服务器, 然后配合ETag或者Last-Modified来验证资源是否有效

怎么不使用本地缓存 使用协商缓存

Cache-Control

  • no-cache 不使用本地缓存。需要使用协商缓存。
  • 可以在客户端存储资源,每次都必须去服务端做新鲜度校验,来决定从服务端获取新的资源(200)还是使用客户端缓存(304)。也就是所谓的协商缓存

主要使用在 index.html 文件上

怎么禁用本地缓存

  • no-store直接禁止浏览器缓存数据,每次请求资源都会向服务器要完整的资源, 类似于 network 中的 disabled cache。永远都不要在客户端存储资源,永远都去原始服务器去获取资源。

用户行为对缓存的影响

F5 刷新的时候,会暂时禁用强缓存

不同的浏览器在 F5 刷新的时候 ,同一个文件 qq 、fire fox 浏览器会返回 304 Not Nodified,在请求头中不携带 Expires/Cache-Control; 而 chrome 和 safari 刷新的时候,会返回 200 from cache, 没有真正发起请求,走强缓存。可见不同的浏览器反馈是不一致的,所以下面表格中"F5刷新"时 Expires/Cache-Control 会无效我认为是存在一定争议的。

而 Ctrl + F5 强制刷新的时候,会暂时禁用强缓存和协商缓存。

如何设置强缓存和协商缓存

后端服务器,写入代码逻辑中:

res.setHeader('max-age': '3600 public') res.setHeader(etag: '5c20abbd-e2e8') res.setHeader('last-modified': Mon, 24 Dec 2018 09:49:49 GMT)

Nginx 配置

add_header Cache-Control "max-age=3600"

一般来说,通过 nginx 静态资源服务器,会默认给资源带上强缓存、协商缓存的 header 字段。

no-cache和max-age=0区别

使用 JS 简单实现一套 SWR 机制

什么事是SWR

SWR 这个名字来自于 stale-while-revalidate:一种由 HTTP RFC 5861[1] 推广的 HTTP 缓存失效策略

与 max-age 类似,它是控制缓存的,是 Cache-Control 的一个指令,英文单词 stale 的意思是陈旧的,不新鲜的。在 HTTP 缓存领域,stale 用来形容一个缓存过期了。

revalidate:使重新生效

普通的缓存策略是这样的:当一个资源的缓存过期之后,如果想再次使用它,需要先对该缓存进行 revalidate。在 revalidate 执行期间,客户端就得等待,直到 revalidate 请求结束。

在一些特别注重性能的场景下,这种传统的同步更新缓存的机制被认为是有性能问题的。

而这个 SWR 策略是说:当 revalidate 请求进行时,客户端可以不等待,直接使用过期的缓存,revalidate 完了缓存就更新了,下次用的就是新的了。

所以 SWR 实现的功能用通俗的词语解释就是“后台缓存刷新”、“异步缓存更新”。

SWR 通常与 max-age 一起使用,比如 Cache-Control: max-age=1, stale-while-revalidate=59 表示:这个缓存在 1s 内是新鲜的,在 1-60s 内,虽然缓存过期了,但仍可以直接使用,同时进行异步 revalidate,在 60s 后,缓存完全过期需要进行传统的同步 revalidate。

SWR 的使用场景通常有:当前天气状况的 API,或者过去一小时内编写的头条新闻等,最近的指数

设置 max-age=630

  1. 初次请求,应用等待请求,得到新鲜的响应,存入缓存
  2. 在 600 秒内再次请求,不用等,得到缓存响应
  3. 在 610 秒时再次请求,不用等,得到缓存响应
  4. 在 640 秒时再次请求,应用等待请求,得到新鲜的响应,存入缓存

设置 max-age=600, stale-while-revalidate=30

  1. 初次请求,应用等待请求,得到新鲜响应,存入缓存
  2. 在 600 秒内再次请求,不用等,得到缓存响应
  3. 在 610 秒时再次请求,不用等,得到缓存响应,同时后台请求了新的响应,存入缓存
  4. 在 640 秒时再次请求,不用等,得到 610 秒时刷新的缓存响应

代码实现

1、当请求数据时,首先从缓存中读取,并立即返回给调用者

2、如果数据已经过期,则发起 fetch 请求,获取最新数据

我们需要用一个“容器”来缓存请求回来的复杂数据,在 JS 中,我们很容易第一时间想到使用 Object。

使用 Object 虽然没有什么问题,但它的结构是 “字符串—值” 的对应,只支持字符串作为键名。而在 ES6 中,Map 提供了 “值—值” 对应这种更完善的 Hash,更适合用于“键值对”这种数据结构

// 不用缓存的方式
const data = await fetcher();

支持数据缓存

为了让 fetcher 支持数据缓存的能力,这里需要对 fetcher 进行一层封装。

封装之前,先定义一下需要被缓存的数据,那么什么数据需要被缓存呢?

很显然,不就是 请求返回的数据吗。

但与此同时,你也应该想到,如果重复调用函数,最好不要发送多次请求

所以缓存数据中应该有:

  • 请求返回的数据
  • 当前正在进行中的请求(如果有),避免多次请求
const cache = new Map(); // 缓存数据

async function swr(cacheKey, fetcher) {
  // 首先从缓存中获取
  let data = cache.get(cacheKey) || { value: null, promise: null };
  // 写入缓存
  cache.set(cacheKey, data);
  
  // 没有数据且也没有在请求中,需要发送请求
  if (!data.value && !data.promise) {
    // 保存当前请求的 promise
    data.promise = fetcher()
      .then((val) => {
        data.value = val; // 请求成功,将数据存起来
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null; // 请求完毕,不再保存 promise
      });
  }
  
  // 没有数据,但正在请求中,复用保存的 promise
  if (data.promise && !data.value) await data.promise;
  // 返回数据
  return data.value;
}

支持缓存过期时间

在已有缓存能力的基础上,再支持过期时间 cacheTime 就很容易了。

只需要在发起新的请求前,判断下是否过期:

const isStaled = Date.now() - 获取到数据的时间 > cacheTime

所以,在缓存数据中我们还需要保存获取到数据的时间:

const cache = new Map();

// 新增 cacheTime 参数
async function swr(cacheKey, fetcher, cacheTime) {
  let data = cache.get(cacheKey) || { value: null, time: 0, promise: null };
  cache.set(cacheKey, data);
  
  // 是否过期
  const isStaled = Date.now() - data.time > cacheTime;
  // 已经过期了,且也没有在请求中,需要发送请求
  if (isStaled && !data.promise) {
    data.promise = fetcher()
      .then((val) => {
        data.value = val;
        data.time = Date.now(); // 保存获取到数据的时间
      });
      .catch((err) => {
        console.log(err);
      })
      .finally(() => {
        data.promise = null;
      });
  }
  
  if (data.promise && !data.value) await data.promise;
  return data.value;
}

有了以上的封装,调用方法变更为:

首次调用时,会通过接口请求数据。随后调用会立即返回缓存数据。如果调用间隔超过 3s,将先返回缓存数据,再请求接口获取最新的数据。

优化

条件请求

目前的代码中,我们虽然使用了 Map,但使用时 cacheKey 还是一个字符串,没有真正发挥 Map 的作用。作为基础能力的补充,可以考虑将 function 作为 cacheKey 传入来实现条件请求特性。

将函数返回值作为 cacheKey,如果有返回,则执行上述逻辑,如果没有,则不缓存。

const shouldCache = function() { ... }

// cacheKey 支持传入函数
const data = await swr(shouldCache, fetcher, 3000);

async function swr(cacheKey, fetcher, cacheTime) {
  // 如果是函数,则调用函数将返回值作为 cacheKey
  const cKey = typof cacheKey === 'function' ? cacheKey() : cacheKey;
  
  // 如果有 cacheKey 才启用缓存
  if (cKey) {
    let data = cache.get(cKey) || { value: null, time: 0, promise: null };
    cache.set(cKey, data);
    
    ...
  } else {
    return await fetcher();
  }
}

LRU 缓存淘汰

我们知道,Map 的遍历顺序就是插入顺序,再加上其键值对的数据结构,很容易想到基于此特性来实现 LRU 缓存淘汰策略。

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

整个流程大致为:

  1. 新加入的数据插入到第一项
  2. 每当缓存命中(即缓存数据被访问),则将数据提升到第一项
  3. 当缓存数量满的时候,将最后一项的数据丢弃

“Vue 的 keep-alive 组件中就用到了此算法”,间接地向面试官传递 你清楚 Vue 相关的原理实现 这个信息。


LRU 算法通常会单独作为一道手写题

手写 LRU 算法

只需要对之前声明的 cache 容器 const cache = new Map(); 进行一些改造:

  • 规定它的最大缓存容量 capacity
  • 同时向外暴露的 get 和 set API 用法保持不变
class LRUCahe {
  constructor(capacity) {
    this.cache = new Map();
    this.capacity = capacity; // 最大缓存容量
  }

  get(key) {
    // 存在即更新(删除后加入)
    if (this.cache.has(key)) {
      const temp = this.cache.get(key);
      this.cache.delete(key);
      this.cache.set(key, temp);

      return temp;
    }
    return undefined;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      // 存在即更新(删除后加入)
      this.cache.delete(key);
    } else if (this.cache.size >= this.capacity) {
      // 不存在即加入
      // 缓存超过最大值,则移除最近没有使用的,也就是 map 的第一个 key
      // map.keys() 会返回 Iterator 对象
      this.cache.delete(this.cache.keys().next().value);
    }
    this.cache.set(key, value);
  }
}

// before
const cache = new Map();

// after
const cache = new LRUCahe(50); // 缓存最大容量为 50
// 后续的 SWR 代码不做改动

使用 Map 实现 LRU 的时间复杂度为 O(1)

强缓存和协商缓存同时存在,如果强缓存还在生效期则强制缓存覆盖对比缓存,协商缓存不生效;如果强缓存不在有效期,对比缓存生效。即:强缓存优先级 > 对比缓存优先级