# 浏览器缓存一探究竟~
先看一张经典的流程图,结合理解
吃了它
# 1. 缓存作用
- 减少了冗余的
数据传输
,节省了网费。 - 减少了服务器的负担, 大大提高了网站的
性能
- 加快了客户端加载网页的
速度
# 2. 缓存分类
# 2.1 DNS 缓存
主要就是在浏览器本地把对应的 IP 和域名关联起来,这样在进行 DNS 解析的时候就很快。
# 2.2 MemoryCache
是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。 内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在。
# 2.3 浏览器缓存
浏览器缓存,也称Http 缓存,分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存
# 2.3.1 强缓存
强缓存是利用 http
头中的 Expires
和 Cache-Control
两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 Expires
和 cache-control
判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
# Expires
实现强缓存,过去我们一直用Expires
。当服务器返回响应时,在 Response Headers 中将过期时间写入 Expires
字段。像这样
expires: Wed, 12 Sep 2019 06:12:18 GMT
可以看到,expires
是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires
的时间戳,如果本地时间小于 expires
设定的过期时间,那么就直接去缓存中取这个资源。
从这样的描述中大家也不难猜测,expires
是有问题的,它最大的问题在于对本地时间
的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires
将无法达到我们的预期。
# Cache-Control
考虑到 expires
的局限性,HTTP1.1
新增了Cache-Control
字段来完成 expires
的任务。expires
能做的事情,Cache-Control
都能做;expires
完成不了的事情,Cache-Control
也能做。因此,Cache-Control
可以视作是 expires
的完全替代方案。在当下的前端实践里,我们继续使用 expires
的唯一目的就是向下兼容。
在 Cache-Control
中,我们通过max-age
来控制资源的有效期。max-age
不是一个时间戳,而是一个时间长度。在本例中,max-age
是 31536000
秒,它意味着该资源在 31536000
秒以内都是有效的,完美地规避了时间戳带来的潜在问题。
Cache-Control
相对于 expires 更加准确,它的优先级也更高。当 Cache-Control
与 expires 同时出现时,我们以 Cache-Control
为准。
可以参考下下面两张图:
# 2.3.2 协商缓存(对比缓存)
协商缓存依赖于服务端与浏览器之间的通信。协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304。
协商缓存的实现,从 Last-Modified
到 Etag
,Last-Modified
是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers
返回:
# Last-Modified
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,浏览器的请求头 headers
会带上一个叫 If-Modified-Since
的时间戳字段,它的值正是上一次 response
返回给它的 Last-Modified
值:
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers
中添加新的 Last-Modified
值;否则,返回 304 响应,Response Headers
不会再添加 Last-Modified
字段。
如下图:
通过最后修改时间来判断缓存是否可用
Last-Modified
:响应时告诉客户端此资源的最后修改时间If-Modified-Since
:当资源过期时(使用Cache-Control
标识的max-age
),发现资源具有Last-Modified
声明,则再次向服务器请求时带上头If-Modified-Since
。- 服务器收到请求后发现有头
If-Modified-Since
则与被请求资源的最后修改时间进行比对。若最后修改时间较新,说明资源又被改动过,则响应最新的资源内容并返回200
状态码; - 若最后修改时间和
If-Modified-Since
一样,说明资源没有修改,则响应304
表示未更新,告知浏览器继续使用所保存的缓存文件。
看个实例代码:
let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
http.createServer(function (req, res) {
let file = path.join(__dirname, req.url);
fs.stat(file, (err, stat) => {
if (err) {
sendError(err, req, res, file, stat);
} else {
let ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
if (ifModifiedSince == stat.ctime.toGMTString()) {
res.writeHead(304);
res.end();
} else {
send(req, res, file, stat);
}
} else {
send(req, res, file, stat);
}
}
});
}).listen(8080);
function send(req, res, file, stat) {
res.setHeader('Last-Modified', stat.ctime.toGMTString());
res.writeHead(200, { 'Content-Type': mime.getType(file) });
fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, stat) {
res.writeHead(400, { "Content-Type": 'text/html' });
res.end(err ? err.toString() : "Not Found");
使用 Last-Modified 存在一些弊端,这其中最常见的就是这样几个场景 1. 某些服务器不能精确得到文件的最后修改时间, 这样就无法通过最后修改时间来判断文件是否更新了。 2. 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。 3. 当我们修改文件的速度过快时(比如花了 100ms
完成了改动),由于 If-Modified-Since
只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。 4. 如果同样的一个文件位于多个 CDN 服务器上的时候内容虽然一样,修改时间不一样。
第二和第三这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified
的补充出现了。
# Etag
这个是协商缓存中的另外一种
Etag
是由服务器为每个资源生成的唯一的标识字符串(指纹),这个标识字符串可以是基于文件内容编码的,只要文件内容不同,它们对应的 Etag
就是不同的,反之亦然。因此 Etag
能够精准地感知文件的变化。
Etag
是 Web 服务端产生的,然后发给浏览器客户端。生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag
需要我们审时度势。正如我们刚刚所提到的——Etag
并不能替代 Last-Modified
,它只能作为 Last-Modified
的补充和强化存在。
执行流程是这样的: 1. 客户端想判断缓存是否可用可以先获取缓存中文档的ETag
,然后通过If-None-Match
发送请求给 Web 服务器询问此缓存是否可用。 2. 服务器收到请求,将服务器的中此文件的ETag
,跟请求头中的If-None-Match
相比较,如果值是一样的,说明缓存还是最新的,Web 服务器将发送304 Not Modified
响应码给客户端表示缓存未修改过,可以使用。 3. 如果不一样则 Web 服务器将发送该文档的最新版本给浏览器客户端
看如下实例代码:
let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
let crypto = require('crypto');
http
.createServer(function(req, res) {
let file = path.join(__dirname, req.url);
fs.stat(file, (err, stat) => {
if (err) {
sendError(err, req, res, file, stat);
} else {
let ifNoneMatch = req.headers['if-none-match'];
let etag = crypto
.createHash('sha1')
.update(stat.ctime.toGMTString() + stat.size)
.digest('hex');
if (ifNoneMatch) {
if (ifNoneMatch == etag) {
res.writeHead(304);
res.end();
} else {
send(req, res, file, etag);
}
} else {
send(req, res, file, etag);
}
}
});
})
.listen(8080);
function send(req, res, file, etag) {
res.setHeader('ETag', etag);
res.writeHead(200, { 'Content-Type': mime.lookup(file) });
fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, etag) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(err ? err.toString() : 'Not Found');
}
# 强缓存和协商缓存比较
优先级:
Etag
在感知文件变化上比 Last-Modified
更加准确,优先级也更高。当 Etag
和 Last-Modified
同时存在时,以 Etag
为准。
对比:
- 强制缓存如果生效,不需要再和服务器发生交互,而对比缓存不管是否生效,都需要与服务端发生交互
- 两类缓存规则可以同时存在,强制缓存优先级高于对比缓存,也就是说,当执行强制缓存的规则时,如果缓存生效,直接使用缓存,不再执行对比缓存规则
# 2.4 Service Worker Cache
Service Worker
是一种独立于主线程之外的 Javascript 线程
。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得
Service Worker
的“个人行为”无法干扰页面的性能,这个幕后工作者可以帮我们实现离线缓存、消息推送和网络代理等功能。我们借助 Service worker
实现的离线缓存就称为 Service Worker
Cache。
Service Worker
的生命周期包括 install、activited、working
三个阶段。一旦 Service Worker
被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件.
它就在浏览器开发工具(F12) Application
标签页中
# 2.5 Push Cache
Push Cache
是指 HTTP2
在 server push
阶段存在的缓存, 即推送缓存。这块的知识比较新,应用也还处于萌芽阶段,应用范围有限不代表不重要——HTTP2
是趋势、是未来。在它还未被推而广之的此时此刻,仍希望大家能对 Push Cache
的关键特性有所了解:
Push Cache
是缓存的最后一道防线。浏览器只有在Memory Cache
、HTTP Cache
和Service Worker Cache
均未命中的情况下才会去询问Push Cache
。Push Cache
是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。- 不同的页面只要共享了同一个
HTTP2
连接,那么它们就可以共享同一个Push Cache
。
大家可以参考这篇扩展文章
# 3. 请求流程
# 3.1 第一次请求
# 3.2 第二次请求
走上面的缓存机制
# 4. 如何干脆不发请求
- 浏览器会将文件缓存到
Cache
目录,第二次请求时浏览器会先检查Cache
目录下是否含有该文件,如果有,并且还没到Expires
设置的时间,即文件还没有过期,那么此时浏览器将直接从 Cache 目录中读取文件,而不再发送请求 Expires
是服务器响应消息头字段,在响应 http 请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求,这是HTTP1.0
的内容,现在浏览器均默认使用HTTP1.1
,所以基本可以忽略Cache-Control
与Expires
的作用一致,都是指明当前资源的有效期,控制浏览器是否直接从浏览器缓存取数据还是重新发请求到服务器取数据,如果同时设置的话,其优先级高于Expires
# 5.1 使用 Cache-Control
- private 客户端可以缓存
- public 客户端和代理服务器都可以缓存
- max-age=60 缓存内容将在 60 秒后失效
- no-cache 需要使用对比缓存验证数据,强制向源服务器再次验证
- no-store 所有内容都不会缓存,强制缓存和对比缓存都不会触发
- Cache-Control:private, max-age=60, no-cache
let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
let crypto = require('crypto');
http
.createServer(function(req, res) {
let file = path.join(__dirname, req.url);
console.log(file);
fs.stat(file, (err, stat) => {
if (err) {
sendError(err, req, res, file, stat);
} else {
send(req, res, file);
}
});
})
.listen(8080);
function send(req, res, file) {
let expires = new Date(Date.now() + 60 * 1000);
res.setHeader('Expires', expires.toUTCString());
res.setHeader('Cache-Control', 'max-age=60');
res.writeHead(200, { 'Content-Type': mime.lookup(file) });
fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, etag) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(err ? err.toString() : 'Not Found');
}
# 缓存位置
前面我们已经提到,当强缓存
命中或者协商缓存中服务器返回304
的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置
呢?
浏览器中的缓存位置一共有四种,按优先级从高到低
排列分别是:
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
# Service Worker
Service Worker
借鉴了 Web Worker
的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问 DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存、消息推送和网络代理等功能。其中的离线缓存就是 Service Worker Cache
。
Service Worker
同时也是 PWA
的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA
的分享中详细介绍。
# Memory Cache 和 Disk Cache
Memory Cache
指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。
Disk Cache
就是存储在磁盘中的缓存(硬盘),从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。稍微有些计算机基础的应该很好理解,就不展开了。
好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:
- 比较大的 JS、CSS 文件会直接被丢进磁盘,反之丢进内存
- 内存使用率比较高的时候,文件优先进入磁盘
# 总结
对浏览器的缓存机制来做个简要的总结:
首先通过 Cache-Control
验证强缓存是否可用
- 如果强缓存可用,直接使用
- 否则进入协商缓存即发送 HTTP 请求,服务器通过请求头中的
If-Modified-Since
或者If-None-Match
这些条件请求字段检查资源是否更新- 若资源更新,返回资源和
200
状态码 - 否则,返回
304
,告诉浏览器直接从缓存获取资源
- 若资源更新,返回资源和
# 参考资料
# 最后
文中若有不准确或错误的地方,欢迎指出,有兴趣可以的关注下Github,一起学习呀~~