深入剖析浏览器缓存策略
前言
在访问一个网页时,客户端会从服务器下载所需的资源。但是有些资源很少发生变动,例如 HTML、JS、CSS、图片、字体文件等。如果每次加载页面都从源服务器下载这些资源,不仅会增加获取资源的时间,也会给服务器带来一定压力。因此,重用已获取的资源十分重要。将请求的资源缓存下来,下次请求同一资源时,直接使用存储的副本,而不会再去源服务器下载。这就是我们常说的缓存技术。
缓存的种类很多:浏览器缓存、网关缓存、CDN 缓存、代理服务器缓存等。这些缓存大致可以归为两类:共享缓存和私有缓存。共享缓存能够被多个用户使用,而私有缓存只能用于单个用户。浏览器缓存只存在于每个单独的客户端,因此它是私有缓存。
本文主要介绍私有(浏览器)缓存。你将学习:
- 浏览器缓存的分类
- 如何启用和禁止缓存
- 缓存存储的位置
- 如何设置缓存的过期时间
- 缓存过期之后会发生什么
- 如何为自己的应用制定合适的缓存策略
调试
- 如何判断网站是否启用了缓存
- 如何禁用浏览器缓存
缓存存储
启用缓存
Cache-Control
浏览器会根据 HTTP Response Headers 中的一些字段来决定是否要缓存该资源。通过设置 Response Headers 中的 Cache-Control
和 Expires
可以启用缓存,这样资源就会被缓存到客户端。
Cache-Control
可以设置 private
、public
、max-age
、 no-cache
来启用缓存。
Cache-Control: private/public Cache-Control: max-age=300 Cache-Control: no-cache
private
:表示该资源只能被浏览器缓存。public
:表示该资源既能被浏览器缓存,也能被任何中间人(比如代理服务器、CDN 等)缓存。max-age
:表示该资源能够被缓存的最大时间。如果设置max-age=0
,该资源仍然会被浏览器缓存,只不过立刻就过期了。no-cache
:该资源会被缓存,但是立刻就过期了,因此需要先和服务器确认资源是否发生变化,只有当资源没有变化时,该缓存才会被使用,否则需要从服务器下载。相当于max-age=0
。
Expires
Expires
标识了缓存的具体过期时间,来控制资源何时过期。通过设置 Expires 可以启用缓存。不过需要注意 Expires 的值是格林威治时间(Greenwich Mean Time, GMT),不是本地时间。
Expires: Fri, 08 Mar 2029 08:05:59 GMT Expires: 0 // Expires: 0 仍然会启用缓存,只不过缓存立刻过期。
优先级
既然 Cache-Control 和 Expires 都能够启用缓存,那么问题来了,如果同时设置 Cache-Control: max-age=600
和 Expires: 0
,那么浏览器应该如何缓存该资源呢?答案是只有 Cache-Control: max-age=600
生效。因为 Cache-Control 的优先级高于 Expires,如果同时设置了 Cache-Control 和 Expires,以 Cache-Control 为准。
浏览器的默认行为
设置 Cache-Control 之后,可以看到浏览器确实启用了缓存(from disk cache)。如下所示:
Cache-Control:max-age=604800, must-revalidate, public
但是我发现,即使 Response Header 中没有设置 Cache-Control 和 Expires,浏览器仍然会缓存某些资源。这是为什么呢?
原来当 Response Header 中有 Last-Modified 但是没有 Cache-Control 和 Expires 时,浏览器会用一套自己的算法来决定这个资源会被缓存多长时间。这是浏览器为了提升性能进行的优化,每个浏览器的行为可能不一致,有些浏览器上甚至没有这样的优化。因此,如果要启用缓存,还是应该自己设置合适的 Cache-Control 和 Expires,不要依赖浏览器自身的缓存算法。当然,如果在调试时发现本应该更新的文件没有更新,也别忘了看看是否被浏览器缓存了。
禁止缓存
给 Cache-Control
设置 no-store
会禁止浏览器和中间人缓存该资源。在处理包含个人隐私数据或银行业务数据的资源时很有用。
Cache-Control: no-store
缓存目标对象
一般来说,浏览器缓存只能存储 GET
响应,例如 HTML、JS、CSS、图片等静态资源。因为这些资源不经常发生变化,所以缓存可以帮助提升获取资源的速度。但是像一些 POST/DELETE 请求,这些请求基本上每一次都不一样,因此也没有什么缓存的价值。
缓存位置
浏览器可以在内存、硬盘中开辟一个空间用以保存请求资源的副本。我们经常在 Dev Tools 里面看到 Memory Cache(内存缓存)和 Disk Cache(硬盘缓存),指的就是缓存所在的位置。请求一个资源时,会按照优先级(Service Worker -> Memory Cache -> Disk Cache -> Push Cache)依次查找缓存,如果命中则使用缓存,否则发起网络请求。这里只介绍常用的 Memory Cache 和 Disk Cache。
200 from Memory Cache
表示不访问服务器,直接从内存中读取缓存。因为缓存的资源保存在内存中,所以读取速度较快,但是关闭进程之后,缓存的资源也会随之销毁。一般来说,系统不会给内存分配较大的容量,因此内存缓存一般用于存储小文件。同时,内存缓存在有时效性要求的场景下也很有用(比如浏览器的隐私模式)。
200 from Disk Cache
表示不访问服务器,直接从硬盘中读取缓存。与内存相比,硬盘的读取速度较慢,但是硬盘缓存持续的时间更长,关闭进程之后,缓存的资源仍然存在。由于硬盘的容量较大,因此一般用于存储大文件。
总的来说就是:
内存缓存:读取快、持续时间短、容量小
硬盘缓存:读取慢、持续时间长、容量大
缓存分类
浏览器缓存一般分为两类:强缓存(也称本地缓存)和协商缓存(也称弱缓存)。判定过程如下:
- 浏览器发送请求前,会先去缓存里面查看是否命中强缓存,如果命中,则直接从缓存中读取资源,不会发送请求到服务器。否则,进入下一步。
- 当强缓存没有命中时,浏览器一定会向服务器发起请求。服务器会根据 Request Header 中的一些字段来判断是否命中协商缓存。如果命中,服务器会返回响应,但是不会携带任何响应实体,只是告诉浏览器可以直接从缓存中获取这个资源。否则,进入下一步。
- 如果前两步都没有命中,则直接从服务器加载资源。
强缓存和协商缓存的共同点在于,如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源。而不同点在于,强缓存不发送请求到服务器,而协商缓存会发送请求到服务器以验证资源是否过期。普通刷新会启用协商缓存,忽略强缓存。只有在地址栏或收藏夹输入网址、通过链接引用资源等情况下,浏览器才会启用强缓存。
缓存过期策略
当缓存过期之后,浏览器会向服务器发起 HTTP 请求,以确定资源是否发生了变化。如果资源未改变,那么浏览器会继续使用本地的缓存资源;如果该资源已经发生变化了,那么浏览器会删除旧的缓存资源,并将新的资源缓存到本地。
过期时间
Http Response Header 里面的 Cache-Control: max-age=xxx
和 Expires
都可以设置缓存的过期时间,但是它们有一些区别:
Expires
:标识该资源过期的时间点,它是一个绝对值,即在这个时间点之后,缓存的资源过期。max-age
:标识该资源能够被缓存的最大的时间。它是一个相对值,相对于第一次请求该文档时服务器记录的「请求发起时间」。
虽然 Cache-Control 是 HTTP 1.1 提出来的新特性,但并不是说 max-age 优于 Expires。它们都有各自的使用场景,我们应该根据业务需求去决定使用哪一个。比如当某个资源需要在特定的时间点过期时应该使用 Expires 。如果只是为了开启缓存,使用 max-age 可能会更好些,因为 Cache-Control 的优先级高于 Expires。
针对应用中几乎不会改变的文件,通常可以设置一个较长的过期时间,以保证缓存的有效。例如图片、CSS、JS 等静态资源。
缓存验证
上一小节已经提到,当浏览器请求一个资源时,如果发现缓存中有该资源,但是已经过期了,那么浏览器就会向服务器发起 HTTP 请求,以验证缓存的资源是否发生变化。
缓存验证时机
什么时候会进行缓存验证?
- 刷新页面。一般来说,为了确保用户获取到最新的数据,在刷新页面时大部分浏览器都不会再使用缓存中的数据,而是发起一个请求去服务器验证。
- Response Header 中设置了 Cache-control: must-revalidate。当缓存的资源过期之后,必须到源服务器去验证,只有确认该资源没有过期,才能继续使用缓存。
缓存验证器
服务器是怎么判断资源改变与否的呢?服务端在返回响应内容的同时,还会在 Response Header 中设置一些验证标识,当缓存的资源过期之后,浏览器就会携带验证标识向服务器发起请求,服务器通过对比这些标识,就能知道缓存的资源是否发生了改变。
Header 中的验证标识字段主要有两组:Etag
和 If-None-Match
、Last-Modified
和 If-Modified-Since
。其中,形如 If-xxx
这样的请求首部字段,可以称之为条件请求。比如只在满足某个条件的情况下返回或上传文件,这样可以节省带宽。
Last-Modified
Last-Modified 就是一个验证器。服务器在将资源返回给客户端的同时,会将资源的最后修改时间 Last-Modified 加在 Response Header 中一起返回。浏览器会为资源标记上该信息,当缓存过期之后,浏览器会把该信息设置到 Request Header 中的 If-Modified-Since
中向服务器发起请求。
如果 If-Modified-Since 中的值和服务器上该资源最终的修改时间一致,就说明该资源没有被修改过,服务器会直接返回 304 状态码,无响应实体,这样就可以节省传输的数据量。如果不一致,服务器会返回 200 状态码,同时和第一次 HTTP 请求一样,返回响应实体和验证器。
Last-Modified:Fri, 04 Jan 2019 14:00:21 GMT
Etag
服务器会通过某种算法,为资源计算出一个唯一标识符,在把响应返回给客户端的时候,会在 Response Header 中加上 Etag: 唯一标识符
一起返回给客户端。
Etag:"952d03d8561454120b550f0a5679a172c4822ce8"
客户端会将 Etag 保存下来,后续请求时会将 Etag 作为 Request Header 中 If-None-Match
的值发给服务器。通过比对客户端发过来的 Etag 和服务器上保存的 Etag 是否一致,就能够知道资源是否发生了变化。如果资源没有发生变化,返回 304,客户端继续使用缓存。如果资源已经修改,则返回 200。
制定缓存策略
缓存真的可以说让我们又爱又恨。在开发时,我们经常遇到这样的问题:明明已经修改了这个文件,为什么没有生效?好吧,文件被缓存了。。。但是在上线时,我们又希望文件尽可能地被浏览器缓存,来提高性能。因此为自己的应用制定合适的缓存策略非常重要。
为静态资源设置较长缓存时间。
有些资源很长时间都不会改变,比如一些三方库,图片,字体文件等。可以为它们设置一个很长的过期时间,例如设定「一年」。
通过给文件名的唯一标识来确保文件修改生效。
有些时候为了解决 bug,我们可能会修改一些文件,比如应用的 CSS、JS 等。如果这些文件已经被缓存,那么除非用户强制刷新页面,否则用户只有在缓存过期之后才有可能获取新的文件。如何让浏览器不使用缓存,而是重新下载新的文件呢?有一个办法就是给文件名加上唯一标识,比如 Hash 或版本信息。当文件修改之后,这个唯一标识也会随之改变。浏览器发现文件改变之后,就不会使用缓存了。
明确是否有资源不能被缓存。
比如一些敏感数据,如果不应该被浏览器缓存,需要在 Response Header 中设置 Cache-Control: no-store。
调试
如何知道请求的资源是否被缓存了?打开 Chrome 的开发者工具,我们可以看到 Size 这一栏下面,如果显示文件真实的大小,则说明该文件未被缓存。如果显示 from xxx cache,则说明该请求使用的是已被缓存的文件。如下:
调试时,如果想禁用浏览器缓存,可以在开发者工具上勾选 Disabel cache。
最后
最后,大家可以通过下面这张图再回顾一下我们刚刚讲过的内容。