浏览器缓存机制

421 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。详情

一、缓存机制介绍

我们都知道浏览器缓存可以减少网络IO的消耗,提升访问速度,从而达到前端优化的效果。

Chrome 官方给出的解释:通过网络获取内容既速度缓慢又开销巨大。较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。

大部分情况下,我们都将浏览器缓存简单地理解为HTTP缓存。实际上,浏览器的缓存机制有四种:Memory CacheService Worker CacheHTTP CachePush Cache

使用http缓存的好处

  • 减少了冗余的数据传输,减少网费
  • 减少服务器端的压力
  • Web 缓存能够减少延迟与网络阻塞,进而减少显示某个资源所用的时间
  • 加快客户端加载网页的速度

二、缓存位置

上面说到缓存位置分四种:

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

当依次查找缓存都没有时,才会去网络请求。

image-20220214183017922.png

1. Service Worker

  • Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。
  • Service Worker的独立性使得它无法干扰页面性能。我们用它来实现:离线缓存消息推送网络代理等。其中离线缓存就是借助Service Worker实现的,称为Service Worker Cache
  • Server Worker 对协议是有要求的,必须以 https 协议为前提。
  • Service Worker 没有命中缓存的时候,我们需要去调用 fetch 函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。

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

Service Worker 的生命周期包括 installactiveworking 三个阶段。一旦 Service Workerinstall,它将始终存在,只会在 activeworking 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。

Service Worker 如何实现离线缓存

// 入口文件中判断和引入Service Worker
window.navigator.serviceWorker.register('/test.js').then(function(){
    console.log('注册成功')  
}).catch(err => {
    console.error("注册失败")
})

test.js 中,我们进行缓存的处理。假设我们需要缓存的文件分别是 test.html,test.csstest.js

// Service Worker会监听 install事件,我们在其对应的回调里可以实现初始化的逻辑  
self.addEventListener('install', event => {
  event.waitUntil(
    // 考虑到缓存也需要更新,open内传入的参数为缓存的版本号
    caches.open('test-v1').then(cache => {
      return cache.addAll([
        // 此处传入指定的需缓存的文件名
        '/test.html',
        '/test.css',
        '/test.js'
      ])
    })
  )
})
​
// Service Worker会监听所有的网络请求,网络请求的产生触发的是fetch事件,我们可以在其对应的监听函数中实现对请求的拦截,进而判断是否有对应到该请求的缓存,实现从Service Worker中取到缓存的目的
self.addEventListener('fetch', event => {
  event.respondWith(
    // 尝试匹配该请求对应的缓存值
    caches.match(event.request).then(res => {
      // 如果匹配到了,调用Server Worker缓存
      if (res) {
        return res;
      }
      // 如果没匹配到,向服务端发起这个资源请求
      return fetch(event.request).then(response => {
        if (!response || response.status !== 200) {
          return response;
        }
        // 请求成功的话,将请求缓存起来。
        caches.open('test-v1').then(function(cache) {
          cache.put(event.request, response);
        });
        return response.clone();
      });
    })
  );
});

2. 内存缓存Memory Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的,从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

内存缓存具有两个特点,分别是快速读取(读取速度快)和时效性(时效短:进程关闭,内存即清空)

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

实际上存放这些文件的划分规则是没有具体定论的。浏览器秉承的是“节约原则”:base64的图片通常再内存缓存种,这可以视作浏览器为节省渲染开销的“自保行为”。内存资源是有限的,对于体量小的js、css文件也很可能存于内存缓存中,但是对于体积大的文件就要进入硬盘缓存了。

3. 硬盘缓存Disk Cache

存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,优势在于存储容量和存储时长。

在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据HTTP Herder中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。

Disk Cache 和 Memory Cache

两者对比,主要的策略👇

  • 内容使用率高的话,文件优先进入磁盘。
  • 比较大的JS,CSS文件会直接放入磁盘,反之放入内存。
  • 浏览器读取缓存的顺序是memory –> disk。

4. Push Cache

Push Cache推送缓存是指 HTTP2server push 阶段存在的缓存。当以上三种缓存都没有命中时,它才会被访问。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。

  • 所有的资源都能被推送,但是 EdgeSafari 浏览器兼容性不怎么好
  • 可以推送 no-cacheno-store 的资源
  • 一旦连接被关闭,Push Cache 就被释放
  • 多个页面可以使用相同的 HTTP/2 连接,也就是说能使用同样的缓存
  • Push Cache 中的缓存只能被使用一次
  • 浏览器可以拒绝接受已经存在的资源推送
  • 你可以给其他域名推送资源

三、缓存策略

缓存策略有两种,一种为强缓存,一种为协商缓存

  • 对于强缓存,浏览器在第一次请求的时候,会直接下载资源,然后缓存在本地,第二次请求的时候,直接使用缓存。
  • 对于协商缓存,第一次请求缓存且保存缓存标识与时间,重复请求向服务器发送缓存标识和最后缓存时间,服务端进行校验,如果失效则使用缓存

强缓存

1. 强缓存

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

命中强缓存的情况下,返回的 HTTP 状态码为 200 .

2. 强缓存的实现

  1. expires:服务端的响应头,第一次请求的时候,告诉客户端,该资源什么时候会过期。

    在过去一直使用expires,例如:

    expires: Wed, 11 Sep 2019 16:12:18 GMT
    

    expires 是一个时间戳,直接使用一个具体的时间戳是有问题的,它最大的问题在于对“本地时间”的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。

    exprires的缺陷是必须保证服务端时间和客户端时间严格同步。

    在后续的HTTP1.1中新增了cache-control字段代替express的作用,且修复了express的局限性

  2. cache-control:在我们使用强缓存时可以设置这个字段的值,例如:

    cache-control: max-age=31536000
    

    使用cache-control可以设置一个过期时间的时间长度,规避了时间戳的带来的问题。

    当Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准

cache-control

  1. cache-control还可以设置为其他内容:

    • no-cache:不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在ETag,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载
    • no-store:直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源
    • public:可以被所有的用户缓存,包括终端用户和CDN等中间代理服务器。
    • private:只能被终端用户的浏览器缓存,不允许CDN等中继缓存服务器对其缓存。
    • max-age:从当前请求开始,允许获取的响应被重用的最长时间(秒)。
    • must-revalidate,当缓存过期时,需要去服务端校验缓存的有效性。
  2. no-store与no-cache

    • no-cache 绕开了浏览器:设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(即走我们下文即将讲解的协商缓存的路线)。
    • no-store 就是不使用任何缓存策略。在 no-cache 的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。
  3. s-maxage

    cache-control: max-age=3600, s-maxage=31536000
    

    s-maxage 优先级高于 max-age,两者同时出现时,优先考虑 s-maxage。如果 s-maxage 未过期,则向代理服务器请求其缓存内容。

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

  4. public 与 private

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

    • 如果我们为资源设置了 public,那么它既可以被浏览器缓存,也可以被代理服务器缓存;如果我们设置了 private,则该资源只能被浏览器缓存。private默认值。但多数情况下,public 并不需要我们手动设置,比如有很多线上网站的 cache-control 是这样的:

      cache-control: max-age=3600, s-maxage=31536000
      

      设置了 s-maxage,没设置 public,那么 CDN 还可以缓存这个资源吗?答案是肯定的。因为明确的缓存信息(例如“max-age”)已表示响应是可以缓存的。

协商缓存

1. 协商缓存

当第一次请求时服务器返回的响应头中存在以下情况时

  • 没有 Cache-ControlExpires
  • Cache-ControlExpires 过期了
  • Cache-Control 的属性设置为 no-cache

这时候,当第二次浏览器请求时就会与服务器询问缓存的是否旧版本,是否需要更新,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。

这种情况下使用缓存时HTTP的状态码是304

2. 协商缓存的实现

服务端用于判断缓存资源是否旧版本的依据:Last-Modified/If-Modified-SinceETag/If-None-Match

浏览器缓存机制

1. Last-Modified

① 第一次请求的时候服务端返回Last-modified表明请求的资源上次的修改时间,② 第二次请求的时候客户端带上请求头If-Modified-Since,表示资源上次的修改时间,服务端拿到这两个字段进行对比。

// 第一次请求时返回在Response Header中
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
// 第二次请求时带上If-Modified-Since
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

第二次请求时服务器接收到If-Modified-Since的时间戳跟服务器上资源的最后修改时间对比:

① 若两个时间一样,返回304相应,且Response Headers 不会再添加 Last-Modified 字段。

② 若不一样返回一个完整的请求,并把新的修改时间在Response Header中的Last-Modified返回。

Last-Modified存在一些弊端

  1. 例如我们修改文件花了 100ms 完成了改动,由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的,无法精准正确请求。(该重新请求的时候,反而没有重新请求了)
  2. 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应。(不该重新请求的时候,也会重新请求)

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

2. Etag

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

EtagLast-Modified 类似,①当首次请求时,我们会在响应头里获取到一个最初的标识符字符串。②第二次请求时,请求头中会带上这个标识,key值为 if-None-Match

// 第一次请求时返回在Response Header中
ETag: W/"2a3b-1602480f459"
// 第二次请求时带上If-None-Match
If-None-Match: W/"2a3b-1602480f459"

Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。 Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。

选择合适的缓存策略

对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略

  • 对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
  • 对于频繁变动的资源,可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。
  • 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后打包时对文件进行哈希处理,一旦文件名变动就会立刻下载新的文件。(这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。)