持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第30天,点击查看活动详情
前言
缓存是优化手段中不可或缺的一部分,面试时也经常问到,它可以加快页面的响应速度、减轻服务器的压力。很多人对这方面的了解都是看过相关文章,但是没有实际操作过,今天我带大家手把手走一遍这个流程,让浏览器缓存知识变成真正属于自己的知识。
搭建 express 服务端
首先我们先用 express 框架搭建一个简单的服务端。
三部曲运行服务器
三部曲第一步:创建一个空文件夹,进入终端生成一个 package.json 文件。
npm init -y
三部曲第二步:安装 express 依赖。
npm install express --save
三部曲第三步:创建一个 index.js 文件。
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
浏览器打开 localhost:3000 看看刚起的服务:
不出意外我们看到的是这个画面。紧接着就可以进行下一步了。
打印请求资源路径
接下来我们要做一件事 —— 将每次向服务端请求资源的路径都打印出来 。这样方便我们后续对缓存未命中,观察哪些资源是需要重新向服务端请求的。
app.all("*", function (req, res, next) {
console.log(req.originalUrl);
next();
});
app.use('/public', express.static(__dirname + '/public', {
...
}))
这里要注意顺序之分,先 app.all 再 app.use 。
静态文件映射
如果你想把所有的静态文件路径都前缀"/static", 你可以使用“挂载”功能。 如果req.url 不包含这个前缀, 挂载过的中间件不会执行。 当function被执行的时候,这个参数不会被传递。 这个只会影响这个函数,后面的中间件里得到的 req.url里将会包含"/static"
// GET /static/javascripts/jquery.js
// GET /static/style.css
// GET /static/favicon.ico
app.use(express.static('/public'));
declare namespace serveStatic {
var mime: typeof m;
interface ServeStaticOptions<R extends http.ServerResponse = http.ServerResponse> {
cacheControl?: boolean | undefined;
etag?: boolean | undefined;
maxAge?: number | string | undefined;
lastModified?: boolean | undefined;
}
...
}
首先我们 禁用所有缓存 。具体的,根据 ts 文件我们可以知道,etag、cacheControl 以及 lastModified 都可以接受一个 Boolean 值,那么我们可以给它们三个设置 false,表示禁用,而 maxAge 我们可以设置一个 0,表示禁用。
app.use(express.static('/static', {
etag: false,
cacheControl: false,
lastModified: false,
maxAge: 0
}))
功能测试
接下来访问 localhost:3000/public/index.html 进行测试:
终端资源请求路径:
ps:小伙伴们测试缓存的时候重点关注 status 和 size 这两栏就好了,后面不再赘述。
不管刷新多少次,状态码都是是 200,Size 栏那边也没有任何表示 test.js 是从缓存中读取的信息,并且每次刷新时,终端中都会打印这两个文件 —— 这说明每次都是从服务端直接获取的。
强缓存
首先我们测试强缓存 —— 在设定的过期时间内,浏览器都不会再向服务器请求资源,而是直接从浏览器缓存中加载资源。
Expires
Expires 是 HTTP 1.0 时期提出的一个 表示资源过期的绝对时间 的头部。
测试
接下来我们尝试给响应的资源加上 Expires 头部:
app.use('/public', express.static(__dirname + '/public', {
...
setHeaders: function (res, path, stat) {
res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
}
}))
这里我们将 资源过期时间 设置为 15 秒,即 15 秒后,强缓存失效,需要向服务端重新请求资源。
重启服务,然后再次访问 localhost:3000/public/index.html,我们来看看浏览器中请求的结果以及终端里当前请求的资源路径:
-
首次访问页面时加载了 index.html 文件以及 test.js 文件:
-
15 秒内,刷新页面重新加载 test.js 文件,此时强缓存仍有效,不用向服务端发起资源请求,而是直接从浏览器缓存中获取资源:
-
超出 15 秒后,重新加载 test.js 文件,发现强缓存失效,需要重新向服务端发起资源请求:
详细过程:
- 浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了
Expires响应头,表示资源过期的绝对时间 ,浏览器接收资源的同时会将这个 Response Header 缓存下来。 - 15 秒内再次加载该资源时,通过比对此前缓存的 Response Header 中
Expires的时间,判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示momory cache。 - 15 秒后加载该资源时,同样通过比对,发现超出过期时间,则判定强缓存失效,会重新向服务端发送资源请求,浏览器接收响应资源的同时缓存该 Response Header 。
缺点
我们从上面可以知道,Expires 是服务端往响应头中设置的 绝对时间,如果说服务器和浏览器的时间不一致的话,是很不稳定的 —— 客户端修改本地时间时或者时区不一致时,都可能产生问题。
Cache-Control
HTTP 1.1 时期增加 Cache-Control 响应头来改善这个问题。
测试
Cache-Control 的默认值为 public, max-age=0 。因此我们要修改 maxAge 属性。
cacheControl?: boolean | undefined;
maxAge?: number | string | undefined;
通过 ts 文件我们可以看出 cacheControl 接收的是 Boolean 值,我们要将它设置为 true 。同时,将 maxAge 属性设置为 15 秒(注意这里的 maxAge 属性单位是毫秒,响应头的 max-age 单位是秒,因此要注意单位换算)。
app.use('/public', express.static(__dirname + '/public', {
etag: false,
cacheControl: true,
lastModified: false,
maxAge: 15 * 1000, // 注意这里的 maxAge 单位是毫秒,响应头的 max-age 单位是秒
// setHeaders: function (res, path, stat) {
// res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
// }
}))
重启服务,然后访问 localhost:3000/public/index.html:
-
首次访问页面时加载了 index.html 文件以及 test.js 文件:
-
15 秒内,刷新页面重新加载 test.js 文件,此时强缓存仍有效,不用向服务端发起资源请求,而是直接从浏览器缓存中获取资源:
-
超出 15 秒后,重新加载 test.js 文件,发现强缓存失效,需要重新向服务端发起资源请求:
喜欢看 动图 的小伙伴看这里:
详细过程:
-
15 秒后,同样的操作,但是发现超出 当前时间,判定强缓存过期,于是重新向服务端发送资源请求,浏览器接收资源的同时将缓存该响应头。
-
浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了
Cache-Control响应头,表示资源过期的相对时间 ,浏览器接收资源的同时会将这个 Response Header 缓存下来。 -
15 秒内再次加载该资源时,通过比对此前缓存的 Response Header 中的资源过期时间
Cache-Control+ 资源请求时间Date,发现 未超出当前时间 ,则判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示momory cache。 -
15 秒后加载该资源时,同样通过比对,发现 超出当前时间 ,则判定强缓存失效,会重新向服务端发送资源请求,浏览器接收响应资源的同时缓存该 Response Header 。
Cache-Control 和 Expires 的优先级
如果 Cache-Control 和 Expires 同时存在会以谁的时间为准呢?我们来测试一下:
app.use('/public', express.static(__dirname + '/public', {
etag: false,
cacheControl: true,
lastModified: false,
maxAge: 10 * 1000,
setHeaders: function (res, path, stat) {
res.append('Expires', new Date(new Date().getTime() + 15 * 1000));
}
}))
重启服务,然后访问 localhost:3000/public/index.html。
可以发现强缓存在 10 秒后就失效了。
因此当 Cache-Control 和 Expires 同时存在时,会以 Cache-Control 的时间为准。
小结
通过上面的了解,我们应当知道:
- 强缓存
Expires,表示的是一个 服务端为准的绝对时间。 - 强缓存
Cache-Control,表示的是一个过期秒数,过期时间是以 客户端为准的相对时间 。 - 两者均存在则以
Cache-Control的时间为准。 - 加载资源时,如果命中强缓存(在有效期内),则响应状态码 200,且有
memory cache字样,表示该资源是从浏览器缓存中加载的。若缓存失效后会重新向服务端发起资源请求,服务器响应资源,浏览器接收响应的同时将Response Header缓存下来。 - 强缓存是由服务器设置的,但是是由浏览器来判断缓存是否还有效。
协商缓存
如果没有命中强缓存,就会判断是否命中协商缓存,此时浏览器会向服务端发送请求,确认资源是否被修改(是否有效),如果资源未修改则告知浏览器仍可用缓存的资源,否则服务端响应时会将资源一并响应。
Last-Modified 和 If-Modified-Since
协商缓存的实现都是成双成对的,我们先介绍 Last-Modified 和 If-Modified-Since。Last-Modified 表示资源的最后修改时间,类似这样:
测试
lastModified?: boolean | undefined;
从 ts 文件中我们可以知道 lastModified 属性接收一个 boolean 值,true 表示开启,我们来尝试一下:
app.use('/public', express.static(__dirname + '/public', {
etag: false,
cacheControl: true,
lastModified: true,
maxAge: 10 * 1000,
}))
我们看看效果:
详细过程:
- 浏览器首次加载资源时向服务端发出资源请求,随后服务端响应资源,同时设置了
Expires和Last-Modified响应头,浏览器接收资源的同时会将这个 Response Header 缓存下来。 - 10 秒内再次加载该资源时,通过比对此前缓存的 Response Header 中
Expires的时间,判定命中了强缓存,会直接从浏览器缓存中加载资源,状态码 200,同时 Size 栏显示momory cache。 - 10 秒后加载该资源时,同样通过比对,发现超出过期时间,则判定强缓存失效,会重新向服务端发送资源请求,验证是否命中协商缓存。此次请求会将缓存的
Response Header中的Last-Modified值作为这次请求头If-Modified-Since的值,服务端接收请求后,将请求头中If-Modified-Since与所请求资源的最后修改时间比对,根据是否修改来判定是否过期 ,如果资源未修改则返回状态码 304 Not Modified,表示命中协商缓存;否则重新返回资源和 新的最后修改时间。
缺点
乍一看好像这样挺好的,没啥毛病,真的是这样吗?
为了方便测试协商缓存的缺点,我们直接关闭 cacheControl 强缓存,使用 Expires ,然后将强缓存设置为当前时间之前,如此一来强缓存会一直过期:
app.use('/public', express.static(__dirname + '/public', {
etag: false,
cacheControl: false,
lastModified: true,
maxAge: 0,
setHeaders: function (res, path, stat) {
res.append('Expires', new Date(new Date().getTime() - 15 * 1000));
}
}))
我们刚刚提到,lastModified 表示的是资源的最后修改时间,那么这个最后修改时间一定准确更新吗?
我们先右键属性看看 test.js 文件的最后修改时间:
时间是 22:21:06。再看看响应头中 Last-Modified 的值:
这里的时间是 GMT 时间 ,我们是在东八区,因此需要加上 8 小时,即 14:21:06 + 8:00 也就是 22:21:06。
这么看来最后修改时间是没问题的,接下来我们来测试。
这是此时 test.js 文件中的内容:
// test.js
console.log(123);
接下来我们进行修改,注意不要保存:
// test.js
console.log(1234);
然后再修改回去,保存。
接下来我们访问 localhost:3000/public/index.html:
我们惊讶的发现 test.js 的 文件内容没有修改 ,但是 Last-Modified 的值变了,并且状态码返回 200,我们再次刷新后状态码又变成 304,也就是命中了协商缓存。
有的小伙伴说:那你确实就是改了文件啊,“最后修改时间”变了不应该吗?
没错,逻辑没毛病,但是它已经不符合我们的需求了 —— 缓存资源 。我们想想为什么要缓存资源?因为它 内容没变动 ,所以我们不应该让服务器返回我们请求的资源的。test.js 的文件内容并没有变动,从需求上来看应该命中协商缓存的,然而没有 。这就是 Last-Modified 这个模式的弊端了 —— 不够准确。
除此之外,如果在极短时间内修改文件同时进行请求,那么“最后修改时间”也有可能没变。
ETag 和 If-None-Match
结论为先,ETag 会根据 文件的内容 + 文件修改时间 生成一个 hash 值。
这个和 Last-Modified 的判定逻辑差不多,不赘述了:
需要注意的是,由于 ETag 是根据 文件的内容 + 文件修改时间 生成的 hash 值,因此 Last-Modified 的缺点它也有 —— 文件内容不变但 ETag 的值变了,缓存失效。
但是起码它能解决 Last-Modified 的另一个缺点。
小结
- 强缓存未命中时,会进行协商缓存判定,如果协商缓存命中,则状态码为 304 Not Modified。协商缓存未命中,则返回资源,同时更新对应的响应头。
Last-Modified是根据请求头中If-Modified-Since的值与资源的最后修改时间来判定是否命中协商缓存的,容易误判。ETag是根据请求头中If-None-Match的值与资源生成的hash值来判定是否命中协商缓存的。
流程图
结束语
本文就到此结束了,相信这么一番操作后,大家对浏览器缓存的理解更加深刻了。
如果小伙伴们有别的想法,欢迎留言,让我们共同学习进步💪💪。
如果文中有不对的地方,或是大家有不同的见解,欢迎指出🙏🙏。
如果大家觉得所有收获,欢迎一键三连💕💕。