这篇文章上次修改于 183 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
这是进击的前端项目之性能优化系列的
第一篇
。进击的前端这个项目目的在于构建大前端的体系化知识,感兴趣的话可以给个
Star
关注一下这个项目
写在前面:
为什么要使用缓存?
下面这张图说的很清楚了:
关于前端的网络缓存的原理在网上一搜一大把,但是大多数都是很理论化的知识,并没有相应的实现。虽然这是前端的浏览器缓存知识,但是要实现的话,却是由我们服务端来做。作为一个合格的大前端,我们并不能只局限于做做页面或者看看浏览器控制台的信息,我们应当要对网络的具体交互和实现有个深入的了解,不然开发一个东西,我们却不知道底层或者实现的原理,那岂不是行于万丈高空之中?而且实现这个软件或者项目只是代表着这个项目的开始,不断地优化、迭代才是这个项目的核心或者说主要发展基调。笔者将在网络缓存这几篇文章中先从理论入手,然后以Node
为服务端开发技术为大家展示实践。
知识大纲
之前在社区看其他前辈的面经的时候,发现大多数人问道前端缓存都认为只有HTTP 缓存
。其实这是不对的,HTTP缓存
仅仅只是我们浏览器缓存的一部分。通过缓存位置来划分的话,其实有四个部分(优先级从上往下):
- Memory Cache
- Service Worker Cache
- Disk Cache
- Push Cache
这一节,笔者阐述的是日常开发较常使用的HTTP缓存机制
(以下为这一节内容的思维导图)
HTTP缓存机制
HTTP缓存
机制分为两种:强缓存
和协商缓存
。强缓存
的优先级要高于协商缓存
,也就是说HTTP缓存
中先走的强缓存
,当强缓存
未命中时才会走协商缓存
。
强缓存
强缓存是通过HTTP Header
中的expires
和cache-control
两个字段实现。如果命中强缓存的话,浏览器在对于该资源不会再向服务端通信。强缓存会存在我们disk cache(磁盘缓存)
或者memory cache(内存缓存)
下。
我们先来看看掘金
首页的网络请求
可以看到很多资源都是走的缓存获取的。
expires
在早期,也就是HTTP 1.0
时代,用的都是expires
实现的。在我们的响应头中将缓存过期时间写入expires
。
可看到我们这个test.js
文件状态码是200
而且显示是来自memory cache
(也就是内存缓存),同时可以注意到响应头中的expires
的值是一个缓存过期时间(笔者写文时间是2020.10.17
,过期时间我设置为了2020.10.25
,也就是说过期时间后,缓存中将不会再有该文件,强缓存不再命中)
所以想要通过expires
实现强缓存是非常简单的,不就是在响应头中加上expires
字段然后设置一个过期时间放到expires
中即可。以下是笔者通过Node
实现的过程,需要设置强缓存的是我们的test.js
。
首先是我们要访问的文件:
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>123</div>
<script src="./test.js"></script>
</body>
</html>
// test.js
alert('ok');
我们的http server
实现:
// server.js
/**
* @description http server
* @author Uni
*/
// 引入相关模块
const http = require('http');
const fs = require('fs');
// 构建 http server 句柄
const app = http.createServer(handleServer);
// 绑定端口以及启动监听
app.listen(3000, () => {
console.log('server run...')
})
/**
* @description http server 控制层实现
* @param {Object} req 请求对象
* @param {Objetc} res 响应对象
*/
function handleServer(req, res) {
const method = req.method;
const path = req.url;
const filePath = __dirname;
if (method === 'GET') {
if (path === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
});
fs.readFile(filePath + '/index.html', (err, data) => {
// 错误处理
err && console.error(err);
res.end(data);
})
}
if (path === '/test.js') {
res.writeHead(200, {
'Content-Type': 'text/javascript',
// 设置 expires
'Expires': new Date('2020-10-18')
})
fs.readFile(filePath + '/test.js', (err, data) => {
err && console.log(err);
res.end(data);
})
}
}
if (method === 'POST') {
// some service...
}
// some other processing
}
实现效果:
第一次请求是有test.js
资源大小显示的,第二次请求以及之后的请求都是显示disk cache
(磁盘缓存)。
强缓存已经成功实现。
在这里笔者想介绍一下笔者最近迷上的一种错误处理的写法,很简洁感觉,目前没有发现什么副作用,可以看到server.js
异步读取文件的回调函数中有这样一段:
err && console.log(err);
这是通过逻辑与(&&
)实现的错误打印,如果存在错误则打印错误。我这里甚至可以将console.log(err)
换成一个特定的错误处理函数的调用,非常优雅。
这种写法不光比if语句
要简洁许多(当然我没说这种写法可以替代if语句
),而且它能够起到一个调用保护
的作用。
比如Redux DevTools Extension
中有这么一个语句:
这句话的意思是如果window
中有__REDUX_DEVTOOLS_EXTENSION__
则调用,如果没有则无操作。
很优雅,很喜欢。
cache-control
回到expires
,如果心细同学应该会发现expires
有个弊端,那就是expires
设置的过期时间是我们服务端设置时间,但是查找缓存是是客户端对比客户端当前时间查找的,如果客户端时间更改(比如手动更改时间到过期时间过后)那么强缓存便可能会不命中。而且如果服务端文件发生了变更,但在过期时间内,客户端是不会请求更改后的文件。
所以HTTP 1.1
标准引入的cache-control
对这点进行了改进,允许配置一个相对时间。通过cache-control
中的max-age
字段设置相对时间戳。比如max-age
设置10000
秒,那么在客户端首次加载这个资源的10000
秒内都是命中强缓存的。
我们将我们的http server
的逻辑更改一下(注意,先清一下缓存,毕竟在过期时间内,强缓存是不会知道我们服务端更改了资源内容的):
100秒后我们重新刷新发现客户端又重新向服务端请求这段js
资源,然后继续为其设置一个相对时间戳
cache-control
不光只有max-age
这个字段,还有s-maxage
、no-cache
、no-store
这三个字段。
s-maxage:s-maxage 优先级高于 max-age,两者同时出现时,优先考虑 s-maxage。如果 s-maxage 未过期,则向代理服务器请求其缓存内容。注意,s-maxage仅在代理服务器中生效,客户端中我们只考虑max-age
no-cache:no-cache 绕开了浏览器,我们为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期。
no-store:设置了这个即代表不采用缓存策略。
协商缓存
协商缓存依赖于服务端与浏览器之间的通信,这是与强缓存之间最大的区别。
刚刚我们通过cache-control
解决了强缓存绝对时间戳的问题以及增加了其他缓存配置,但是我们一直没实现资源如果未改动,则无需请求,只有改动后再请求这一优化。这是就要用到我们的协商缓存,浏览器先想服务器询问缓存的相关信息,进而判断是重新发起请求、加载资源,还是直接使用客户端缓存的资源。
如果服务端觉得缓存资源没有被改动,资源请求会被重定向到浏览器缓存,响应状态码是304 Not Modified
Last-Modified/if-Modified-Since
Last-Modified
是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着响应头
返回。
随后我们每次请求时,会带上一个叫 If-Modified-Since
的时间戳字段,它的值正是上一次响应头返回给它的 Last-Modified
值。
我们来用这种方式实现一下协商缓存(以提前将请求路径改为/cache.js
)
//server.js
if (path === '/cache.js') {
// 获取文件状态
const filePath = __dirname + '/test.js';
const stat = fs.statSync(filePath);
const mtime = stat.mtime.toGMTString();
const reqMtime = req.headers['if-modified-since'];
//协商缓存触发,文件未修改
if (mtime === reqMtime) {
res.statusCode = 304;
res.end();
return;
}
// 文件修改,重新请求,设置协商缓存
res.writeHead(200, {
'Content-Type': 'text/javascript',
'Last-Modified': mtime,
})
fs.readFile(filePath, (err, data) => {
err && console.error(err);
res.end(data);
})
}
来看看效果:
可以发现,我们虽然设置了协商缓存,但是走的还是强缓存,但是我们没有设置强缓存呀?为什么这样?
这是因为浏览器默认启用了一个启发式缓存,这在设置了 Last-Modified 响应头且没有设置 Cache-Control: max-age/s-maxage 或 Expires 时会触发,它的一个缓存时间是用 Date - Last-Modified 的值的 10% 作为缓存时间
我们来修改一下代码:
// server.js
res.writeHead(200, {
'Content-Type': 'text/javascript',
'Last-Modified': mtime,
'Cache-Control': 'max-age=0' // 修改地方
})
协商缓存成功实现!
ETag
细心的同学可能会发现一个问题,Last-Modified
是通过判断文件的状态也就是编辑时间进行判断,如果我们文件重新编辑了但是内容没有发生变化的话,仍会重新请求资源,仍达不到一个性能优化的效果。
为了解决这个问题,ETag
出现了。Etag
是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag
就是不同的,反之亦然。因此 Etag
能够精准地感知文件的变化。Etag
和 Last-Modified
类似,当首次请求时,我们会在响应头里获取到一个最初的标识符字符串,下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match
的字符串供服务端比对了
我们来实现一下:
通过代码我们也可以看出,ETag
会造成很多服务器的开销,所以ETag
不能替代Last-Modified
,一般我们是同时使用的。Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。
没有评论