前言
为什么需要浏览器缓存策略?自然是为了减小服务器的负担、提高网站性能。对于浏览器缓存,最理想的效果就是发起请求后获取静态资源并保存到本地,如果服务器的静态资源没有更新,那么下次请求时直接从本地读取;如果静态资源更新了,那下次请求时,就到服务器获取资源并保存到本地。一个优秀的缓存策略可以减少带宽、降低网络负荷、加快网页加载速度。
一、缓存过程分析
浏览器与服务器通信方式为应答模式,即浏览器发起HTTP请求 -> 服务器响应该请求
浏览器如何判断该资源是否缓存?
浏览器接收到响应结果后,根据响应报文中HTTP头的缓存标识(如信息头中的Cache-Control、实体头中的Etag、Epires、Last-Modified等)决定是否缓存结果,是的话就将请求结果和缓存标识存到本地。具体过程如图:
由上图可知:
- 浏览器每次发送请求都会先在浏览器缓存中去查找是否有该请求的请求结果和缓存标识
- 浏览器接收到请求结果都会将结果和标识缓存到本地
以上两点是浏览器缓存机制的关键,它确保了每个请求的缓存和读取,那么浏览器会将请求结果和标识缓存在哪些位置?浏览器怎么判断是否需要向服务器重新发起HTTP请求?接下来我们详细分析
二、缓存位置
缓存位置分为四种,优先级之分,当依次查找缓存且都没有命中的时候才会向服务器发送请求
- Serivce Worker
- Memory Cache
- Disk Cache
- Push Cache
1.Service Worker
Service Worker 是运行在浏览器背后的独立线程,可以用来实现缓存功能。由于Service Worker 涉及到请求拦截,所以传输协议应该用HTTPS来保障安全。Service Worker 的缓存是持续的,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存。
具体缓存实现分为三个步骤:
- 先注册Service Worker
- 监听到 install 事件之后就可以缓存需要的文件
- 用户访问时可以通过拦截请求方式查询是否存在缓存,存在则直接读取
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function(registration) {
console.log('service worker 注册成功')
})
.catch(function(err) {
console.log('servcie worker 注册失败')
})
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
e.waitUntil(
caches.open('my-cache').then(function(cache) {
return cache.addAll(['./index.html', './index.js'])
})
)
})
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(function(response) {
if (response) {
return response
}
console.log('fetch source')
})
)
})
如图:打开页面,可以在开发者工具中的应用程序(Application)中看到Service Worker
如图:在缓存存储(Cache)中也可以发现文件已被缓存
当 Service Worker 没有命中缓存时,我们需要调用fetch函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。
2.Memory Cache
Memory Cache 是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据比磁盘快,虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭页面,内存中的缓存也就被释放了。
既然内存缓存读取高效,我们是否能让数据都存在内存中?
自然是不可能的,内存中的容量比硬盘容量小很多,操作系统需要精打细算内存的使用,所有我们能使用的内存并不多。
当我们访问页面后再次刷新可以发现很多数据来自Memory Cache,如图:
内存缓存可以使用相关指令preloader下载资源(<link rel="preloader>"),preloader指令已是页面优化的常见手段之一,它可以一边解析js等文件,一边请求下一个资源。需要注意的是内存缓存在缓存资源时并不关心HTTP缓存头Cache-Control是什么值,同时资源的匹配也并非仅仅是对URL做匹配,还可能会对Content-Type,CORS等其他特征做校验。
3.Disk Cache
Disk Cache 是存储在硬盘中的缓存,读取速度较慢,但是磁盘存储的容量大,比之 Memory Cache 胜在容量和存储时效性上。
在浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。
那浏览器会把哪些文件缓存在内存,哪些缓存在硬盘?
- 对于大文件来说,大概率是不存储在内存中的,反之优先。
- 当前系统内存使用率高的话,文件优先存储进硬盘。
4.Push Cache
Push Cache(推送缓存)是HTTP/2中的内容,当以上三种缓存都没有命中时,才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。
关于 Push Cache 这里推荐阅读Jake Archibald的 HTTP/2 push is tougher than I thought 这篇文章,文章中的几个结论
- 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差
- 可以推送 no-cache 和 no-store 的资源
- 一旦连接被关闭,Push Cache 就被释放
- 多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。
- Push Cache 中的缓存只能被使用一次
- 浏览器可以拒绝接受已经存在的资源推送
- 你可以给其他域名推送资源
如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。
为了性能上的考虑,大部分的接口都应该选择好缓存策略,通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的。
三、缓存策略
1.强缓存
使用强缓存策略时,如果缓存资源有效,则直接使用缓存资源,不用向服务器发送请求。一般通过设置HTTP头信息中的Expires 和 Cache-Control属性来实现,其中Cache-Control 优先级较高。
1.1 Expires
服务器通过在响应头中添加 Expires 属性(Expires=max-age + 请求时间,需要与Last-modified结合使用),来指定资源的过期时间(该时间是服务端的具体时间点)。在过期时间以内,该资源可以被缓存使用,不必再向服务器发送请求。
Expires 是 HTTP1.0 中的方式,存在这样的问题:客户端的时间和服务器端的时间不一致,或者用户可以对本地时间进行修改,可能会影响缓存命中的结果。这个时候浏览器向服务器发送请求,接收到的资源文件可能与缓存的资源文件相同,就会造成网络资源浪费。于是在 HTTP1.1 中提出了一个新的头部属性就是 Cache-Control ,它提供了对资源的缓存的更精确的控制。
1.2 Cache-Control
在HTTP/1.1中,Cache-Control是很重要的规则,主要用于精确控制资源缓存,主要取值为:
- public :资源可以被任何对象(包括发送请求的客户端、代理服务器等)缓存,该字段不常用
- private :资源只有发送请求的客户端可以缓存,不允许任何代理服务器缓存。在实际开发当中,对于一些含有用户信息的HTML,通常都要设置这个字段值,避免代理服务器(CDN)缓存,Cache-Control的默认取值
- no-cache :需要先和服务端确认返回的资源是否发生了变化,如果资源未发生变化,则使用缓存好的资源(使强缓存失效,但是还有协商缓存)
- no-store :禁止任何缓存,每次都会向服务端发起新的请求,拉取最新的资源(使强缓存和协商缓存都失效)
- max-age=xxx :设置缓存日期,单位为秒,缓存内容将在xxx秒后失效
- s-maxage :与max-age作用相同,但优先级高于max-age,仅适用于共享缓存(CDN),比如当s-maxage=60时,在这60秒中,即使更新了CDN的内容,浏览器也不会进行请求。
- max-stale :能容忍的最大过期时间。发送请求的客户端愿意接收已经过期的资源,但是不能超过给定的时间限制。
- min-fresh : 能够容忍的最小新鲜度。客户端不愿意接受新鲜度不多于当前的age加上min-fresh设定的时间之和的响应。
强缓存判断是否缓存的依据来自于是否超出某个时间或者某个时间段,而不关心服务器端文件是否已经更新,这可能会导致加载文件不是服务器端最新的内容,那我们如何获知服务器端内容是否已经发生了更新呢?此时我们需要用到协商缓存策略。
2.协商缓存
没有命中强缓存如果设置了协商缓存,这个时候协商缓存就发挥作用了。当强缓存失效后,浏览器携带缓存标识向服务器发送请求,由服务器根据缓存标识决定是否使用缓存,如果资源没有修改,则返回 304 状态,让浏览器使用本地的缓存;资源修改则返回 200 、修改后的资源文件、新的缓存标识,浏览器会将新的资源文件和标识重新缓存到本地。
如图:资源没有修改,返回304
如图,资源被修改,返回新的资源文件、缓存标识、200
协商缓存一般通过HTTP头信息中的Etag/If-None-Match 和 Last-Modified/If-Modified-Since 属性。
2.1 Last-Modified/If-Modified-Since
服务器通过在响应头中添加 Last-Modified 属性来指出资源最后一次修改的时间,如图
当浏览器下一次发起请求时,会在请求头中添加一个 If-Modified-Since 的属性,属性值为上一次资源返回时的 Last-Modified 的值。当请求发送到服务器后服务器会通过这个属性来和资源的最后一次的修改时间来进行比较,以此来判断资源是否做了修改。如果资源没有修改,那么返回 304 状态,让客户端使用本地的缓存。如果资源已经被修改了,则返回修改后的资源。
Last-Modified 存在的弊端:
- 如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成 Last-Modified 被修改,服务端不能命中缓存导致发送相同的资源。
- 因为 Last-Modified 只能以秒计时,如果文件在1秒内完成修改,那么文件被改变但是LAst-Modified值却没有改变,服务端会认为资源还是命中了,不会返回正确的资源。
由于 Last-Modified 可能发生的不准确性,http 中提供了另外一种方式,那就是 Etag 属性。
2.2 Etag/If-None-Match
服务器在返回资源的时候,在头信息中添加了 Etag 属性,这个属性是资源生成的唯一标识符(由服务器生成)
当资源发生改变的时候,这个值也会发生改变。在下一次资源请求时,浏览器会在请求头中添加一个 If-None-Match 属性,这个属性的值就是上次返回的资源的 Etag 的值。
服务接收到请求后会根据这个值来和资源当前的 Etag 的值来进行比较,以此来判断资源是否发生改变,是否需要返回资源。通过这种方式,比 Last-Modified 的方式更加精确。(注意:当 Last-Modified/If-Modified-Since 和 Etag/If-None-Match 属性同时出现的时候,Etag/If-None-Match 的优先级更高。)
2.3 两者对比
- 在精确度上,Etag要优于Last-Modified。
Last-Modified的时间单位是秒,如果某个文件在1秒内改变了多次,那么他们的Last-Modified其实并没有体现出来修改,但是Etag每次都会改变确保了精度;如果是负载均衡的服务器,各个服务器生成的Last-Modified也有可能不一致。
- 在性能上,Etag要逊于Last-Modified,Last-Modified 只需要记录时间,而 Etag 需要服务器通过算法来计算出一个 hash 值。
使用协商缓存的时候,服务器需要考虑负载平衡的问题,因此多个服务器上资源的 Last-Modified 应该保持一致,因为每个服务器上 Etag 的值都不一样,因此在考虑负载平衡时,最好不要设置 Etag 属性。
四、缓存机制
- 浏览器第一次请求资源,服务器返回200,浏览器下载并缓存资源文件和标识,以供下次请求判断。
- 下一次加载资源时,由于强缓存优先级较高,浏览器会先比较当前时间与上一次返回200时的时间差,如果·没有超过cache-control设置的max-age,说明没有过期,命中强缓存直接从本地读取资源。若浏览器不支持HTTP1.1,就使用Expires头来判断是否过期。
- 如果资源已过期,说明没有命中强缓存,开始协商缓存,向服务器发送携带If-None-Match和If-Modified-Since标识的请求。
- 服务器收到请求后,优先根据Etag值判断资源文件是否有修改,如果Etag值一致说明没有修改,返回304;Etag值不一致说明有修改,返回200、新的Etag值、新的资源文件、标识(If-None-Match和If-Modified-Since),浏览器收到后缓存到本地。
- 如果没有收到Etag值,就会将If-Modified-Since和请求文件最后的修改时间作对比,一致则命中协商缓存,返回304;不一致则返回200、新的Last-Modified、标识(If-None-Match和If-Modified-Since)、资源文件,浏览器收到后缓存到本地。
如果浏览器什么缓存策略都没设置,浏览器会采用一个启发式的算法,一般会取响应头中的Date减去Last-Modified值的10%作为缓存时间。
五、实际场景应用
频繁变动的资源:
对于频繁变动的资源,首先需要使用Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。
不常变动的资源:
通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名(或者路径)中添加 hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。
在线提供的类库 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均采用这个模式。
六、用户行为对浏览器缓存的影响
所谓用户行为对浏览器缓存的影响,指的就是用户在浏览器如何操作时,会触发怎样的缓存策略。主要有 3 种:
- 地址栏输入地址: 查找 Disk Cache 中是否有匹配。如有则使用;如没有则发送网络请求。
- 普通刷新 (F5):因为 Tab 并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话)。其次才是 disk cache。
- 强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有
Cache-control: no-cache(为了兼容,还带了Pragma: no-cache),服务器直接返回 200 和最新资源文件。