理解浏览器的缓存机制

本文借助其他同学分享的内容完成。

了解缓存

从前端的角度看缓存我们需要了解的是浏览器缓存,首先我们先了解一下浏览器缓存的使用过程。

一般只有GET请求才会被缓存

第一次发起HTTP请求

0006

先查找浏览器缓存没有,再请求服务器并根据缓存规则缓存。

浏览器存在有效缓存

0007

当我们第二次请求同一个资源,如果存在有效浏览器缓存将不请求服务器直接使用。

浏览器存在无效缓存服务器没过期

0008

当我们过了一段时间再次请求该资源浏览器缓存已经过期,这时就需要带着缓存标识请求服务器,如果服务器没变化就返回304就继续使用。

浏览器存在无效缓存服务器过期

0009

当我们再过了一段时间再次请求该资源浏览器缓存已经过期,这时就需要带着缓存标识请求服务器,这时服务器资源有变化就返回200和新的资源。

看到这里感觉浏览器缓存也挺简单的,但是浏览器缓存真的就是仅仅查看一次吗?还有我盗用其他文章图片上有文字提到的强制缓存,还有没提到的协商缓存,这些都是什么东西,我们怎么去配置,对于我们前端同学来说这些应该是一个需要了解的东西。

看过别人的文章有说强制缓存、协商缓存的,也有说按照位置Service Worker、memory cache、disk cache、网络请求,但无论怎样我都按照自己的想法去梳理浏览器缓存。

在我看来强制缓存就是浏览器缓存,协商缓存是在浏览器缓存过期的情况下根据从服务器获取资源状态决定是否使用浏览器缓存,而浏览器缓存又分为Service Worker、memory cache、disk cache;而初次加载保存浏览器缓存依次从 disk cache、memory cache、Service Worker保存,在使用缓存时则是反序依次去查找。

再来看一个上面内容合并在一起的流程图,注意里面的Age

0013

新增浏览器缓存

浏览器缓存是缓存在客户端上,可以根据资源请求返回的缓存规则自动去缓存,不受开发者控制,也不受 HTTP 协议头的约束,算是一个黑盒。也可以通过Service Worker由开发者编写的额外的脚本,且缓存位置独立,出现也较晚,使用还不算太广泛。

浏览器自动缓存

浏览器缓存是HTTP缓存的一部分,还有代理缓存、网关缓存、CDN、反向代理缓存、负载均衡器等部署在服务器上,为站点和 web 应用提供更好的稳定性、性能和扩展性。

我们设置响应头的某些字段来告诉浏览器哪些资源可以缓存,缓存规则是什么。当前可以设置 Expires 或者 Cache-control

当浏览器识别当前资源可以缓存时首先保存在磁盘上作为disk cache,然后再保存在内存中作为memory cache。

Expires

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

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

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

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

  1. 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自行修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。
  2. 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致非法属性从而设置失效。

(完整的列表可以查看 MDN)

Cache-control

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

这两者的区别就是前者是绝对时间,而后者是相对时间。如下:

1
Cache-control: max-age=2592000

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

  • max-age:即最大有效时间,在上面的例子中我们可以看到
  • must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效。
  • no-cache:虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的对比来决定。
  • no-store: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。
  • public:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)
  • private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。

这些值可以混合使用,例如 Cache-control:public, max-age=2592000。在混合使用时,它们的优先级如下图:(图片来自 https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=zh-cn)

0010

这里有一个疑问: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 是一个相对时间,即使客户端时间发生改变,相对时间也不会随之改变,这样可以保持服务器和客户端的时间一致性。而且 Cache-control 的可配置性比较强大。

Cache-control 的优先级高于 Expires,为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段我们都会设置。

disk cache

disk cache 也叫 HTTP cache,顾名思义是存储在硬盘上的缓存,因此它是持久存储的,是实际存在于文件系统中的。而且它允许相同的资源在跨会话,甚至跨站点的情况下使用,例如两个站点都使用了同一张图片。

disk cache 会严格根据 HTTP 头信息中的各类字段来判定哪些资源可以缓存,哪些资源不可以缓存;哪些资源是仍然可用的,哪些资源是过时需要重新请求的。当命中缓存之后,浏览器会从硬盘中读取资源,虽然比起从内存中读取慢了一些,但比起网络请求还是快了不少的。绝大部分的缓存都来自 disk cache。

关于 HTTP 的协议头中的缓存字段,我们会在稍后进行详细讨论。

凡是持久性存储都会面临容量增长的问题,disk cache 也不例外。在浏览器自动清理时,会有神秘的算法去把“最老的”或者“最可能过时的”资源删除,因此是一个一个删除的。不过每个浏览器识别“最老的”和“最可能过时的”资源的算法不尽相同,可能也是它们差异性的体现。

访问一个 disk cache 最后会标注为 from disk cache。

memory cache

memory cache 是内存中的缓存,(与之相对 disk cache 就是硬盘上的缓存)。按照操作系统的常理:先读内存,再读硬盘。

几乎所有的网络请求资源都会被浏览器自动加入到 memory cache 中。但是也正因为数量很大但是浏览器占用的内存不能无限扩大这样两个因素,memory cache 注定只能是个“短期存储”。常规情况下,浏览器的 TAB 关闭后该次浏览的 memory cache 便告失效 (为了给其他 TAB 腾出位置)。而如果极端情况下 (例如一个页面的缓存就占用了超级多的内存),那可能在 TAB 没关闭之前,排在前面的缓存就已经失效了。

memory cache 机制保证了一个页面中如果有两个相同的请求 (例如两个 src 相同的 <img>,两个 href 相同的 <link>)都实际只会被请求最多一次,避免浪费。

在从 memory cache 获取缓存内容时,浏览器会忽视例如 max-age=0, no-cache 等头部配置。例如页面上存在几个相同 src 的图片,即便它们可能被设置为不缓存,但依然会从 memory cache 中读取。这是因为 memory cache 只是短期使用,大部分情况生命周期只有一次浏览而已。而 max-age=0 在语义上普遍被解读为“不要在下次浏览时使用”,所以和 memory cache 并不冲突。

不想让一个资源进入缓存,就连短期也不行,那就需要使用 no-store。存在这个头部配置的话,即便是 memory cache 也不会存储。

访问一个 memory cache 最后会标注为 from memory cache

注意:在firfox中是不区分 memory cache 和 disk cache 的,当浏览器访问已经缓存的资源时直接访问服务器,如果是304状态直接使用该资源。

Service Worker缓存

因为Service Worker是通过脚本执行的,为了用户体验可以浏览器加载完之后执行,也可以为了完美控制等待加载成功之后执行。Service Worker本身是为了webapp的离线存储,但是因为本身的兼容性不是很好,所以在常规缓存里面是不使用Service Worker的。

Service Worker 是将资源离线缓存,并且是永久性缓存,即关闭 TAB 或者浏览器,下次打开依然还在。

访问一个 Service Worker 缓存, 最后会标注为 from ServiceWorker

如果Service Worker缓存过多则会根据神秘规则删掉一部分,再请求资源时如果有memory cache或者disk cache切没过期则将不会请求网络,该部分请求资源规则同下面要讲的部分。但该请求最后还是会标注为 from ServiceWorker(待考证)。

0011
0012

想了解更多Service workder 可以访问讲解Service Worker使用的注册和清理

浏览器缓存未过期

当浏览器判断当前时间在 Expires 或者 Cache-control设置的缓存时间内,则浏览器认为缓存是有效的,将不访问服务器。

存在Service Worker缓存则优先访问Service Worker,Service Worker缓存过期的内容会访问 memory cache、 disk cache、 网络请求,但是最后都会标注为 from ServiceWorker(待考证)。

如果从 memory cache 获取,则最后会标注为 from memory cache。

如果从 disk cache 获取,则最后会标注为 from disk cache。

在浏览器判断过期与否有一个词叫新鲜度,里面提到驱逐算法用于将陈旧的资源(缓存副本)替换为新鲜的。

浏览器缓存过期

当浏览器判断当前时间在 Expires 或者 Cache-control设置的缓存时间之外,则浏览器认为缓存是无效的,将访问服务器,根据服务器返回的响应状态判断是否使用缓存。

当前可通过请求头关键字If-Modified-SinceIf-None-Match 分别将上次请求响应头返回的 Last-ModifiedEtag 内容再次给到服务器进行对比,Etag的优先级比Last-Modified高。

Vary

服务器无变化

服务器返回的状态为304,表示服务器内容没变化,可继续使用该缓存。

服务器有变化

服务器返回的状态为200,表示服务器内容有变化,要使用请求返回的内容重新进行缓存。

Last-Modified & If-Modified-Since

Last-Modified
If-Modified-Since

  1. 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如

    1
    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 状态码,并返回数据。

但是他还是有一定缺陷的:

  • 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
  • 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。

Etag & If-None-Match

Etag
If-None-Match

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

Etag 存储的是文件的特殊标识(一般都是 hash 生成的),服务器存储着文件的 Etag 字段。之后的流程和 Last-Modified一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。

Etag 的优先级高于 Last-Modified
分布式系统里多台机器间文件的 last-modified 必须保持一致,以免负载均衡到不同机器导致比对失败
分布式系统尽量关闭掉Etag(每台机器生成的etag都会不一样)etag off;,如果依然想使用需要自行修改源码。
分布式系统如果是动态内容则要开启 etag ,因为每次请求内容的 last-modified 都不一样

如何配置(nginx)

nginx 配置浏览器缓存策略可以查看官方文档Module ngx_http_headers_module

官方例子

1
2
3
4
5
6
7
8
9
expires    30d;
expires 24h;
expires modified +24h;
expires @24h;
expires 0;
expires -1;
expires epoch;
expires $expires;
add_header Cache-Control private;

设置expires为具体的时间后会在响应头自动添加Cache-Control max-age=3600(秒数),如果是禁用缓存设置expires -1;的同时设置add_header Cache-Control no-cache

设置Cache-Control当为多个配置时可以写在一行,也可以写多行,但是不要冲突哦。Cache-Control值有很多(官方配置)请自行查看。

[Last-Modified & If-Modified-Since] 这两个参数是服务器自动完成的,不需要配置。

当然也可以禁用Last-Modified

etag 只是禁用和启用就可以 etag off; 或者 etag on;

在实际项目中使用缓存

web开发者发明了一种被 Steve Souders 称之为 revving 的技术[1] 。不频繁更新的文件会使用特定的命名方式:在URL后面(通常是文件名后面)会加上版本号。加上版本号后的资源就被视作一个完全新的独立的资源,同时拥有一年甚至更长的缓存过期时长。但是这么做也存在一个弊端,所有引用这个资源的地方都需要更新链接。web开发者们通常会采用自动化构建工具在实际工作中完成这些琐碎的工作。当低频更新的资源(js/css)变动了,只用在高频变动的资源文件(html)里做入口的改动。

这种方法还有一个好处:同时更新两个缓存资源不会造成部分缓存先更新而引起新旧文件内容不一致。对于互相有依赖关系的css和js文件,避免这种不一致性是非常重要的。

一个简单的例子可以查看vue-router history模式nginx配置并配置静态资源缓存

一些案例

具体的案例查看这里,查看其中的“一些案例”

Manifest

Manifest 作为 PWA 离线缓存的一部分,具体使用方法查看下相关文档

Web App Manifest
HTML5 Cache Manifest

Vary与内容协商

说了这么多缓存,我们也应该知道Vary,请移步Vary与内容协商

参考

HTTP 缓存
一文读懂前端缓存
一文读懂前端缓存
彻底理解浏览器的缓存机制
你应该知道的浏览器缓存知识
HTTP 消息头
Service Worker API 服务工作线程
使用 Service Workers
Cache
网站渐进式增强体验(PWA)改造:Service Worker 应用详解
Expires
Cache-Control
Last-Modified
If-Modified-Since
Etag
If-None-Match
Vary