前言
在任何一个前端项目中,访问服务器获取数据都是很常见的事情,但是如果相同的数据被重复请求,那么多余的请求次数必然会浪费网络带宽,以及延迟浏览器渲染所要处理的内容,从而影响用户的使用体验。如果用户使用的是按量计费的方式访问网络,那么多余的请求还会隐性地增加用户的网络流量资费。因此考虑使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。
缓存的原理
在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类有很多,比如代理缓存
、浏览器缓存
、网关缓存
、负载均衡器
及内容分发网络
等,它们大致可以分为两类:
共享缓存
– 缓存内容可被多个用户使用,如公司内部的Web代理。私有缓存
– 只能单独被用户使用的缓存,如浏览器缓存。
HTTP 缓存 算是前端开发中最常接触的缓存机制之一,它又可细分为 强制缓存 与 协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器端进行询问以协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求。 下面就来具体看HTTP缓存的具体机制及缓存的决策策略。
强制缓存
对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中(还没有过期),则可直接从强制缓存中返回请求响应,无须与服务器进行任何通信。
简单地说就是,不会向服务器发送请求,直接从缓存中读取资源。
强制缓存原理
强制缓存就是向浏览器缓存查找该请求结果,并根据该结果的缓存规则来决定是否使用该缓存结果的过程,强制缓存的情况主要有三种,如下:
-
第一种情况: 第一次请求,不存在缓存结果和缓存标识,直接向服务器发送请求。
-
第二种情况: 存在缓存标识和缓存结果,但是已经失效过期,强制缓存失效,则使用协商缓存(后面会介绍)
-
第三种情况: 存在该缓存结果和缓存标识,且该结果尚未失效,强制缓存生效,直接返回该结果。
强制缓存的缓存规则
当浏览器向服务器发起请求时,服务器会将缓存规则放入HTTP响应报文的HTTP头中和请求结果一起返回给浏览器,控制强制缓存的字段分别是Expires
和Cache-Control
,其中Cache-Control
优先级比Expires
高。
在介绍 强制缓存 判断是否命中之前,我们首先来看一段响应头的部分信息:
access-control-allow-origin: *
age: 734978
content-length: 40830
content-type: image/jpeg
cache-control: max-age=31536000
expires: Web, 14 Fed 2021 12:23:42 GMT
复制代码
其中和强制缓存相关的两个字段是 expires
和 cache-control
。
Expires
expires
是在HTTP1.0协议
中声明的用来控制缓存失效日期时间戳的字段。它在服务器端设置后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。- 有效值:GMT 格式
Expires = max-age + 请求时间
若之后浏览器再次发起相同的资源请求,便会对比 Expires
与本地当前的时间戳,如果当前请求的本地时间戳小于 Expires
的值,则说明浏览器缓存的响应还未过期,可以直接使用而无须向服务器端再次发起请求。只有当本地时间戳大于 Expires
值时,才允许重新向服务器发起请求。
resp.setHeader("Expires", new Date("2021-08-13 22:50:24").toUTCString());
复制代码
Expires 的缺陷:
上述的 强制缓存是否过期的判断机制 存在一个很大的漏洞,即对本地时间戳过分依赖,如果客户端本地的时间与服务器端的时间不同步,或者对客户端时间进行主动修改,那么对于缓存过期的判断可能就无法和预期相符。
Cache-control
为了解决 Expires
判断的局限性,从 HTTP1.1协议
开始新增了 Cache-control
字段来对 Expires
的功能进行扩展和完善。
max-age
从上述代码中可见 Cache-control
设置了 max-age=31536000
的属性值来控制响应资源的有效期,它是一个以 秒
为单位的时间长度,表示该资源在被请求后的 31536000秒
内有效。如此便可避免服务器端和客户端时间戳不同步而造成的问题。除此之外,Cache-control
还可配置一些其他属性值来更准确地控制缓存,下面来具体介绍:
no-cache 和 no-store
设置 no-cache
并非像字面上的意思不使用缓存,其表示为强制进行 协商缓存(后面会说),即对于发起的请求不再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。
设置 no-store
则表示禁止使用任何缓存策略,客户端的每次请求都需要服务端给予全新的响应结果。
no-cache
和 no-store
是两个互斥的属性值,不能同时设置。
private 和 public
private
和 public
也是 Cache-control
的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。
private
– 表示响应资源既可以被浏览器缓存,又可以被代理服务器缓存。public
– Cache-control的默认取值。表示响应资源只能被浏览器缓存。
对于应用程序中不会改变的文件,通常可以在发送响应头前添加积极缓存(public
)。例如应用程序中的静态文件,例如图像、CSS文件和JS文件。
Cache-control: public, max-age=600
复制代码
max-age 和 s-maxage
max-age
属性值比 s-maxage
更常用,它(max-age)表示服务器端告知客户端浏览器响应资源的过期时长,在一般项目的使用场景中基本够用。 但对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是 s-maxage
存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了 public
属性值才有效。
总结
由此可见 Cache-control
能作为 expires
的完全替代方案,并且拥有其所不具备的一些缓存控制特性,在项目实践中使用它就足够了。而目前 expires
还存在的唯一理由是考虑到可用性方面的向下兼容。
协商缓存
协商缓存就是在使用本地缓存之前,需要向服务器端发起一次 GET 请求,与之协商当前浏览器保存的本地缓存是否已经过期。
Last-Modified
通常是采用所请求资源最近一次的修改时间戳(获取文件的 mtime
时间)来判断的,为了便于理解,下面来看一个例子:假设客户端浏览器需要向服务器请求一个 main.js
的 JS 文件资源,为了让该资源被再次请求时能通过协商缓存的机制使用本地缓存,那么首次返回该图片资源的响应头中应包含一个 Last-Modified
字段,该字段属性值为该 JS 文件资源最近一次修改的时间戳。简略截取 请求头 与 响应头 的关键信息:
// 第一次请求的请求头
Request URL: http://localhost:3000/image.jpg
Request Method: GET
// 第一次请求的响应头
last-modified: Thu, 29 Apr 2021 03:09:28 GMT
cache-control: no-cache
复制代码
当我们刷新网页时,由于该 JS 文件使用的是协商缓存(no-cache),客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次 GET
请求,进行缓存有效性的协商,此次 GET
请求的请求头中需要包含一个 If-Modified-Since
字段,其值就是上次响应头中 Last-Modified
的字段值。
当服务器收到该请求后便会对比请求资源当前的修改时间戳与 If-Modified-Since
字段的值,如果二者相同则说明缓存未过期,告知浏览器可继续使用本地缓存,否则服务器重新返回全新的文件资源,简略截取 请求头 与 响应头 的关键信息:
// 再次请求的请求头
Request URL: http://localhost:3000/image.jpg
Request Method: GET
If-Modified-Since: Thu, 29 Apr 2021 03:09:28 GMT
// 协商缓存有效的响应头
Status Code: 304 Not Modified
复制代码
*!注意:*协商缓存 判断缓存是否有效的响应状态码是 304
,即缓存还有效就重定向到本地缓存上。这和强制缓存有所不同,强制缓存是若有效,则再次请求的响应状态码是 200
。
Last-Modified 的缺陷
通过 Last-Modified
所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:
- 首先它只是根据资源最后的修改时间戳进行判断的,虽然请求的文件资源进行了编辑,但内容并没有发生任何变化(如修改文件名称,随后有修改回来 【a.jpg -> b.jpg -> a.jpg】),时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整的资源请求。这无疑会造成网络带宽资源的浪费,以及延长用户获取到目标资源的时间。
- 其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,是无法识别出该次文件资源的更新的。(如1秒内多次修改是无法捕捉到的)
其实造成上述两种缺陷的原因相同,就是服务器无法仅依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存的 Bug 场景。
ETag
为了弥补通过时间戳判断的不足,从 HTTP 1.1协议
开始新增了一个 ETag
的头信息,即实体标签(Entity Tag)。
其内容主要是服务器根据对不同资源进行哈希运算所生成的字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的 ETag
值就会不同。因此可以使用 ETag
对文件资源进行更精确的变化感知。
下面使用 ETag
进行协商缓存图片资源为例,首次请求后的部分响应头关键信息:
Content-Type: image/jpeg
ETag: "xxx"
Last-Modified: Fri, 12 Jul 2021 18:30:00 GMT
Content-Length: 9887
复制代码
上面响应头中同时包含了 last-modified
文件修改时间戳和 ETag
实体标签两种协商缓存的有效性校验字段,因为 ETag
比 Last-Modified
具有更准确的文件资源变化感知,所以它的优先级也更高,二者同时存在时会以 ETag
为准。
再次对该图片资源发起请求时,就会将之前首次请求的响应头的 ETag 的字段值作为此次请求头中 If-None-Match 字段,提供给服务器进行缓存有效性验证。
请求头的关键字段信息:
// 再次请求的请求头:
If-Modified-Since: Fri, 12 Jul 2021 18:30:00 GMT
If-None-Match: "xxx"
复制代码
若验证缓存有效,则返回 304
状态码响应重定向到本地缓存。
ETag 的缺陷:
不像强制缓存中的 cache-control
可以完全替代 expires
的功能,在协商缓存中,ETag
并非 Last-Modified
的替代方案而是一种补充方案,因为它依旧存在一些弊端。
-
一方面服务器对于生成文件资源的
ETag
需要付出额外的计算开销,如果资源的尺寸较大,数量较多且修改比较频繁,那么生成ETag
的过程就会很影响服务器的性能。 -
另一方面
ETag
字段值的生成还分为 强验证 和 弱验证:- 强验证:根据资源内容进行生成,能够保证每个字节都相同;
- 弱验证:根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率。