前端浏览器缓存

我们为什么要缓存

  • 缓存可以减少用户的等待时间,提升用户的体验。

  • 减少网络带宽消耗

    对于网站运营者和用户,带宽都代表着成本,过多的带宽消耗,都需要支付额外的费用。如果可以使用缓存,只会产生极小的网络流量,这将有效的降低运营成本

  • 降低服务器压力。

    给网络资源设定有效期之后,用户可以重复使用本地的缓存,减少对源服务器的请求,降低服务器的压力。此外,搜索引擎的爬虫机器人也能根据过期机制降低爬取的频率,也能有效降低服务器的压力。

注意:如果缓存数据使用不当,会有“脏数据”。导致用户数据异常。

前端缓存/后端缓存

首先我们定义一下:什么是前端缓存?什么是后端缓存?

基本的网络请求有三个步骤:请求处理响应

后端缓存主要集中在处理步骤。通过保留数据库的连接,存储结果的处理等方式缩短处理时间。尽快进入“响应”步骤。本文不讲后端缓存。

前端缓存则可以在剩下的两步:请求响应中进行。在请求的过程中,浏览器也可以通过存储结果的方式直接使用资源,省去了发送请求。而响应步骤则需要浏览器和服务器共同配合。通过减少响应内容来缩短传输的时间。

按缓存位置分类

按缓存位置分类可分为三个部分:service worker, memory cache, disk cache

在Chrome 的开发者工具中,Network -> Size 一列看到一个请求最终的处理方式:如果是大小 (多少 K, 多少 M 等) 就表示是网络请求,否则会列出 memory cache, disk cacheServiceWorker

如图:

优先级是:(由上到下寻找,找到即返回;找不到则继续)

  • service worker
  • memory cache
  • disk cache
  • 网络请求

memory cache

memory cache 是内存中的缓存。

几乎所有的网络请求资源都会被浏览器自动加入到 memory cache 中。但是因为 数量很大,而且浏览器占的内存不能无限扩大 这两个原因。memory cache 就只能是个短期存储

通常的情况下,浏览器的tab 关闭后该浏览器tab的memory cache 就会失效了(为了给其他的tab腾出空间)。

极端的情况下,如果一个页面的缓存用了超级多的内存,那么可能在它没有关闭前,排在前面的缓存就已经失效了。

disk cache (HTTP cache)

disk cache 也叫HTTP cache,是存储在硬盘上的缓存。因此它是持久存储的,是实际存在于文件系统中的。

disk cache 会严格根据 HTTP 头信息中的各类字段来判定哪些资源可以缓存,哪些资源不可以缓存;哪些资源是仍然可用的,哪些资源是过时需要重新请求的。当命中缓存之后,浏览器会从硬盘中读取资源,虽然比起从内存中读取慢了一些,但比起网络请求还是快了不少的。绝大部分的缓存都来自 disk cache。

凡是永久性存储都会面临容量增长的问题。disk cache也是一样的。在浏览器自动清理时, 会有神秘的算法去把“最老的”或者“最可能过时的”资源删除。是一个一个删掉的。不过每个浏览器识别“最老的”和“最可能过时的”资源的算法不尽相同,可能也是它们差异性的体现。

Service Worker

memory cache, disk cache的缓存策略以及缓存/读取/失效的动作都是由浏览器内部判断 & 进行的,我们只能设置响应头的某些字段来告诉浏览器,而不能自己操作。而Service Worker是我们自己能够操作缓存的。

我们可以从 Chrome 的 F12 中,Application -> Cache Storage 找到这个单独的“小金库”。

Service Worker 的优点:

  • Service Worke 能够直接操作缓存。
  • 缓存是永久的。即使tab或者浏览器关闭,下次打开依然还在。

有两种情况会导致这个缓存中的资源被清除:手动调用 API cache.delete(resource) 或者容量超过限制,被浏览器全部清空。

请求网络

如果一个请求在上述 3 个位置都没有找到缓存,那么浏览器会正式发送网络请求去获取内容。之后容易想到,为了提升之后请求的缓存命中率,自然要把这个资源添加到缓存中去。

前端缓存

前端缓存分类 HTTP缓存浏览器缓存

HTTP缓存:在HTTP请求传输时用到的缓存,主要在服务器代码上设置(disk cache)。

浏览器缓存:前端开发在前端js上进行设置(如:Service Worker )。

前端缓存的分析过程

浏览器与服务器通信的方式为应答模式,即是:浏览器发起HTTP请求 – 服务器响应该请求。浏览器第一次向服务器发起该请求后拿到请求结果,会根据响应报文中HTTP头的缓存标识,决定是否缓存结果,是则将请求结果和缓存标识存入浏览器缓存中。如图:

浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识

浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

强缓存

强制缓存就是向浏览器缓存查找该请求结果,并根据该结果的缓存规则来决定是否使用该缓存结果的过程,

强制缓存的情况主要有三种情况:

  • 不存在该缓存结果和缓存标识,强制缓存失效,则直接向服务器发起请求

  • 存在该缓存结果和缓存标识,但该结果已失效,强制缓存失效,则使用协商缓存

  • 存在该缓存结果和缓存标识,且该结果尚未失效,强制缓存生效,直接返回该结果

强制缓存的规则:

当浏览器向服务器发起请求时,服务器会将缓存规则放入HTTP响应头中和请求结果一起返回给浏览器

控制强制缓存的字段: ExpiresCache-Control

强缓存的直接优点:直接减少请求数,是提升最大的缓存策略。

Expires

这是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间)。再次发起该请求时,如果客户端的时间小于Expires的值时,直接使用缓存结果。

例子:

后端的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 强缓存---> Expires
app.get('/1.css', (req, res) => {
const cssPath = path.join(__dirname, './public/stylesheets/1.css');
// 读取1.css 文件
fs.readFile(cssPath, (err, content) => {
if(!err) {
// 设置到期时间
res.setHeader('Expires', 'Thu Dec 05 2019 20:13:08 GMT+0800 (CST)');
// 发送1.css文件buffer
res.end(content);
}
});
});

第一次请求1.css 的结果:

另打开一个tab再次请求1.css的结果:

缺点:

  • 由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑自信修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。

  • 写法太复杂了。表示时间的字符串多个空格,少个字母,都会导致非法属性从而设置失效。

Cache-Control

已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-Control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求

这两者的区别就是前者是绝对时间,而后者是相对时间

例子:

后端的处理逻辑:

1
2
3
4
5
6
7
8
9
10
// 强缓存---> Cache-Control
app.get('/2.css', (req, res) => {
const cssPath = path.join(__dirname, './public/stylesheets/2.css');
fs.readFile(cssPath, (err, content) => {
// 设置到期时间,全部资源, 10秒请求使用本地资源
res.setHeader('Cache-Control', 'public, max-age=600');
// 发送2.css文件buffer
res.end(content);
});
});

第一次请求的结果:

另打开一个tab再次请求的结果:

Cache-control 字段常用的值:

  • max-age: 即最大有效时间。
  • must-revalidate:如果超过了 max-age的时间,浏览器就必须向服务器发送请求,验证资源是否有效。
  • no-cache:然字面意思是“不要缓存”,但实际上还是要求客户端缓存内容的,只是是否使用这个内容由后续的对比来决定。
  • no-store: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。
  • public:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)。
  • private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。

这些值是可以混合使用。

总结:自从 HTTP/1.1 开始,Expires 逐渐被 Cache-control 取代。Cache-control 是一个相对时间,即使客户端时间发生改变,相对时间也不会随之改变,这样可以保持服务器和客户端的时间一致性。而且 Cache-control 的可配置性比较强大。

Cache-control 的优先级高于 Expires,为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段我们都会设置。如果两个设置都有效的话, 优先使用Cache-Control。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,

协商缓存的两种情况:

  • 协商缓存生效,返回304

  • 协商缓存失效,返回200和请求结果结果

对比缓存的流程:

  1. 浏览器请求缓存数据库,返回一个缓存标识。
  2. 浏览器拿这个标识和服务器通讯。
  3. 如果缓存未失效,则返回HTTP状态码 304 标识缓存可以继续使用。如果缓存失效,则返回新的数据和缓存规则。浏览器响应数据后,再写入到缓存数据库。

对比缓存的优点: 通过减少响应体体积,来缩短网络传输时间。

虽然请求数和没有缓存是一样的。但是如果是304的话,返回的仅仅是一个状态码而已。但是并没有实际的文件内容,因此在响应体体积上的节省是它优化点。虽然和强制缓存相比提升幅度较小。但总比没有缓存好。

协商缓存的字段: Last-Modified / If-Modified-SinceEtag / If-None-Match

其中Etag / If-None-Match的优先级比Last-Modified / If-Modified-Since高

Last-Modified/If-Modified-Since

last-Modified是服务器在响应请求时用来说明资源的最后修改时间。与之对应的是if-Modified-Since.

在对比缓存中,浏览器发送HTTP请求中Header中会带上if-Modified-since字段,值为缓存资源的Last-Modified属性的值。

当服务器端接收到带有 If-Modified-Since 的请求时,则会将 If-Modified-Since 的值与被请求资源的最后修改时间做对比。如果相同,说明资源没有新的修改,则响应 HTTP Status Code 304,浏览器会继续使用缓存资源;如果最后修改时间比较新,则说明资源被修改过,则响应 HTTP Status Code 200,并返回最新的资源。

例如:

后端对处理:

1
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
// 对比缓存 [if-modified-since, Last-Modified, ]
app.use('/1.js', (req, res) => {
const jsPath = path.join(__dirname, './public/javascripts/1.js');

// 获取文件1.js的信息
fs.stat(jsPath, (err, stat) => {
// 获取文件内容被修改的时间 modify time
let lastModified = stat.mtime.toUTCString();
// 判断 if-modified-since 的时间与资源的最后修改时间是否一致
if (req.headers['if-modified-since'] === lastModified) {
// 设置响应状态码
res.writeHead(304, 'not modified');
// 不需要传输响应体
res.end();
} else {
// 读取文件
fs.readFile(jsPath, (err, content) => {
// 设置Last-Modified
res.setHeader('Last-Modified', lastModified);
// 设置响应状态码
res.writeHead(200, 'ok');
// 需要传输响应体
res.end(content);
})
}
})
});

第一次请求的结果:第一次请求, 后端设置响应字段 Last-Modified

第二次请求的结果:

当我修改1.js文件之后再次访问的结果:

存在的问题:

  • Last-Modified 标注的最后修改只能精确到秒级,如果某些文件在 1 秒钟以内被修改多次的话,它将不能准确标注文件的最后修改时间;
  • 如果本地打开缓存文件,即使没有对文件进行修改,但 Last-Modified 却改变了,导致文件没法使用缓存

Etag/If-None-Match

Etag是服务器端在响应请求时用来说明资源在服务器端的唯一标识,与之对应的是 If-None-Match 字段。

If-None-Match是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。

当服务器端接收到带有 If-None-Match 的请求时,则会将 If-None-Match 的值与被请求资源的唯一标识做对比。如果相同,说明资源没有新的修改,则响应 HTTP Status Code 304,浏览器会继续使用缓存资源;如果不同,则说明资源被修改过,则响应 HTTP Status Code 200,并返回最新的资源。

例子:

后端的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// 对比缓存 [ Etag, If-None-Match ]
app.get('/2.js', (req, res) => {
const jsPath = path.join(__dirname, './public/javascripts/2.js');
// 读取文件
fs.readFile(jsPath, (err, content) => {
// 对文件内容使用md5加密形成一个唯一的标识
let etag = md5(content);
// 请求头的唯一标识和当前文件的唯一标识是一致的,标识文件没有被修改过
if (req.headers['if-none-match'] === etag) {
// 设置响应头 304
res.writeHead(304, 'not modified');
// 响应体为空,减少传输时间
res.end();
} else {
// 设置响应头的Etag
res.setHeader('Etag', etag);
// 设置响应头 200
res.writeHead(200, 'ok');
// 需要返回内容
res.end(content);
}
})
});

第一次请求的结果:

再次请求的结果:

修改文件2.js 之后再次请求:

注意:Last-Modified 是 HTTP 1.0 的字段,而 Etag 是 HTTP 1.1 的字段,当 Last-Modified 与 Etag 同时存在时,Etag 的优先级要高于 Last-Modified。Etag 的出现主要是为了解决 Last-Modified 存在的问题。

用户刷新/访问行为

强缓存的例子,再次请求有让大家打开另一个tab,为什么呢?为什么不直接刷F5刷新,或者点击工具栏的帅秀楠按钮或者邮件菜单重新加载呢?

我们把刷新/访问界面的手段分为三类:

  • 在url输入栏输入然后回车/通过书签访问
  • F5/点击工具栏的刷新按钮/右键菜单重新加载
  • ctl+F5/硬性重新加载/清空缓存并且硬性重新加载

对以上三种访问情况进行实践和讨论。

准备工作:模拟第一次访问资源。请求头没有任何相关的缓存的信息。而响应体设置了以下头部信息:Cache-Control,Expires,Last-Modified。请求之后,浏览器会对该文件进行缓存。

后端代码如下:

1
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
// 刷新/访问行为
app.get('/1.png', (req, res) => {
// 设置到期时间
res.setHeader('Expires', 'Thu Dec 05 2019 23:51:08 GMT+0800 (CST)');
res.setHeader('Cache-Control', 'public, max-age=6000');

const imgPath = path.join(__dirname, './public/images/1.png');
// 获取文件1.png的信息
fs.stat(imgPath, (err, stat) => {
// 获取文件内容被修改的时间 modify time
let lastModified = stat.mtime.toUTCString();
// 判断 if-modified-since 的时间与资源的最后修改时间是否一致
if (req.headers['if-modified-since'] === lastModified) {
// 设置响应状态码
res.writeHead(304, 'not modified');
// 响应体为空,减少传输时间
res.end();
} else {
// 读取文件
fs.readFile(jsPath, (err, content) => {
// 设置Last-Modified
res.setHeader('Last-Modified', lastModified);
// 设置响应状态码
res.writeHead(200, 'ok');
// 响应体为空,减少传输时间
res.end(content);
})
}
})
});

1.在url输入栏输入然后回车

我们可以看到返回的响应码是200 ok (disk cache)。浏览器发现了该资源以及缓存了而且没有过期(Cache-Control或者Expires)。 没有跟服务器确认,而是直接使用了浏览器缓存的内容,其中响应的内容和上一次的响应内容是一样的。

如图:

2.F5/点击工具栏的刷新按钮/右键菜单重新加载

F5/点击工具栏的正常刷新按钮/右键菜单重新加载 的作用和在url输入栏输入然后回车的作用是不一样的。前者是无论如何都要发一个HTTP Request 给server。即使先前的响应中有Cache-Control或者Expires。而发送的请求头中,包含了这样的header信息。

其中Cache-Control 是浏览器强制加上的。而If-Modified-Since是因为在获取资源的时候包含了Last-Modified的头部,所以浏览器会使用If-Modified-Since头部信息重新发送改该时间确认资源是否需要重新发送。世纪server 没有改过这个1.png这个文件, 所以返回了304 not modified.

2.ctl+F5/硬性重新加载/清空缓存并且硬性重新加载

ctl+F5/硬性重新加载/清空缓存并且硬性重新加载是彻底的从server拿一份新的资源过来。所以不光耀发送HTTP request给server。而且这个请求里面连If-Modified-Since/If-None-Match都没有。这样就能逼着服务器不能返回304,而是把整个资源原原本本的返回一次。

为了保证拿到的是从server上获取的。 不只是去掉了f-Modified-Since/If-None-Match, 还添加了一些头部信息,如Cache-control:no-cache。因为cache不光是存在浏览器端,在浏览器端到服务器端的中间节点(如:Proxy)也可能扮演者Cache的角色。所以为了防止从这些节点获取缓存,所以加了Cache-control:no-cache。

最后,附上以上所有例子的代码:https://github.com/shuliqi/frontCache

文章作者: 舒小琦
文章链接: https://shuliqi.github.io/2019/12/03/前端浏览器缓存/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 舒小琦的Blog