vlambda博客
学习文章列表

web性能优化(五):浏览器缓存策略

初识浏览器缓存

当我们的浏览器/手机/电脑遇到问题的时候,我们会主动/被动地先去试试清理一下缓存。似乎“清理缓存”是一个万能的解决问题的方法。很像我们电脑遇到问题时候的“重启一下试试”,对吧?

这么做,有的时候确实解决了问题,有的时候却并没有。而神奇的是,不管事情是否得到解决,我们似乎都并不清楚“缓存”这个概念里面具体装的是啥东西。

今天,我们就来尝试了解浏览器的缓存。

为什么需要浏览器缓存

在具体展开各个不同的缓存机制之前,我们来聊聊为什么需要浏览器缓存?

浏览器的设计者认为:互联网不够快!从缓存、硬盘中获取资源会比从互联网上获取资源要快得多。 

浏览器的设计者观察到:大部分的网站中,重复相同的资源会出现在多个页面上。自然地,就会去想:为什么浏览器需要为多个页面都重复地(从互联网)下载相同的资源呢?为什么不只下载一次,然后不同网页上的相同资源,都使用这一份下载好的资源,那么就可以省略许多的下载了呀?毕竟,从缓存、硬盘中获取资源会比从互联网上获取资源要快得多。如果我们这么做,浏览器使用者的体验也会好得多。

浏览器缓存汇总

浏览器缓存这个概念,在不同人眼里会有不同的理解。

有的时候会被放大范围:认为 HTTP 缓存、Memory Cache、cookie、webStorage以及IndexedDB存储的数据都称之为缓存。理由是它们都是保存在客户端的数据,没有什么区别。

也有的时候,会被过度缩小范围:有人倾向于将浏览器缓存简单地理解为“HTTP 缓存”。

事实上,浏览器缓存机制有四个方面,它们按照获取资源时请求的优先级依次排列为:

  1. Memory Cache

  2. Service Worker Cache

  3. HTTP Cache

  4. Push Cache

大家对 HTTP Cache(即 cache-control、expires 等字段控制的缓存)应该比较熟悉,如果对其它几种缓存可能还没什么概念。我们看一张开发者工具的截图:

web性能优化(五):浏览器缓存策略

然后放大看看 size 这一栏:

web性能优化(五):浏览器缓存策略

这里面出现的形如“(from xxx)”这样的描述,其对应的资源就是我们通过缓存获取到的。其中,“from memory cache”对标到 Memory Cache 类型,“from ServiceWorker”对标到 Service Worker Cache 类型。至于 Push Cache,这个比较特殊,是 HTTP2 的新特性。

本文主要对两种类型的缓存进行讲解:Memory Cache、HTTP Cache。其他两种不太常用,于是忽略之。

考虑到 HTTP 缓存是最主要、最具有代表性的缓存策略,下面优先针对 HTTP 缓存机制进行剖析。

HTTP 缓存机制探秘

HTTP 缓存是我们日常开发中最为熟悉的一种缓存机制。主要通过 HTTP 中的 Header 字段发挥作用。它既可在请求 Header 中使用,也可在响应 Header 中使用。一般而言,HTTP 缓存机制是指服务器与浏览器之间,就响应 Header 中的不同字段,做出的不同缓存决策。

更详细地,HTTP 缓存又分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。

强缓存

强缓存是利用 HTTP Header 中的 expires 和 cache-control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。

强缓存的实现:从 expires 到 cache-control

实现强缓存,HTTP 1.1 以前一直用 expires。当服务器返回响应时,在 Response Headers 中将过期时间写入 expires 字段。如:

web性能优化(五):浏览器缓存策略

图中的expires字段:Mon, 10 Feb 2020 03:00:35 GMT

可知,expires 是一个时间戳。接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。

从这样的描述中我们不难猜测,expires 是有问题的,它最大的问题在于对“本地时间”的依赖。如果服务端和客户端的时间设置不同,或者直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。

考虑到 expires 的局限性,HTTP1.1 新增了 cache-control 字段来完成 expires 的任务。expires 能做的事情,cache-control 都能做;expires 完成不了的事情,cache-control 也能做。因此,cache-control 可以视作是 expires 的完全替代方案。

现在我们给 cache-control 字段一个特写:Cache-Control: max-age=600。此处,cache-control 通过 max-age=600 来控制资源的有效期。其中,600的单位是“秒”。这意味着,该资源在 600 秒内都是有效的,完美规避了 expires 字段规则中“本地时间”带来的问题。

cache-control 分析

作为 expires 的完全替代方案,cache-control 的功能比你想象中的还要强大。

为了更好分析 cache-control ,我们先来了解一下几个全新的概念:

max-age 与 s-maxage

s-maxage 不像 max-age 一样为大家所熟知。它只对代理服务器起作用,而客户端用不上。那是否说明 s-maxage 不重要了呢?并不是的。

在项目不是特别大的场景下,max-age 足够用了。但在依赖各种代理的大型架构中,我们不得不考虑代理服务器的缓存问题。s-maxage 就是用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。

关于 public 的知识点,我们接着看。

public 与 private

public 与 private 是针对资源是否能够被代理服务器缓存而存在的一组对立概念。

如果我们为资源设置了 public,那么它既可以被浏览器缓存,也可以被代理服务器缓存;如果我们设置了 private,则该资源只能被浏览器缓存。

既然有不被代理服务器缓存的设置,那么是否可以设置资源不被浏览器缓存呢?矮~ 还真有。请看下面这组概念。

no-store 与 no-cache

no-cache 绕开了浏览器:我们为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(即走我们下文即将讲解的协商缓存的路线)。

no-store 更绝情:顾名思义就是不使用任何缓存策略。在 no-cache 的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。

web性能优化(五):浏览器缓存策略

协商缓存

协商缓存是浏览器与服务器合作之下的缓存策略。它依赖于服务端与浏览器之间的通信。

从上文了解到,只有在强缓存失效的时候,才会走协商缓存。此时,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。

如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304(如下图)。

web性能优化(五):浏览器缓存策略

协商缓存:从 Last-Modified 到 Etag

Last-Modified 是一个时间戳。如果我们启用了协商缓存,它会在首次请求时伴随 Response Headers 一起返回:

last-modified: Mon, 06 Jan 2020 08:00:19 GMT

随后我们每次请求同一资源时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:

If-Modified-Since: Mon, 06 Jan 2020 08:00:19 GMT

服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。

如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,Response Headers 不会再添加 Last-Modified 字段。

使用 Last-Modified 存在一些弊端,这其中最常见的就是这样两个场景:

  • 我们编辑了文件,但文件的内容没有改变(如:编辑文件增加字符‘a’,然后删除字符‘a’, 点击保存)。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。

  • 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。

这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了。

Etag 是由服务器为每个资源生成的唯一的标识字符串。这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。

Etag 和 Last-Modified 类似,有一个相似的判断过程。在此不敷述。

Etag 并不是没有缺点的:

  • 它的生成过程需要服务器额外付出开销,会影响服务端的性能。

  • 针对分布式后台,每个服务器对同一个资源生成的唯一标识字符串可能不一样。此时,需要更多的处理逻辑才能保证 Etag 的正确运行。

因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。 Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。

HTTP 缓存决策建议

至此,对于 HTTP 缓存的知识点,我们已经清楚地整理了一遍。那么,面对真实的缓存需求时,我们该如何做呢?是否会有什么建议呢?

让我们来看看 Chrome 官方给出的一个权威指南:

我们现在一起解读一下这张流程图:

当我们的资源内容不可复用时,直接为 Cache-Control 设置 no-store,拒绝一切形式的缓存;否则考虑是否每次都需要向服务器进行缓存有效确认,如果需要,那么设 Cache-Control 的值为 no-cache;否则考虑该资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;然后考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;最后,配置协商缓存需要用到的 Etag、Last-Modified 等参数。

希望这个图(权威指南),对我们的具体工作有一定的帮助。

MemoryCache介绍

MemoryCache,是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来讲,它是响应速度最快的一种缓存。

内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束(tab 关闭)后,内存里的数据也将不复存在。

那么哪些文件会被放入内存呢?

事实上,这个划分规则,一直以来都是没有定论的。不过想想也可以理解,内存是有限的,很多时候需要先考虑即时呈现的内存余量,再根据具体的情况决定分配给内存和磁盘的资源量的比重——资源存放的位置具有一定的随机性。

因此,在我们开发过程中如果发现同一个资源一会儿是“from disk cache”,一会儿是“from memory cache”。也请不要感到意外。这是因为上面的随机性所导致的。

小结

在本篇文章中,我们聊到了浏览器缓存的必要性,也讲述了两个常用的浏览器缓存:Http Cache 与 Memory Cache。希望有所收获!