前言
在任何一个前端项目中,访问服务器获取数据都是很常见的事情,但是如果相同的数据被重复请求,那么多余的请求次数必然会浪费网络带宽,以及延迟浏览器渲染所要处理的内容,从而影响用户的使用体验。如果用户使用的是按量计费的方式访问网络,那么多余的请求还会隐性地增加用户的网络流量资费。因此考虑使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。
缓存的原理
在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类有很多,比如代理缓存、浏览器缓存、网关缓存、负载均衡器及内容分发网络等,它们大致可以分为两类:
共享缓存- 缓存内容可被多个用户使用,如公司内部的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字段值的生成还分为 强验证 和 弱验证:- 强验证:根据资源内容进行生成,能够保证每个字节都相同;
- 弱验证:根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率。