# 四.浏览器缓存机制

前言

缓存可以减少网络 IO 消耗,提高访问速度。浏览器缓存是一种操作简单、效果显著的前端性能优化手段。对于这个操作的必要性,Chrome 官方给出额解释似乎更有说服力一些:

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

# 1.缓存位置

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

  • 1.Service Worker
  • 2.Memory Cache
  • 3.Disk Cache
  • 4.Push Cache
  • 5.网络请求

# Service Worker

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

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

Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个“幕后工作者”可以帮我们实现离线缓存、消息推送和网络代理等功能。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。

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

下面我们就通过实战的方式,一起见识一下 Service Worker 如何为我们实现离线缓存(注意看注释): 我们首先在入口文件中插入这样一段 JS 代码,用以判断和引入 Service Worker:

window.navigator.serviceWorker
  .register("/test.js")
  .then(function () {
    console.log("注册成功")
  })
  .catch((err) => {
    console.error("注册失败")
  })
1
2
3
4
5
6
7
8

在 test.js 中,我们进行缓存的处理。假设我们需要缓存的文件分别是 test.html,test.css 和 test.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()
      })
    })
  )
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

注意

Server Worker 对协议是有要求的,必须以 https 协议为前提。

# Memory Cache

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

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

既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?

先说结论,这是不可能的。首先计算机中的内存一定比硬盘容量小得多,操作系统门需要精打细算内存的使用,所以能让我们的内存必然不多。内存中其实可以存储大部分的文件,比如说 JS、HTML、CSS、图片等等。但是浏览器会把哪些文件丢进内存这个过程就很玄学了,我查阅了很多资料都没有一个定论。

当然,我通过一些实践和猜测也得出了一些结论:

  • 对于大文件来说,大概率是不存储在内存中的,反义优先
  • 当前系统内存使用率高的话,文件优先存储进硬盘

# Disk Cache

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

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

# Push Cache

Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(session)中存在,一旦会话结束就会被释放。

Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及,但是 HTTP/2 将会是日后一个趋势。

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

# 2.缓存策略

通常浏览器缓存策略分为两种:强缓存协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的

强缓存

强缓存可以通过设置两种 HTTP Header 实现:ExpiresCache-Control。强缓存表示在缓存期间不需要请求,state code为 200

Expires

Expires是 HTTP/1 的产物,表示资源会在Web,22 Oct 2018 08:41:00 GMT后过期,需要再次请求。并且Expires受限于本地时间,如果修改了本地时间,可能会造成缓存失效。

Cache-control Cache-Control出现于 HTTP/1.1,优先级高于Expires。该属性值表示资源会在 30 秒后过期,需要再次请求。

Cache-Control可以在请求头或者响应头中设置,并且可以组合使用多种指令。

指令 作用
public 表示响应可以被客户端和代理服务器缓存
private 表示响应只可以被客户端缓存
max-age=30 缓存 30 秒后就过期,需要重新请求
s-maxage=30 覆盖 max-age,作用一样,只在代理服务器中生效
no-store 不缓存任何响应
no-cache 资源被缓存,但是立即失效,下次会发起请求验证资源是否过期
max-state=30 30 秒内,即使缓存过期,也使用缓存
min-fresh=30 希望在 30 秒内获取最新的响应

s-maxage 优先级高于 max-age,两者同时出现时,优先考虑 s-maxage。如果 s-maxage 未过期,则向代理服务器请求其缓存内容。 这个 s-maxage 不像 max-age 一样为大家所熟知。的确,在项目不是特别大的场景下,max-age 足够用了。但在依赖各种代理的大型架构中,我们不得不考虑代理服务器的缓存问题。s-maxage 就是用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。

(10.24 晚更新。感谢评论区@敖天羽的补充,此处应注意这样一个细节:s-maxage 仅在代理服务器中生效,客户端中我们只考虑 max-age。)

那么什么是 public 缓存呢?说到这里,Cache-Control 中有一些适合放在一起理解的知识点,我们集中梳理一下:

# public 与 private

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

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

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

# no-store 与 no-cache

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

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

# 协商缓存

如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-ModifiedEtag

当浏览器发起请求验证资源时,如果资源没有改变,那么服务端就会返回 304 状态码,并且更新浏览器缓存有效期。

# Last-Modified 和 If-Modified-Since

Last-Modified表示本地文件最后修改日期,If-Modified-Since会将Last-Modified的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话会将新的资源发送回来,否则返回 304 状态码。

但是Last-Modified存在一些弊端

  • 如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成Last-Modified被修改,服务端不能命中缓存导致发送相同的资源
  • 因为Last-Modified只能以秒计时,如果在不可感知的时间内修改完成文件,那么服务端会认为资源还是命中了,不会返回正确的资源。

# ETag 和 If-None-Match

ETag类似于文件指纹,If-None-Match会将当前ETag发送给服务器,询问该资源ETag是否变动,有变动的话就将新的资源发送回来。并且ETag优先级比Last-Modified

以上就是缓存策略的所有内容了,看到这里,不知道你是否会有这样一个疑问。如果什么缓存策略都没设置,那么浏览器会怎么处理?

对于这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的Date减去Last-Modified值的 10%作为缓存时间。

# 3.实际场景应用缓存策略

# 频繁变动的资源

对于频繁变动的资源,首先需要使用Cache-Control:no-cache使浏览器每次都请求服务器,然后配合ETag或者Last-Modified来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

# 代码文件

这里特这除了 HTML 外的代码文件,因为 HTML 文件一般不换存或者缓存时间很短。

一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年Control:max-age=31536000,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码,否则就一直使用缓存。