前言
一个Web应用在生产环境中,往往经常会被用户访问很多次,现代计算机的发展水平,网页页面加载和渲染的速度是非常快速的,可以做到毫秒级别。然而在页面加载和渲染整个过程中,比较慢的环节就是网络请求。考虑到用户的使用体验,我们期望应用的加载速度越快越好,因此在做页面性能优化的时候,我们就要去针对那个最大的瓶颈入手——网络请求。而且由于网络环境具有不稳定性,会加剧我们的网络请求以及页面加载的不稳定性。因此更加提升了我们优化网络请求的必要性。
为了让网络请求更快一些,我们就要尽量减少我们网络请求资源的“体积”和“数量”,基于这个背景,我们需要使用缓存策略,而Http协议也为我们提供了非常强大的缓存机制。
一、什么是HTTP缓存策略
浏览器缓存是浏览器在本地磁盘对用户最近请求过的特定资源进行存储,当访问者再次访问同一页面时,浏览器就可以直接从本地磁盘加载可以多次使用的特定资源。用来减少发送不必要的网络请求,加块页面的加载速度,提升用户的使用体验
二、缓存策略
1. 与缓存有关的首部字段名
与强缓存有关的首部字段名主要有两个: Expires 和Cache-control
Expires
Expires 首部字段是HTTP/1.0中定义缓存的字段,其给出了缓存过期的绝对时间,即在此时间之后,响应资源过期,属于实体首部字段
Expires: Wed, 11 May 2022 03:50:47 GMT
上述示例表示该资源将在以上时间之后过去,而在该时间之前浏览器可以直接从浏览器缓存中读取数据,无需在此请求服务器,注意这里无需在此请求服务器便是命中了强缓存。
但是因为Expires设置的缓存过期时间是一个绝对时间,所以会受到客户端时间的影响(例如时区)而变得不精准(后面给出了max-age来代替使用)
Cache-Control
Cache-Control首部字段是HTTP/1.1中定义缓存的字段,其用于控制缓存的行为,可以组合使用多种指令,多个指令直接可以通过“,”分割,属于通用首部字段,常用的指令有:max-age,s-max-age,public/private,no-cache/no store等。
Cache-Control: max-age:3600, s-maxage=3600,public
Cache-control: no-cache
max-age 指令给出了缓存过期的相对时间,单位为秒数,当其余Expires同时出现时,max-age的优先级更高,但往往为了做向下兼容,两者都会经常出现在响应首部中。
同时max-age还可以在请求首部中被使用,告知服务器客户端希望接收一个存在时间不大于多少秒的资源。
s-maxage与max-age不同之处在于,其只适用于公共缓存服务器,比如资源从源服务器发出后又被中间的代理服务器接收并缓存,当使用s-maxage指令后,公共缓存服务器将直接忽略Expires和max-age指定的值。
public 指定表示该资源可以被任何节点缓存(包括客户端和代理服务器)
private与public相反,表示只提供给客户端缓存,代理服务器不会进行缓存,同时设置了private指令后,s-maxage指令将被忽略
no-cache 和 no store这两个指令在请求和响应中都可以使用,两者看上去都代表不缓存,但是在响应首部中被使用时,no-store 才是真正的不进行任何缓存,no-cache在请求首部和响应首部中略有不同。
当no-cache 在请求首部中被使用时,表示告知(代理**)服务器不直接使用缓存,要求向源服务器发起请求**,而当在响应首部中被返回时,表示客户端可以缓存资源,但每次使用缓存资源前都必须先向服务器确认其有效性,这对每次访问都需要确认身份的应用来说很有用。
我们也可以在代码里加入meta标签的方式来修改资源的请求首部:
<meta http-equiv="Cache-Control" content="no-cache"></meta>
上面我们已经了解了强缓存下的请求相应的两个主要首部字段,接下来我们在看看协商缓存中设计的主要首部字段名:If-Modified-Since , If-Modified-Since , Etag , If-None-Match
Last-Modified和If-Modified-Since
Last-Modified首部字段代表资源的最后修改时间,属于响应首部字段,当浏览器第一次接收到服务器返回的资源的Last-Modified值后,会把这个值存储起来,并下次访问该资源时通过携带If-Modified-Since请求首部发送给服务器验证该资源有没有过期
Last-Modified: Fri, 14 May 2021 17:23:13 GMT
If-Modified-Since: Fri, 14 May 2021 17:23:13 GMT
如果在If-Modified-Since字段指定的时间之后资源发生了更新,那么服务器会将最新的资源(状态码200)发送给浏览器并返回最新的Last-modified值,浏览器收到资源后会更新缓存的If-Modified-Since的值。
如果在If-Modified-Since字段指定的时间之后资源没有发生更新,那么服务器会返回状态码304 Not Modified的响应,浏览器会去读取缓存中的资源。
Etag和If-None-Match
Etag首部字段用于代表资源的唯一性标识,服务器会按照指定的规则生成资源的标识,其属于响应首部字段。当资源发送变化时,Etag的标识也会更新。同样的,当浏览器第一次接收到服务器返回资源的Etag值后,其会把这个值存储起来,并在下次访问该资源时通过携带If-None-Match请求首部发送给服务器验证该资源有没有过期
Etag: "asfasf-13213asfasfasfasfagfhfghrgfh"
If-None-Match: "asfasf-13213asfasfasfasfagfhfghrgfh"
如果服务器发现If-None-Match值与Etag 不一致时。说明服务器上的文件已经被更新,那么服务器会发送更新后的资源给浏览器并返回最新的Etag值,浏览器收到资源后会更新缓存的If-None-Match的值。
2. 强制缓存
浏览器首次发起HTTP请求会向浏览器缓存询问,若没有该资源的缓存数据时,会向服务器端发送HTTP请求,服务端会返回资源响应数据和缓存标识Cache-Control,随后浏览器将该资源的缓存数据存入浏览器缓存中,这就是强缓存的生成过程
当我们在此访问网站时,可以观察到如下图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-36r8JEZN-1650560015296)(C:\Users\Administrator\Desktop\image-20220419002923081.png)]
Size一列大部分都变成了disk cache 或者memory cache ,由图我们可以看出浏览器并没有和服务器进行交互,我们称这为命中强缓存,并且我们从Time可以得知,memory Cache比disk Cache更快(后面会介绍)
强缓存的特点是响应报头中都包含了与强缓存有关的首部字段: Expires或Cache-Control;
max-age与s-maxage
- s-maxage仅在代理服务器中生效(例如Nginx代理服务器)
- 在代理服务器中s-maxage优先级高于max-age,同时出现时s-maxage会覆盖max-age
Cache-Control: max-age=2592000,s-maxage=3600
如上首部,我们可以理解成,一个CDN资源,属于代理服务器资源,在其服务器中的缓存时间并不是30天,而是3600秒,所以当浏览器缓存30天之后重新向CDN服务器获取资源时,此时CDN缓存的资源也已经过期,会触发回源机制,即向源服务器发起请求更新缓存数据。
expires与max-age
上面提到过Expires设置的缓存过去时间是一个绝对时间,所以会受客户端时间的英雄而变得不精准,即例如我们将过期时间设置成2022年4月19日00:39:11,然后将客户端时间修改成过期时间之后,那么就会导致浏览器向服务器重新请求资源,缓存失效。
注意: 我们使用max-age相对时间(秒)来弥补expires绝对时间的不足,但是实际操作却发现我们修改了客户端时间,缓存仍然会失效,这是为什么呢?我们接下来看。
缓存新鲜度与使用期算法
正如食品保质期和使用期一样,强缓存也有着它的保质期和使用期
强缓存是否信息 = 缓存新鲜度 > 缓存使用期
1. 缓存新鲜度(保质期)
强缓存设计时间单位的首部字段主要有两个: max-age和expires。而缓存新鲜度公式如下:
缓存新鲜度 = max-age || (expires - data)
理解:当max-age存在时缓存新鲜度等于max-age的秒数,是一个时间单位,就像保质期一样。当max-age不存在时,缓存新鲜度等于expires - data 的值,expires我们应该已经熟悉,它是一个绝对时间,表示缓存过期的时间,那么下面主要介绍下首部字段data。
Data表示创建报文的日期时间, 可以理解称服务器(包含源服务器和代理服务器)返回新资源的时间,和expires一样是一个绝对时间,比如
data: Wed, 25 Aug 2021 13:52:55 GMT
那么过期时间(expires)减去创建时间(data)就可以计算出浏览器真实可以缓存的时间(默认已经转化为秒数),即缓存的保质期限(缓存新鲜度)。
2. 缓存使用期(使用期)
缓存使用期可以理解为浏览器已经使用该资源的时间。相比食品的使用期与当前日期和生成日期有关,缓存使用期主要与响应使用期,传输延迟时间和停留缓存时间有关,计算公式如下:
缓存使用期 = 响应使用期 + 传输延迟时间 + 停留缓存时间
响应使用期
响应使用期可以通过下面两种方式计算:
- max(0, response_time - data_value)
- age_value
第一种方式中的response_time (浏览器缓存收到响应的本地时间)是电脑客户端缓存获取到响应的本地时间,而data_value (响应首部data值)上面已经介绍过时服务器创建报文的时间,两者相减与0取最大值。
第二种方式直接获取age_value (响应首部age值),Age表示推算资源创建经过时间,可以理解为源服务器在多久前创建了响应或者在代理服务器中存储的时长,当一份Response Message(响应报文) 是从缓存里获取的时候,HTTP/1.1协议要求在Resposne Message 里添加一个Age header filed(Age首部字段)
Age介绍
age: 600
MDN: Age的值通常接近于0.表示此对象刚刚从原始服务器获取不久,其他的值则表示代理服务器当前的系统时间与此应答中的通用头Data的值之差
最终我们可以将以上两种方式进行组合为如下计算公式:
apparent_age = max(0, response_time - data_value)
响应使用期 = max(apparent_age, age_value)
传输延迟时间
因为HTTP的传输是耗时的,所以传输延迟时间是存在的,传输延迟时间可以理解为浏览器缓存发起请求到收到响应的时间差,其计算公式为:
传输延迟时间 = response_time - request_time
response_time (浏览器缓存收到响应的本地时间)是电脑客户端缓存获取到响应的本地时间,request_time 代表浏览器缓存发起请求的本地时间,两者相减便得到了传输延迟时间。
停留缓存时间
停留缓存时间表示资源在浏览器上已经缓存的时间,其计算公式如下:
停留缓存时间 = now - response_time
now 代表电脑客户端的当前时间,response_time 代表浏览器缓存收到响应的本地时间,两者相减便得到了停留缓存时间。
max-age仍然收到本地时间影响的原因
通过上面的分析我们得出影响缓存使用期的因素有如下几个:
上面request_time和response_time都是取的客户端本地时间,而now则是修改客户端本地时间直接导致强缓存失效的原因,因此一旦修改了电脑客户端本地时间为未来时间,缓存使用期的计算便会收到影响,主要是停留缓存时间变大,从而导致缓存使用期超过缓存新鲜度范围(强缓存失效)。
3. 协商缓存
讲完强缓存,接下来我们来看协商缓存。
浏览器启用协商缓存的前提是强缓存失效,但反过来,强缓存失效并不一定导致浏览器启用协商缓存。
当我们发送HTTP请求前向浏览器缓存查询时发现请求的缓存资源失效,便将缓存标识返回给服务器,于是浏览器携带缓存标识向服务器发起HTTP请求,之后服务器根据该标识判断这个资源有没有更新过,如果没有更新过返回304,浏览器便向浏览器缓存获取数据,这就是协商缓存的生效流程
Last-Modified与Etag
除了强缓存失效外,浏览器判断是否要走协商缓存还得借助上述提到的缓存标识: Last-Modified与Etag;
- Etag的优先级要高于last-modified,两者同时出现时,只有Etag会生效,在强缓存失效后浏览器便会携带它们向服务器发起请求,其中If-modified-since对应last-modified的值,If-None-Match对应Etag的值。
Last-Modified的弊端
Last-Modified是一个时间,最小单位是秒,如果资源的修改时间非常快,毫秒级别,那么服务器会误认为盖资源仍然没有修改,会导致资源无法在浏览器及时更新。另外还有一种情况是服务器资源确实被编辑了,但是其实质内容并没有修改,但是服务器此时还是会返回最新的last-modified诗句,但是我们并不希望浏览器认为这个资源被修改而重新加载,为了避免以上现象的发生,在特殊的场景下,我们就需要使用到Etag。
Etag原理和实现
查看Node中etag包我们可以得知,Etag值的生成方式大致有两种
- 使用文件大小和修改时间
- 使用文件内容的hash值和内容长度
通过上述方法生成的Etag被称为强Etag值,其不论实体发生多么细微的变化都会改变它的值。与之对立的便是弱Etag值,在etag包源码中我们可以发现通过传递第二个参数weak值为true就可以开启弱校验(弱Etag值只适用于提示资源是否相同,只有资源发生了根本变化,产生差异时才会改变Etag的值,这时会在字段值最开始处附加W/)
Etag: W/"123123-ascgkldsfglmxcvlpkajsl"
注意:不像强制缓存中 cache-control 可以完全替代 expires 的功能,在协商缓存中,ETag 并非 last-modified 的替代方案而是一种补充方案,因为它依旧存在一些弊端。
一方面服务器对于生成文件资源的 Etag 需要付出额外的计算开销,如果资源的尺寸较大,数量较多且修改比较频繁,那么生成 Etag 的过程就会影响服务器的性能。 另一方面 ETag 字段值的生成分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。
4. 启发式缓存
如果响应报文中没有max-age(s-maxage)和expires这两个关键字段时,强缓存的新鲜度该如何计算呢?
没有了强缓存的必要字段值,浏览器还会走强缓存嘛?
答案是肯定是,虽然响应报文中缺少用来确定强缓存过期时间的字段,但是此时会触发浏览器的启发式缓存
缓存新鲜度 = max(0, (data - last-modified)) * 10%
根据响应报头中data与last-modified值之差与0取最大值后取其值的十分之一作为缓存时间
三、前端应用中的HTTP缓存方案
在使用缓存技术优化性能体验的过程中,有一个问题是不可逾越的:我们既希望缓存能在客户端尽可能久的保存,又希望它能在资源发生修改时进行及时更新。
这是两个互斥的优化诉求,使用强制缓存并定义足够长的过期时间就能让缓存在客户端长期驻留,但由于强制缓存的优先级高于协商缓存,所以很难进行及时更新;若使用协商缓存,虽然能够保证及时更新,但频繁与服务器进行协商验证的响应速度肯定不及使用强制缓存快。那么如何兼顾二者的优势呢?
我们可以将一个网站所需要的资源按照不同类型去拆解,为不同类型的资源制定相应的缓存策略,以下面的HTML文件资源为例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP 缓存策略</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<img src="photo.jpg" alt="poto">
<script src="script.js"></script>
</body>
</html>
该 HTML 文件中包含了一个 JavaScript 文件 script.js 、一个样式表文件 style.css 和一个图片文件 phooto.jpg ,若要展示出该 HTML 中的内容就需要加载出其包含的所有外链文件。据此我们可针对它们进行如下设置。
首先 HTML 在这里属于包含其他文件的主文件,为保证当其内容发生修改时能及时更新,应当将其设置为协商缓存,即为 cache-control 字段添加 no-chche 属性值;其次是图片文件,因为网站对图片的修改基本都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可采用强制缓存且过期时间不宜过长,故可设置 cache-control 字段值为 max-age=86400 。
接下来需要考虑的是样式表文件 style.css ,由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存来提高重用效率,故可以考虑在样式表文件的命名中增加文件指纹或版本号(比如添加文件指纹后的样式表文件名变为了 style.a1s14daf.css ),这样当发生文件修改后,不同的文件便会有不同的文件指纹,即需要请求的文件 URL 不同了,因此必然会发生对资源的重新请求。同时考虑到网络中浏览器与 CDN 等中间代理的缓存,其过期时间可适当延长到一年,即 cache-control:max-age=31536000 。
最后是 JavaScript 脚本文件,其可类似于样式表文件的设置,采取文件指纹和较长的过期时间,如果 JavaScript 中包含了用户的私人信息而不想让中间代理缓存,则可为 cache-control 添加 private 属性值。
从这个缓存策略的示例中我们可以看出,对不同资源进行组合使用强制缓存、协商缓存及文件指纹或版本号,可以做到一举多得:及时修改更新、较长缓存过期时间及控制所能进行缓存的位置。
缓存设置注意事项
在前面的内容中虽然给出了一种制定缓存决策的思路与示例,但需要明白的一点是:不存在适用于所有场景下的最佳缓存策略。凡是恰当的缓存策略都需要根据具体场景下的请求资源类型、数据更新要求及网络通信模式等多方面因素考量后制定出来,所以下面列举一些缓存决策时的注意事项,来作为决策思路的补充。
-
拆分源码,分包加载 对大型的前端应用迭代开发来说,其代码量通常很大,如果发生修改的部分集中在几个重要模块中,那么进行全量的代码更新显然会比较冗余,因此我们可以考虑在代码构建过程中,按照模块拆分将其打包成多个单独的文件。这样在每次修改后的更新提取时,仅需拉取发生修改的模块代码包,从而大大降低了需要下载的内容大小。 -
预估资源的缓存失效 根据不同资源的不同需求特点,规划相应的缓存更新时效,为强制缓存指定合适的 max-age 取值,为协商缓存提供验证更新的 Etag 实体标签。 -
控制中间代理的缓存 凡是会涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。 -
避免网站的冗余 缓存是根据请求资源的 URL 进行的,不同的资源会有不同的 URL,所以尽量不要将相同的资源设置为不同的 URL。 -
规划缓存的层次结构 参考缓存决策中介绍的示例,不仅是请求的资源类型,文件资源的层次结构也会对制定缓存策略有一定影响,我们应当综合考虑。
四、用户操作与HTTP缓存
以chrome浏览器为例,当我们F12打开开发者工具的时候,右键刷新按钮可以看到有三种加载方式
正常重新加载
正常重新加载就是我们经常使用的F5刷新,观察开发者工具我们可以看到,大多数资源会命中强缓存,刷新之后大多数资源会从内存缓存(memory cache)中读取,由此可知正常重新加载模式会优先读取缓存。
硬性重新加载
硬性重新加载就是我们经常使用的ctrl + F5刷新,实践我们发现所有的请求报头首部被加上了 cache-control:
no-cache 和pragma: no-cache,两者的作用都表示告知代理服务器不直接使用缓存,要求向原服务器发起请求,而pragma的作用是为了兼容HTTP/1.0,由此我们可以得知硬性重新加载并没有清空缓存,而是禁用缓存,类似于我们在开发者工具面板勾选了Disable cache选项(有所不同,看下文);
清空缓存并硬性重新加载
该操作会将浏览器存储的本地缓存都清空后再重新向服务器发起请求,而且是所有的网站
我们在实际的项目中发现,虽有我们有时候使用了硬性重新加载,但是还是有个别的资源走了强缓存,这是为什么呢?
我们可以猜想,在资源硬性重新加载后请求报头并没有加上特定的两个请求首部。
其实原因很简单,因为硬性重新加载并没有清空缓存,当异步资源在页面加载完后插入时,其加载仍然会优先读取缓存。这里与勾选Disable cache选项有所不同,勾选Disable cache选项时即使是异步资源也不会读取缓存,因为缓存被我们提前禁用了。
在观察项目我们可以发现,有一种资源比一步资源还更加顽固,几乎永远都是from memory cache,它便是base64图片
这一现象可以解释为,Base64图片本质是一堆字符串,随着页面的渲染而加载,浏览器会对其进行解析,会损耗一定的性能,Base64被塞进内存缓存中可以看做是浏览器的一种节省渲染的自保行为
参考资料:掘金小册-前端缓存技术与方案解析 参考资料:https://blog.csdn.net/sxh951026/article/details/77934463
|