前言

什么是缓存?

缓存是一种保存资源副本并在下次请求时直接使用该副本的技术。

缓存,是提高前端性能最简单、最直接的手段。缓存的好处不言而喻,它能够减少请求,更多地使用本地的资源,给用户更好的体验的同时,也减轻服务器压力。所以了解缓存、用好缓存,是前端进阶之路绕不开的一步。

开始之前先思考一个问题:为什么我们通常讲的缓存,都是HTTP缓存?

我的答案是,因为HTTP协议是应用层协议。用户的数据以什么样的方式被缓存,缓存以什么样的方式被删除,是由开发者根据应用的需求设计的。而应用层正是与应用直接对接的网络协议层,理所应当地,缓存机制也应该由HTTP协议实现。

下面我们来看HTTP协议中和缓存相关的部分。

HTTP缓存

HTTP/1.1协议中与缓存相关的header有12种。

1. Pragma

Tag: HTTP/1.0

语法:

'Pragma': 'no-cache'

Cache-Control: no-cache 效果一致。强制要求缓存服务器在返回缓存的版本之前将请求提交到源头服务器进行验证。

由于 Pragma 在 HTTP 响应中的行为没有确切规范,所以不能可靠替代 HTTP/1.1 中通用首部 Cache-Control,尽管在请求中,假如 Cache-Control 不存在的话,它的行为与 Cache-Control: no-cache 一致。建议只在需要兼容 HTTP/1.0 客户端的场合下应用 Pragma 首部。

2. Expires

Tag: HTTP/1.0

语法:

Expires: Wed, 21 Oct 2015 07:28:00 GMT

Expires 响应头包含日期/时间, 即在此时候之后,响应过期。

如果设置了Expires之后,客户端在需要请求数据的时候,首先会对比当前系统时间和这个Expires时间,如果没有超过Expires时间,则直接读取本地磁盘中的缓存数据,不发送请求。

如果在Cache-Control响应头设置了 “max-age” 或者 “s-max-age” 指令,那么 Expires 头会被忽略。

这个方案的缺陷很明显,判断缓存是否过期依赖的是系统时间,如果系统时间设置错误,那么缓存机制可能也会随之出错。

3. Cache-Control

Tag: 最常用,强缓存机制

Cache-Control在请求头中的可选值:

指令参数说明类型
no-cache强制源服务器再次验证可缓存性
no-store不缓存请求或是响应的任何内容可缓存性
max-age=[秒]缓存时长,单位是秒缓存的时长,也是响应的最大的Age值到期
min-fresh=[秒]必需期望在指定时间内响应仍然有效到期
no-transform代理不可更改媒体类型其他
only-if-cached从缓存获取其他
max-age=[秒]缓存时长,单位是秒表明客户端愿意接收一个已经过期的资源。参数表示响应不能已经过时超过该给定的时间。到期

Cache-Control在响应头中的可选值:

指令参数说明类型
public任意一方都能缓存该资源(客户端、代理服务器等)可缓存性
private可省略只能特定用户缓存该资源可缓存性
no-cache可省略缓存前必须先确认其有效性可缓存性
no-store不缓存请求或响应的任何内容可缓存性
no-transform代理不可更改媒体类型其他
must-revalidate可缓存但必须再向源服务器进确认重新验证和重新加载
proxy-revalidate要求中间缓存服务器对缓存的响应有效性再进行确认重新验证和重新加载
max-age=[秒]缓存时长,单位是秒缓存的时长,也是响应的最大的Age值到期
s-maxage=[秒]必需公共缓存服务器响应的最大Age值到期

4. Modified机制

Tag: 协商缓存机制、低精度

这里包含了三种header,分别是Last-ModifiedIf-Modified-SinceIf-Unmodified-Since

Modified机制的精确度比ETag要低,因此它是一个备用机制。

语法:

Last-Modified : Fri , 12 May 2006 18:53:33 GMT
If-Modified-Since : Fri , 12 May 2006 18:53:33 GMT
If-Unmodified-Since : Fri , 12 May 2006 18:53:33 GMT

在协商缓存的情况下:

浏览器第一次发起请求时,服务端返回头中附带Last-Modified字段,该标记会被浏览器储存起来。

浏览器发起第二次请求时,会使用If-Modified-Since这个header,并附带上一次请求服务端Last-Modified中附带的时间,由服务端判断是否使用缓存:如果资源已经修改,返回200;如果没有修改,返回304,告知浏览器可以采用本地缓存。不同于 If-Unmodified-Since, If-Modified-Since 只可以用在 GETHEAD 请求中。当与 If-None-Match 一同出现时,它(If-Modified-Since)会被忽略掉,除非服务器不支持 If-None-Match

如果浏览器发起第二次请求时,使用的是If-Unmodified-Since,当资源没有被修改时,才会返回200并返回资源,否则会返回412 Precondition Failed。

If-Unmodified-Since使用场景主要有:与non-safe方法搭配使用,用于优化并发控制,如文档编辑器的提交需要确认编辑文档过程中原文本没有被修改(如被修改会返回412错误);与If-Range消息头搭配使用,用来确保新的请求片段来自于未经修改的文档。

Modified机制有一个问题,如果文件在1秒的时间内多次发生变化,由于时间戳的精度仅为1秒,因此无法准确地校验缓存是否可用。

5. ETag机制

Tag: 协商缓存、高精度

这里包含三种header,反别是ETagIf-None-MatchIf-Match

语法:

ETag: abc-123456
If-None-Match: abc-123456
If-Match: abc-123456

这里的ETag时服务器自定的算法,对资源生成一个唯一的标识,在第一次请求时通过ETag头来返回给浏览器。

浏览器在第二次请求时,会通过If-None-MatchIf-Match将之前储存的ETag返回给服务端,服务端处理机制类似Modified机制:

对于GET和HEAD方法,Etag校验成功(缓存有效)时,服务端应该返回304 Not Modified,否则应该返回200;

对于non-safe的方法如POST、DELETE等,服务端应该返回412 Precondition Failed。

6. 启发式缓存

如果ExpiresCache-Control: max-age,或 Cache-Control:s-maxage都没有在响应头中出现,并且设置了Last-Modified时,那么浏览器默认会采用一个启发式的算法,即启发式缓存。通常会取响应头的Date_value - Last-Modified_value值的10%作为缓存时间。

缓存的层级

缓存的层级,按照缓存所处的位置划分,从上到下可以分为以下四种:

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

Memory Cache

顾名思义,就是内存中的缓存。按照操作系统的逻辑理解,它是短期、非持久的缓存。基本上所有网络请求的资源都会进入Memory Cache中。有效期一般为本次Session(关闭页面后失效)。极端情况下,如果一个页面占用的缓存非常多,可能页面没关闭之前,排在前面的老的缓存已经失效了。

这个机制保证了一个页面中有两个相同的请求,实际上都只会被请求最多一次,避免浪费。

在从Memory Cache获取缓存时,浏览器可能会忽视 max-age=0, no-cache 等头部配置。这是因为 memory cache 只是短期使用,大部分情况生命周期只有一次浏览而已。而 max-age=0 在语义上普遍被解读为“不要在下次浏览时使用”,所以和 memory cache 并不冲突。如果要禁止资源进入Memory Cache,则需要使用no-store

Disk Cache

顾名思义,就是存在硬盘中的缓存。因此它是持久储存的。Disk Cache机制会严格按照HTTP头部中的信息来判断资源是否需要缓存、缓存的资源是否可用。Disk Cache的访问速度略慢于Memory Cache。

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

Service Worker

上述的缓存策略,都是浏览器内部自行判断和操作的,我们只能通过设置响应头来告诉浏览器应该如何操作缓存。Service Worker可以让我们更灵活地去操作缓存,自己选择应该缓存哪些文件、路由匹配规则、缓存匹配并返回等。而且这个缓存是永久的,除非手动调用cache.delete(resource),或容量超过限制被浏览器清空。

利用缓存做前端性能优化

缓存的意义就在于减少请求,更多地使用本地的资源,给用户更好的体验的同时,也减轻服务器压力。所以,最佳实践,就应该是尽可能命中强缓存,同时,能在更新版本的时候让客户端的缓存失效。

对于前端页面的资源而言,我们可以划分为两类:

  1. 经常变化的资源,如HTML文件。
  2. 并非经常变化的资源,如JS文件和CSS文件。

对于经常变化的资源,我们希望它尽可能地能够及时更新,因此应该尽可能地使用协商缓存,做新鲜度校验;

而对于非经常变化的资源,我们希望它的更新尽可能的lazy。因此应该尽可能地使用强缓存。同时,为了避免缓存下的旧资源被更新后的新资源引用的问题,我们应该给强缓存内容添加指纹,确保资源版本能够对应。

因此,比较合理的方案是:

  • HTML:使用协商缓存。
  • CSS&JS&图片:使用强缓存,文件命名带上hash值。

通过Webpack,我们可以对打包后的文件名添加内容hash。

module.exports = {
  //...
  output: {
    //...
    chunkFilename: '[chunkhash].js',
  },
};

除此之外,我们还可以利用webpack做code-splitting。

如果将项目内所有JS代码打包成同一个文件并使用强缓存,那么任何一行代码的修改,都会使得文件的hash值发生变化,使得缓存失效。

对于那些不常发生变化的代码(如框架、外部库等),以及互相关联不大的代码(不同页面的逻辑代码),我们都可以做code-splitting,将他们单独打成一个chunk,单独做缓存。这样,修改A页面的代码,不会影响B页面的缓存,也不会影响公共库代码的缓存。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

参考资料

网站的缓存控制策略最佳实践及注意事项 - 山月行

缓存详解 - Damonare

一文读懂前端缓存 - 小蘑菇小哥

HTTP 缓存 - MDN