# 什么是缓存
基本的网络请求就是三个步骤:请求,处理,响应。
后端缓存主要集中于处理步骤,通过保留数据库连接,存储处理结果等方式缩短处理时间,尽快进入响应步骤。当然这不在本文的讨论范围之内。
而前端缓存则可以在剩下的两步:请求和响应中进行。在请求步骤中,浏览器也可以通过存储结果的方式直接使用资源,直接省去了发送请求;而响应步骤需要浏览器和服务器共同配合,通过减少响应内容来缩短传输时间。前端缓存主要是保存资源副本并在下次请求时直接使用该副本。
# 为什么要缓存
- 缓解服务器压力(不用每次去请求资源)。
- 提升性能,提高访问速度(打开本地资源速度当然比请求回来再打开要快得多)。
- 减少网络IO消耗,减少带宽消耗。
- 通过网络获取内容既速度缓慢又开销巨大。
- 如果是较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。
# 缓存分类
缓存总体可分为:私有缓存(private)与共享缓存(public)。
- 私有缓存:该资源只能被浏览器缓存,private为默认值,只能用于单独用户。比如浏览器中的“缓存”选项,浏览器缓存拥有用户可以通过http下载的所有文档。
- 共享缓存:该资源既可以被浏览器缓存,也可以被代理服务器缓存,能够被多个用户使用。这样资源是可以被重复使用的,优点:减少网络拥堵和延迟。
按种类分的话可以分为:
- 数据库缓存。
- 代理服务器缓存。
- 网关缓存(CDN缓存)。
- 浏览器缓存。
这里我们主要聊一下浏览器缓存。
# 按缓存位置分类
从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络:
- Service Worker。
- memory cache。
- disk cache。
- Push Cache。
# Service Worker
Service Worker是一种独立于主线程之外的Javascript线程。它脱离于浏览器窗体,可以帮我们实现离线缓存、消息推送和网络代理等功能。
使用Service Worker的话,传输协议必须为HTTPS。因为Service Worker中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。
Service Worker的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
我们可以从Chrome的F12中,Application -> Cache Storage找到。除了位置不同之外,这个缓存是永久性的,即关闭TAB或者浏览器,下次打开依然还在(而memory cache不是)。有两种情况会导致这个缓存中的资源被清除:手动调用API:caches.delete(resource)或者容量超过限制,被浏览器全部清空。
Service Worker实现缓存功能一般分为三个步骤:首先需要先注册Service Worker,然后监听到install事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。
当Service Worker没有命中缓存的时候,我们需要去调用fetch函数获取数据。也就是说,如果我们没有在Service Worker命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从memory cache或者disk cache中还是从网络请求中获取的数据,浏览器都会显示我们是从Service Worker中获取的内容,即from ServiceWorker。
/* 判断当前浏览器是否支持serviceWorker */
if ('serviceWorker' in navigator) {
/* 当页面加载完成就创建一个serviceWorker */
window.addEventListener('load', () => {
/* 创建并指定对应的执行内容 */
/* scope 参数是可选的,可以用来指定你想让 service worker 控制的内容的子目录
在这个例子里,我们指定了 '/',表示 根网域下的所有内容。这也是默认值。 */
navigator.serviceWorker.register('./serviceWorker.js', { scope: './' })
.then((registration) => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch((err) => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
// serviceWorker.js
self.addEventListener('install', e => {
// 当确定要访问某些资源时,提前请求并添加到缓存中。
// 这个模式叫做“预缓存”
e.waitUntil(
caches.open('service-worker-test-precache').then(cache => {
return cache.addAll(['/static/index.js', '/static/index.css'])
})
)
})
self.addEventListener('fetch', e => {
// 缓存中能找到就返回,找不到就网络请求,之后再写入缓存并返回。
// 这个称为 CacheFirst 的缓存策略。
return e.respondWith(
caches.open('service-worker-test-precache').then(cache => {
return cache.match(e.request).then(matchedResponse => {
return matchedResponse || fetch(e.request).then(fetchedResponse => {
cache.put(e.request, fetchedResponse.clone())
return fetchedResponse
})
})
})
)
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# memory cache
memory cache是指存在内存中的缓存。包括当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。
因为存储在内存中,memory cache是响应速度最快的一种缓存,但由于同样的原因,缓存持续性很短,会随着进程的释放而释放。一旦我们关闭Tab页面,内存中的缓存也就被释放了。
计算机内存有限,比硬盘容量小很多,浏览器会考虑计算机具体情况来决定缓存放在内存中还是硬盘中。小文件优先缓存在内存中。
内存缓存中有一块重要的缓存资源是preloader相关指令下载的资源。众所周知preloader的相关指令已经是页面优化的常见手段之一,它可以一边解析js/css文件,一边网络请求下一个资源。
有些浏览器还会下载css中的@import内容或者<video>的poster等。而这些被preloader请求过来的资源就会被放入memory cache中,供之后的解析执行操作使用。preload(虽然看上去和刚才的preloader就差了俩字母)。实际上这个大家应该更加熟悉一些,例如<link rel="preload">。这些显式指定的预加载资源,也会被放入memory cache中。
WARNING
memory cache机制保证了一个页面中如果有两个相同的请求(例如两个src相同的<img>,两个href相同的<link>)都实际只会被请求最多一次,避免浪费。
不过在匹配缓存时,除了匹配完全相同的URL之外,还会比对他们的类型,CORS中的域名规则等。因此一个作为脚本(script)类型被缓存的资源是不能用在图片(image)类型的请求中的,即便他们src相等。
在从memory cache获取缓存内容时,浏览器会忽视例如max-age=0,no-cache等头部配置。例如页面上存在几个相同src的图片,即便它们可能被设置为不缓存,但依然会从memory cache中读取。这是因为memory cache只是短期使用,大部分情况生命周期只有一次浏览而已。而max-age=0在语义上普遍被解读为“不要在下次浏览时使用”,所以和memory cache并不冲突。
但如果真心不想让一个资源进入缓存,就连短期也不行,那就需要使用no-store。存在这个头部配置的话,即便是memory cache也不会存储。
# disk cache
disk cache也叫HTTP cache,也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之memory cache胜在容量和存储时效性上。实际是存在于文件系统中的。而且它允许相同的资源在跨会话,甚至跨站点的情况下使用,例如两个站点都使用了同一张图片。
会根据HTTP Herder中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。
对于到底哪些文件会缓存进内存,哪些进硬盘,参考如下:
- 对于大文件来说,大概率是不存储在内存中的,反之优先。
- 当前系统内存使用率高的话,文件优先存储进硬盘。
WARNING
有时候看到的是prefetch cache
prefetch cache (opens new window)是一种浏览器预加载机制,其利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的文档。网页向浏览器提供一组预取提示,并在浏览器完成当前页面的加载后开始静默地拉取指定的文档并将其存储在缓存中。当用户访问其中一个预取文档时,便可以快速的从浏览器缓存中得到。
如何实现:对要预加载的文件的link标签加上rel="prefetch":
<link rel="prefetch" href="/images/big.jpeg">
<!-- 或者使用 meta 标签 -->
<meta http-equiv="Link" content="</images/big.jpeg>; rel=prefetch">
2
3
# Push Cache
- Push Cache推送缓存是HTTP2在server push阶段存在的缓存,当以上三种缓存都没有命中时,它才会被使用。
- Push Cache是一种存在于会话阶段的缓存,当会话终止时,缓存也随之释放,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。
- 不同的页面只要共享了同一个HTTP2连接,那么它们就可以共享同一个Push Cache。
- Push Cache中的缓存只能被使用一次。
- 可以推送
no-cache和no-store的资源。 - 浏览器可以拒绝接受已经存在的资源推送。
# 请求网络
如果一个请求在上述四个缓存都没有命中,那么浏览器会正式发送网络请求去获取内容。之后容易想到,为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去。具体来说:
- 根据
Service Worker中的handler决定是否存入Cache Storage(额外的缓存位置)。 - 根据HTTP头部的相关字段(
Cache-Control、Pragma等)决定是否存入disk cache。 memory cache保存一份资源的引用,以备下次使用。
# 按失效策略分类
memory cache是浏览器为了加快读取缓存速度而进行的自身的优化行为,不受开发者控制,也不受HTTP协议头的约束,算是一个黑盒。Service Worker是由开发者编写的额外的脚本,且缓存位置独立,出现也较晚,使用还不算太广泛。所以我们平时最为熟悉的其实是disk cache,也叫HTTP cache(因为不像memory cache,它遵守HTTP协议头中的字段),平时所说的强缓存,协商缓存,启发式缓存,也都归于此类。
# 强缓存
强缓存的含义是,当客户端请求后,会先访问缓存数据库看缓存是否存在。如果存在则直接返回;不存在则请求真的服务器,响应后再写入缓存数据库。
强缓存直接减少请求数,是提升最大的缓存策略。它的优化覆盖了文章开头提到过的请求数据的全部三个步骤。如果考虑使用缓存来优化网页性能的话,强缓存应该是首先被考虑的。
- 不会向服务器发送请求,直接从缓存中读取资源。
- 状态码:200,显示
from disk cache或from memory cache。 - 设置两种HTTP Header实现:
Expires和Cache-Control。
# Expires
这是HTTP 1.0的字段,表示缓存到期时间,是一个绝对的时间(当前时间+缓存时间),如:
Expires: Thu, 10 Nov 2017 08:45:11 GMT
在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。
但是,这个字段设置时有两个缺点:
- 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自行修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。
- 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致非法属性从而设置失效。
# Cache-Control
已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-Control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求
这两者的区别就是前者是绝对时间,而后者是相对时间。如下:
Cache-Control: max-age=2592000
下面列举一些Cache-Control字段常用的值:(完整的列表可以查看MDN (opens new window))
- max-age:即最大有效时间,单位秒。
- s-maxage:同
max-age作用一样,只在代理服务器中生效(比如CDN缓存),s-maxage优先级高于max-age,只对public缓存有效。设置了s-maxage,没设置public,代理服务器也可以缓存这个资源。 - must-revalidate:如果超过了
max-age的时间,浏览器必须向服务器发送请求,验证资源是否还有效。 - no-cache:虽然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的协商缓存来决定。
- no-store:真正意义上的“不要缓存”。所有内容都不走缓存,包括强缓存和协商缓存。
- public:所有的内容都可以被缓存(包括客户端和代理服务器,如CDN)。
- private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。
WARNING
这里有一个疑问:max-age=0和no-cache等价吗?
从规范的字面意思来说,max-age到期是应该(SHOULD)重新验证,而no-cache是必须(MUST)重新验证。但实际情况以浏览器实现为准,大部分情况他们俩的行为还是一致的。(如果是max-age=0, must-revalidate就和no-cache等价了)
顺带一提,在HTTP/1.1之前,如果想使用no-cache,通常是使用Pragma字段,如Pragma: no-cache(这也是Pragma字段唯一的取值)。但是这个字段只是浏览器约定俗成的实现,并没有确切规范,因此缺乏可靠性。它应该只作为一个兼容字段出现,在当前的网络环境下其实用处已经很小。
总结一下,自从HTTP/1.1开始,Expires逐渐被Cache-Control取代。Cache-Control是一个相对时间,即使客户端时间发生改变,相对时间也不会随之改变,这样可以保持服务器和客户端的时间一致性。而且Cache-Control的可配置性比较强大。
Cache-Control的优先级高于Expires,为了兼容HTTP/1.0和HTTP/1.1,实际项目中两个字段我们都会设置。
# 协商缓存
当Cache-Control和Expires过期或者它的属性设置为no-cache时(即不走强缓存),那么浏览器第二次请求时就会与服务器进行协商,与服务器端对比判断资源是否进行了修改更新。
- 如果服务器端的资源没有修改(
Not Modified),那么就会返回304状态码,告诉浏览器可以使用缓存中的数据。返回的仅仅是一个状态码而已,并没有实际的文件内容,因此在响应体体积上的节省是它的优化点。 - 如果数据有更新就会返回200状态码,服务器就会返回更新后的资源并且将缓存信息一起返回。
- 跟协商缓存相关的header头属性有两组
ETag/If-None-Match、Last-Modified/If-Modified-Since请求头和响应头需要成对出现。
# Last-Modified/If-Modified-Since
- 服务器通过
Last-Modified字段告知客户端,资源最后一次被修改的时间,在response header中,例如Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT。 - 浏览器将这个值和内容一起记录在缓存数据库中。
- 下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的
Last-Modified的值写入到请求头的If-Modified-Since字段 服务器会将If-Modified-Since的值与Last-Modified字段进行对比。如果相等,则表示未修改,响应304;反之,则表示修改了,响应200状态码,并返回数据。
但是他还是有一定缺陷的:
- 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
- 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。
补充:If-Modified-Since、If-Unmodified-Since、If-Match、If-None-Match
If-Modified-Since:是一个条件式请求首部,服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为200。如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的304响应,而在Last-Modified首部中会带有上次修改时间。不同于If-Unmodified-Since,If-Modified-Since只可以用在GET或HEAD请求中。- 最常见的应用场景是来更新没有特定ETag标签的缓存实体。
If-Unmodified-Since:消息头用于请求之中,使得当前请求成为条件式请求:只有当资源在指定的时间之后没有进行过修改的情况下,服务器才会返回请求的资源,或是接受POST或其他non-safe方法的请求。如果所请求的资源在指定的时间之后发生了修改,那么会返回412(Precondition Failed)错误。- 与non-safe方法如POST搭配使用,可以用来优化并发控制,例如在某些wiki应用中的做法:假如在原始副本获取之后,服务器上所存储的文档已经被修改,那么对其作出的编辑会被拒绝提交(共享文档多人编辑场景)。
- 与含有
Range消息头的范围请求搭配使用,用来确保新的请求片段来自于未经修改的文档(断点续传)。
If-Match:表示这是一个条件请求。在请求方法为GET和HEAD的情况下,服务器仅在请求的资源满足此首部列出的ETag值时才会返回资源。而对于PUT或其他非安全方法来说,只有在满足条件的情况下才可以将资源上传。If-None-Match:是一个条件式请求首部。对于GET和HEAD请求方法来说,当且仅当服务器上没有任何资源的ETag属性值与这个首部中列出的相匹配的时候,服务器端才会返回所请求的资源,响应码为200。对于其他方法来说,当且仅当最终确认没有已存在的资源的ETag属性值与这个首部中所列出的相匹配的时候,才会对请求进行相应的处理。
# Etag/If-None-Match
为了解决上述问题,出现了一组新的字段Etag和If-None-Match。
Etag存储的是文件的特殊标识(一般都是hash生成的),服务器存储着文件的Etag字段。之后的流程和Last-Modified一致,只是Last-Modified字段和它所表示的更新时间改变成了Etag字段和它所表示的文件hash,把If-Modified-Since变成了If-None-Match。服务器同样进行比较,命中返回304,不命中返回新资源和200。
Etag与Last-Modified比较:
Etag在感知文件变化上比Last-Modified更加准确。Etag的生成过程需要服务器额外付出开销,会影响服务端的性能。Etag的优先级高于Last-Modified。
# 启发式缓存
如果Expires,Cache-Control: max-age,或Cache-Control: s-maxage都没有在响应头中出现,并且设置了Last-Modified时,那么浏览器默认会采用一个启发式的算法,即启发式缓存:根据响应头中2个时间字段Date和Last-Modified之间的时间差值,取其值的10%作为缓存时间周期。这是浏览器默认的缓存方式
// Date 减去 Last-Modified 值的 10% 作为缓存时间。
// Date:创建报文的日期时间, Last-Modified 服务器声明文档最后被修改时间
response_is_fresh = max(0, (Date - Last-Modified)) / 10
2
3
# 缓存小结
当浏览器要请求资源时
调用
Service Worker的fetch事件响应。查看
memory cache。查看
disk cache。这里又细分:- 如果有强制缓存且未失效,则使用强制缓存,不请求服务器。这时的状态码全部是200。
- 如果有强制缓存但已失效,使用协商缓存,比较后确定304还是200。
发送网络请求,等待网络响应
- 把响应内容存入
disk cache(如果HTTP头信息配置可以存的话)。 - 把响应内容的引用存入
memory cache(无视HTTP头信息的配置)。 - 把响应内容存入
Service Worker的Cache Storage(如果Service Worker的脚本调用了cache.put())。
# 用户行为对缓存的影响
强制刷新,window下是Ctrl + F5,mac下就是command + shift + R

# 缓存的最佳实践
整体的流程图:

频繁变动的资源,使用协商缓存
Cache-Control: no-cache
HTML文件:
html页面缓存的设置主要是在<head>标签中嵌入<meta>标签,这种方式只对页面有效,对页面上的资源无效。
<meta http-equiv="cache-control" content="no-cache">
<meta http-equiv="Cache-Control" content="max-age=7200">
2
不常变化的资源,静态资源,使用强缓存并配合文件名添加hash
Cache-Control: max-age=31536000
CSS,JS,图片,给它们的Cache-Control配置一个很大的max-age=31536000(一年)。
给文件名加上hash值,webpack给我们提供了三种哈希值计算方式:
- hash:跟整个项目的构建相关,构建生成的文件hash值都是一样的,只要项目里有文件更改,整个项目构建的hash值都会更改。(一般不用这个)
- chunkhash:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的hash值。
- contenthash:由文件内容产生的hash值,内容不同产生的contenthash值也不一样。
# 其他一些关于缓存的知识点
缓存服务器版本
当
Expires和Cache-Control:max-age=xxx同时存在的时候取决于缓存服务器应用的HTTP版本。应用HTTP/1.1版本的服务器会优先处理max-age,忽略Expires,而应用HTTP/1.0版本的缓存服务器则会优先处理Expires而忽略max-age。Vary
Vary是一个HTTP响应头部信息,它用来告诉服务器要用哪些头部信息来返回资源。如果你提供给移动端的内容是不同的,怎么让缓存服务器区分移动端和PC端呢?可以设置
User-Agent字段来区分不同的客户端。Vary: User-Agent1源服务器启用了gzip压缩,但用户使用了比较旧的浏览器,不支持压缩,缓存服务器如何返回?
Vary: Accept-Encoding1age
response headers里面的age表示命中代理服务器的缓存。它指的是代理服务器对于请求资源的已缓存时间,单位为秒。如果文件被修改或替换,age会重新由0开始累计。age值为0表示代理服务器刚刚刷新了一次缓存。
date
指的是响应生成的时间。如果按F5频繁刷新发现响应里的Date没有改变,就说明命中了缓存服务器的缓存。
兼容性
在Firefox浏览器下,使用
Cache-Control:no-cache是不生效的,其识别的是no-store。这样能达到其他浏览器使用Cache-Control:no-cache的效果。所以为了兼容Firefox浏览器,经常会写成Cache-Control: no-cache,no-store。后端设置缓存
强缓存:
res.setHeader('Cache-Control', 'public, max-age=xxx');1协商缓存:
res.setHeader('Cache-Control', 'public, max-age=0'); res.setHeader('Last-Modified', xxx); res.setHeader('ETag', xxx);1
2
3
# CDN缓存原理
CDN的全称Content Delivery Network,即内容分发网络。简而言之就是将静态资源文件(图片、视频、脚本等)缓存到距离用户最近位置的服务器上。因此用户在请求访问网站时,可以快速获取自己想要的内容。从而解决了跨运营商,跨地区,带宽小,CPU负载小所引起的响应速度慢等问题。
CDN网络是在用户和服务器之间增加Cache层,主要是通过接管DNS实现,将用户的请求引导到Cache上获得源服务器的数据,从而降低网络的访问的速度。
资源上传CDN之后,当用户访问CDN的资源地址之后会经历下面的步骤:
- 首先经过本地的DNS解析,请求cname指向的那台CDN专用的DNS服务器。
- DNS服务器返回全局负载均衡的服务器IP给用户。
- 用户请求全局负载均衡服务器,服务器根据IP返回所在区域的负载均衡服务器IP给用户。
- 用户请求区域负载均衡服务器,负载均衡服务器根据用户IP选择距离近的,并且存在用户所需内容的,负载比较合适的一台缓存服务器IP给用户。当没有对应内容的时候,会去上一级缓存服务器去找,直到找到资源所在的源站服务器,并且缓存在缓存服务器中。用户下一次在请求该资源,就可以就近拿缓存了。
注意:因为CDN的负载均衡和就近选择缓存都是根据用户的IP来的,服务器只能拿到local DNS的IP,也就是网络设置中设置的DNS IP,如果这个设置的不合理,那么可能起不到加速的效果。可能就近找到的缓存服务器实际离得很远。