概述
在前端性能优化中,浏览器缓存一直占有比较重要的地位,一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷,所以了解浏览器的缓存机制原理,对于前端工程师来说,是很有必要的。
浏览器缓存分类
浏览器的缓存分类主要分为强缓存
和缓存协商
,我们先来看下加载一个页面的简单流程:
- 浏览器先根据资源请求的http头部信息来判断是否命中
强缓存
,如果命中了就直接加载缓存中资源,不会再发送请求到服务器。 - 如果未命中
强缓存
,浏览器会发送资源请求至服务器,由服务器来判断浏览器本地缓存是否失效,如果可以继续使用,服务器则不会返回资源信息,浏览器继续从缓存中加载资源信息,这个过程叫缓存协商
。 - 如果未命中
缓存协商
,服务器则会将完整的资源信息返回给浏览器,浏览器加载新的资源,并且更新本地缓存。
强缓存
与缓存协商
的共同点是:如果命中了,都是从浏览器缓存中加载资源,而不是从服务器加载资源;区别是:强缓存不发送请求到服务器,缓存协商
需要发送请求到服务器。
缓存位置
缓存位置主要有4种,并且各自的优先级不同,当依次查找缓存并且都没有命中的时候,才会去请求服务端资源。
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
Service Worker
Service Worker
是运行在浏览器背后的独立线程,一般可以用来实现离线缓存的功能,关于Service Worker可以点击链接进行了解。简单的来说Service Worke
就类似于一个中间人,所有浏览器发送的请求都会被Service Worke
拦截,所以必须使用 HTTPS 协议来保障安全,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。Service Worke Demo
所有被Service Worker
缓存的资源都能在控制台的Application下的Cache Tab里的Cache Storage看到:
Memory Cache
顾名思义就是直接从内存中读取缓存,一般是在页面刷新后,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等,当页面关闭后,内存就会释放。
Disk Cache
顾名思义就是直接从硬盘中读取缓存,相比Memory Cache
的优点在于容量和存储时效性上,绝大多数的缓存都来自于Disk Cache
,它主要是根据 HTTP Herder
中的字段判断哪些资源需要缓存,哪些资源可以直接使用缓存,哪些资源过期了需要重新请求,关于缓存字段会在后文中分析。
浏览器会把哪些文件放到内存中?哪些放到硬盘中?
大文件一般大概率是不会放到内存中去,这一点很容易理解,内存相对与硬盘来说容量是很少的,操作系统需要精打细算内存的使用,所以一般刷新页面后,缓存到内存中的资源一般都不大,例如页面上已经下载的样式、脚本、图片。还有就是在系统内存使用率高的情况下,文件会优先存储进硬盘。
Push Cache
Push Cache
(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,详情可点击HTTP/2 push is tougher than I thought
了解。
存储优先级
Service Worker
> memory cache
> disk cache
> Push Cache
强缓存
浏览器在命中强缓存
的时候是不会发送请求至服务器的,上文也说到过,强缓存
是根据资源请求的http头部信息来判断的,主要是根据哪些信息来判断的呢?其实呢强缓存
主要是根据请求header头中的Expires
或者是Cache-Control
两个字段来控制的,这两个字段都是在http response header
实现的,表示资源在浏览器缓存的有效期,Expires
是http1.0
提出的,而Cache-Control
是http1.1
提出的,如果二者同时存在,则优先以Cache-Control
为准。
Expires
Expires
是缓存过期时间,用来指定资源到期时间,是从服务器端返回的一个具体时间点,在http请求时告诉浏览器在过期时间前浏览器都可以直接从缓存中加载资源数据,而无需再次请求。如上图中所示,服务器返回expires: Sat, 11 Sep 2021 06:48:30 GMT
,这个时间就代表资源失效的时间,也就是说在2021-09-11 14:48:30
之前都是有效的。但是这种方式有个明显的bug,那就是服务器返回的是一个绝对时间,所以当客户端本地的时间被修改后,就能影响缓存命中的结果,于是就衍生出Cache-Control
这个字段,这也是为啥Expires
和Cache-Control
同时存在的时候,会以后者为准,下面介绍下Cache-Control
:
Cache-Control
Cache-Control
是一个相对时间,相对于Expires
,Cache-Control
就不会出现上述修改客户端时间导致影响缓存命中结果的问题。
Cache-Control
可以由多个字段组合而成,主要又以下几个值:
- max-age:
max-age
的值是一个时间长度,单位是s,在这个时间段内缓存是有效的,在下图中max-age=31536000
,也就是说缓存有效期为31536000s
,在没有禁用缓存并且没有超过这个有效时间的情况下,再次访问这个资源就会命中缓存。 - public:
Cache-Control: public
表示响应内容可以被任何对象(客户端、代理服务器...)缓存。 - private:
Cache-Control: private
代表只有发起请求的浏览器才可以进行缓存。 - s-maxage:同
max-age
,但只会在代理服务器中生效。 - no-cache:并不是字面意思上的‘不缓存’,而是使用对比缓存验证,强制向服务器验证,言外之意就是每次发送请求前,都会向服务器进行验证,如果服务器允许,才能使用本地缓存。
- no-store:这个是真正意义上的禁止缓存,每次请求都要向服务器重新获取数据。
cache-control
指令如何使用?
通过Cache-Control
以及max-age
设置,可以达到长缓存的效果,下面用node来看下Cache-Control
是如何被设置的。
// server.js
const http = require('http')
const fs = require('fs')
http.createServer(function (request, response) {
console.log('request come', request.url)
if (request.url === '/') {
const html = fs.readFileSync('test.html', 'utf8')
response.writeHead(200, {
'Content-Type': 'text/html'
})
response.end(html)
}
if (request.url === '/script.js') {
response.writeHead(200, {
'Content-Type': 'text/javascript',
'Cache-Control': 'max-age=200' // 浏览器缓存时间
})
response.end('console.log("script loaded twice")')
}
}).listen(8888)
console.log('server listening on 8888')
如果客户端向服务器发了请求,那么是否意味着一定要读取回该资源的整个实体内容呢?
试想客户端上某个资源保存的缓存过期时间过期了,但这个时候服务器并没有更新过这个资源,如果这个资源很大,客户端要求服务器再把这个东西重新发一遍过来,此时服务器内心是拒绝的,因为资源并没有改变,再次发送会非常浪费带宽和时间,属于可以发送但没必要,那是否有办法让服务器知道客户端现有的缓存文件是有效的,然后直接告诉客户端:“这东西你直接用缓存里的就可以了,我这边没更新过呢,就不再传一次过去了”。为了让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,http新增了几个字段来做这件事情,下面来说一下。
缓存协商
注意:
缓存协商
必须配合强缓存
的使用,如果不启用强缓存
,缓存协商
就根本没有意义。
当浏览器对某个资源的请求没有命中强缓存
,就会发送请求到服务器,验证缓存协商
是否命中,如果协商缓存命中,服务器将会返回一个http状态码为304并且会显示一个Not Modified
的响应,浏览器收到响应后,会继续从缓存中去加载此资源。
200 and 304
(第一次见面的谈话:)
C:hello,你几岁了?
S: 我今年18岁了。
=====================================
(半年后的谈话:)
C:你几岁了?我猜你18岁了。
S: 靠,你知道还问我?(304)
=====================================
(一年后的谈话:)
C:你几岁了?我猜你18岁了。
S: 我19岁了(200)
缓存协商
主要是利用【Last-Modified,If-Modified-Since】
和【ETag、If-None-Match】
这两对来判断的的。
Last-Modified,If-Modified-Since
Last-Modified
表示该资源在服务器上的最后修改时间,该字段是在浏览器第一次向服务器请求该资源时,服务器返回的一个字段。- 当浏览器再次向服务器请求这个资源时,会在request的header上加上
If-Modified-Since
的header,这个header的值就是上一次请求时返回的Last-Modified
的值。 - 当服务器再次收到资源请求时,会根据请求头中的
If-Modified-Since
字段与资源在服务器上的最后修改时间进行对比,如果没有变化则返回304 Not Modified
,否则就正常返回资源内容。
如果缓存协商也没有命中,浏览器将直接从服务器加载资源,这个时候Last-Modified
字段会在重新加载后更新,下次请求时,If-Modified-Since
会启用上传返回的Last-Modified
的值。
其实这个方法也是会有缺陷的,例如服务器端有个修改非常频繁的文件,可能就会出现服务器上资源发生了变化,但是最后修改时间却没有变化,这个时候缓存命中就会出现问题了,所以就衍生出了另一对【ETag、If-None-Match】
来管理缓存协商,下面来进行介绍:
ETag、If-None-Match
与【Last-Modified,If-Modified-Since】
不同的是,【ETag、If-None-Match】
返回的是一个校验码。ETag
可以保证每一个资源都是唯一的,资源发生变化都会导致ETag
变化,服务器则根据浏览器发送的If-Modified-Since
字段值来判断是否命中缓存,其中If-Modified-Since
的值就是浏览器第一次请求该资源时,服务器返回的ETag
值,这样就很好的解决了上述Last-Modified
遇到的问题。
Last-Modified
与ETag
也是可以一起使用的,但是服务器会优先验证ETag
,一致的情况下,才会继续对比Last-Modified
,最后才决定是否返回304
。
ETag
是如何生成的
在Apache
中,ETag
生成靠以下几种因子:
- 文件的i-node编号,是Linux/Unix用来识别文件的编号,使用命令’ls –I’可以看到
- 文件最后修改时间。
- 文件大小。
生成Etag的时候,可以使用其中一种或几种因子,使用抗碰撞散列函数来生成。
下面是我在查ETag
生成的时候看到的一个问题,可以了解一下:
如果 http 响应头中 ETag 值改变了,是否意味着文件内容一定已经更改?
ETag
的使用
下面通过node简单实现一下:
// server.js
const http = require('http')
const fs = require('fs')
http.createServer(function (request, response) {
console.log('request come', request.url)
if (request.url === '/') {
const html = fs.readFileSync('test.html', 'utf8')
response.writeHead(200, {
'Content-Type': 'text/html'
})
response.end(html)
}
if (request.url === '/script.js') {
console.log(request.headers)
const etag = request.headers['if-none-match']
if(etag === '777') {
response.writeHead(304, {
'Content-Type': 'text/javascript',
'Cache-Control': 'max-age=2000000, no-cache',
'Last-Modified': '123',
'Etag': '777'
})
response.end('') // 这里不传任何内容,即使有内容,浏览器也不会读取
} else {
response.writeHead(200, {
'Content-Type': 'text/javascript',
'Cache-Control': 'max-age=2000000, no-cache', // 通过 no-cache,即使没过期浏览器也要向服务器验证,不会从缓存读取。
'Last-Modified': '123', // 随便设的值
'Etag': '777'
})
response.end('console.log("script loaded twice")')
}
}
}).listen(8888)
console.log('server listening on 8888')