相关:[[010.HTTP 缓存协议|HTTP 缓存协议]]

什么是浏览器缓存

在正式开始讲解浏览器缓存之前,我们先来回顾一下整个 Web 应用的流程。

![[2021-12-03-063551.png]]

上图展示了一个 Web 应用最简单的结构。客户端向服务器端发送 HTTP 请求,服务器端从数据库获取数据,然后进行计算处理,之后向客户端返回 HTTP 响应。

上面整个流程中,哪些地方比较耗费时间呢?总结起来有如下两个方面:

  1. 发送请求的时候
  2. 涉及到大量计算的时候

一般来讲,上面两个阶段比较耗费时间。

首先是发送请求的时候。这里所说的请求,不仅仅是 HTTP 请求,也包括服务器向数据库发起查询数据的请求。

其次是大量计算的时候。一般涉及到大量计算,主要是在服务器端和数据库端,服务器端要进行计算这个很好理解,数据库要根据服务器发送过来的查询命令查询到对应的数据,这也是比较耗时的一项工作。

因此,单论缓存的话,其实在很多地方都可以做缓存。例如:

针对各个地方做出适当的缓存,都能够很大程度的优化整个 Web 应用的性能。但是要逐一讨论的话,是一个非常大的工程量,所以本文我们主要来看一下浏览器缓存,这也是和前端开发息息相关的。

整个浏览器的缓存过程如下:

![[2021-12-03-063613.png]]

从上图可以看到,整个浏览器端的缓存没有想象的那么复杂。其最基本的原理就是:

以上两点结论就是浏览器缓存机制的关键,它确保了每个请求的缓存存入与读取,只要再理解浏览器缓存的使用规则,那么所有的问题就迎刃而解了。

按照缓存位置分类

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。这四种依次为:

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache

如果一个请求在上述几个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容。为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去。具体来说:

Service Worker

Service Worker 是运行在浏览器背后的独立线程,可以用来实现缓存功能,一般配合 PWA(Progressive Web Apps、渐进式网络应用程序)使用。

使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。

Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存就可以直接读取缓存文件,否则就去请求数据。

当 Service Worker 没有命中缓存,需要去调用 fetch 函数获取数据。也就是说,如果没有在 Service Worker 命中缓存,会根据缓存查找优先级去查找数据。但是不管是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示是从 Service Worker 中获取的内容。

![[2021-12-03-063636.png]]

Memory Cache

Memory Cache 也就是内存中的缓存,主要包含的是当前页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。

读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。

当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存:

![[2021-12-03-063700.png]]

Memory Cache 机制保证了一个页面中如果有两个相同的请求,都实际只会被请求最多一次,避免浪费。

Disk Cache

Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。

在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。

并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。

凡是持久性存储都会面临容量增长的问题,Disk Cache 也不例外。

在浏览器自动清理时,会有特殊的算法去把“最老的”或者“最可能过时的”资源删除,因此是一个一个删除的。不过每个浏览器识别“最老的”和“最可能过时的”资源的算法不尽相同,这也可以看作是各个浏览器差异性的体现。

Push Cache

Push Cache 翻译成中文叫做“推送缓存”,是属于 HTTP/2 中新增的内容。

当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在 Chrome 浏览器中只有 5 分钟左右,同时它也并非严格执行 HTTP/2 头中的缓存指令。

Push Cache 在国内还不够普及。

这里推荐阅读 Jake Archibald 的 HTTP/2 push is tougher than I thought 这篇文章。

文章中的几个结论:

按照缓存类型分类

按照缓存类型来进行分类,可以分为强缓存和协商缓存。需要注意的是,无论是强制缓存还是协商缓存,都是属于 Disk Cache(或者叫做 HTTP Cache)里面的一种。

强缓存

强缓存的含义是:当客户端请求后,会先访问缓存数据库看缓存是否存在。如果存在则直接返回;不存在则请求真实服务器,响应后再写入缓存数据库。

强缓存直接减少请求数,是提升最大的缓存策略。如果考虑使用缓存来优化网页性能的话,强缓存应该是首先被考虑的。

可以造成强缓存的响应头字段是 Cache-Control 和 Expires。

Expires

这是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间(当前时间+缓存时间),如:

Expires: Thu, 10 Nov 2017 08:45:11 GMT

在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。

但是,这个字段设置时有两个缺点:

Cache-Control

已知 Expires 的缺点之后,在 HTTP/1.1 中,增加了一个字段 Cache-Control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求

这两者的区别就是前者是绝对时间,而后者是相对时间,当然 Cache-Control 还可以取很多值。如下:

Cache-Control: max-age=2592000

下面列举一些 Cache-Control 字段常用的值:(完整的列表可以查看 MDN)

这些值可以混合使用,例如:Cache-control: public, max-age=2592000。在混合使用时,它们的优先级如下图:

![[2021-12-03-063734.png]]

max-age=0no-cache 等价吗?

从规范的字面意思来说,max-age 到期是应该(SHOULD)重新验证,而 no-cache 是必须(MUST)重新验证。但实际情况以浏览器实现为准,大部分情况他们俩的行为还是一致的。(如果是 max-age=0, must-revalidate 就和 no-cache 等价了)

在 HTTP/1.1 之前,如果想使用 no-cache,通常是使用 Pragma 字段,如 Pragma: no-cache(这也是 Pragma 字段唯一的取值)。

但是这个字段只是浏览器约定俗成的实现,并没有确切规范,因此缺乏可靠性。它应该只作为一个兼容字段出现,在当前的网络环境下其实用处已经很小。

总结一下,自从 HTTP/1.1 开始,Expires 逐渐被 Cache-Control 取代。

Cache-Control 是一个相对时间,即使客户端时间发生改变,相对时间也不会随之改变(相对的是服务端返回的 Data 响应头,如果没有 Data 响应头才是相对本地时间),这样可以保持服务器和客户端的时间一致性。而且 Cache-Control 的可配置性比较强大。Cache-Control 的优先级高于 Expires。

为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段一般都会设置。

协商缓存

当强缓存失效(超过规定时间)或 Cache-Controlno-cache 时,就需要使用协商缓存,由服务器决定缓存内容是否失效。

流程上说,浏览器先请求缓存数据库,返回一个缓存标识。之后浏览器拿这个标识和服务器通讯。如果缓存未失效,则返回 HTTP 状态码 304 表示继续使用,于是客户端继续使用缓存;

![[2021-12-03-063801.png]]

如果失效,则返回新的数据和缓存规则,浏览器响应数据后,再把规则写入到缓存数据库。

![[2021-12-03-063821.png]]

协商缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容(响应体),因此在响应体体积上的节省是它的优化点。

它的优化主要体现在“响应”上面通过减少响应体体积,来缩短网络传输时间。所以和强缓存相比提升幅度较小,但总比没有缓存好。

协商缓存是可以和强缓存一起使用的,作为在强制缓存失效后的一种后备方案。实际项目中他们也的确经常一同出现。

与服务器协商携带的缓存标识(请求服务器判断本地缓存是否过期)有 2 组字段:

  1. Last-ModifiedIf-Modified-Since
  2. EtagIf-None-Match

Last-ModifiedIf-Modified-Since

Last-ModifiedIf-Modified-Since 是 HTTP 1.0 中的内容

  1. 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如:Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
  2. 浏览器将这个值和内容一起记录在缓存数据库中。
  3. 下一次请求相同资源时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段
  4. 服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304,不携带响应体;反之,则表示修改了,响应 200 状态码,并返回数据。

但是有一些缺陷:

因此在 HTTP/1.1 出现了 ETagIf-None-Match

EtagIf-None-Match

为了解决上述问题,出现了一组新的字段 Etag 和 If-None-Match。

Etag 存储的是文件的特殊标识(一般都是一个 Hash 值),服务器存储着文件的 Etag 字段。

之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 Hash,把 If-Modified-Since 变成了 If-None-Match。

浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的 Etag 值放到请求头里的 If-None-Match 里,服务器只需要比较客户端传来的 If-None-Match 跟自己服务器上该资源的 ETag 是否一致,就能很好地判断资源相对客户端而言是否被修改过了。

如果服务器发现 ETag 匹配不上,那么直接以常规 200 回包形式将新的资源(当然也包括了新的 ETag)发给客户端;如果 ETag 是一致的,则直接返回 304 告诉客户端直接使用本地缓存即可。

两者之间的简单对比:

为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两组字段一般都会设置。

浏览器缓存流程总结

当浏览器要请求资源时:

  1. 从 Service Worker 中获取内容(如果设置了 Service Worker)
  2. 查看 Memory Cache
  3. 查看 Disk Cache
  4. 发送网络请求,等待网络响应
  5. 把响应内容存入 Disk Cache(如果 HTTP 响应头信息有相应配置的话)
  6. 把响应内容的引用存入 Memory Cache(无视 HTTP 头信息的配置)
  7. 把响应内容存入 Service Worker 的 Cache Storage(如果设置了 Service Worker)

其中针对第 3 步,具体的流程图如下:

![[2021-12-03-063919.png]]

浏览器行为

在了解了整个缓存策略或者说缓存读取流程后,还需要了解一个东西,那就是用户对浏览器的不同操作,会触发不同的缓存读取策略。

对应主要有 3 种不同的浏览器行为:

  1. 打开网页,地址栏输入地址:查找 Disk Cache 中是否有匹配。如有则使用;如没有则发送网络请求。
  2. 普通刷新(F5):因为 Tab 并没有关闭,因此 Memory Cache 是可用的,会被优先使用(如果匹配的话),其次才是 Disk Cache。
  3. 强制刷新(Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache)。服务器直接返回 200 和最新内容。

缓存的最佳实践

频繁变动的资源

Cache-Control: no-cache

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。

这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

不常变化的资源

Cache-Control: max-age=31536000

通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000(一年),这样浏览器之后请求相同的 URL 会命中强制缓存。

而为了解决更新的问题,就需要在文件名(或者路径)中添加 Hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强缓存失效(其实并未立即失效,只是不再使用了而已)。

在线提供的类库(如 CDN 等)均采用这个模式。