这篇文章上次修改于 183 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

这是进击的前端项目之性能优化系列的第一篇

进击的前端这个项目目的在于构建大前端的体系化知识,感兴趣的话可以给个Star关注一下这个项目

写在前面:

为什么要使用缓存?

下面这张图说的很清楚了:

0OeKT1.png

关于前端的网络缓存的原理在网上一搜一大把,但是大多数都是很理论化的知识,并没有相应的实现。虽然这是前端的浏览器缓存知识,但是要实现的话,却是由我们服务端来做。作为一个合格的大前端,我们并不能只局限于做做页面或者看看浏览器控制台的信息,我们应当要对网络的具体交互和实现有个深入的了解,不然开发一个东西,我们却不知道底层或者实现的原理,那岂不是行于万丈高空之中?而且实现这个软件或者项目只是代表着这个项目的开始,不断地优化、迭代才是这个项目的核心或者说主要发展基调。笔者将在网络缓存这几篇文章中先从理论入手,然后以Node为服务端开发技术为大家展示实践。

知识大纲

之前在社区看其他前辈的面经的时候,发现大多数人问道前端缓存都认为只有HTTP 缓存。其实这是不对的,HTTP缓存仅仅只是我们浏览器缓存的一部分。通过缓存位置来划分的话,其实有四个部分(优先级从上往下):

  • Memory Cache
  • Service Worker Cache
  • Disk Cache
  • Push Cache

这一节,笔者阐述的是日常开发较常使用的HTTP缓存机制(以下为这一节内容的思维导图)

0L4yE8.png

HTTP缓存机制

HTTP缓存机制分为两种:强缓存协商缓存强缓存的优先级要高于协商缓存,也就是说HTTP缓存中先走的强缓存,当强缓存未命中时才会走协商缓存

强缓存

强缓存是通过HTTP Header中的expirescache-control两个字段实现。如果命中强缓存的话,浏览器在对于该资源不会再向服务端通信。强缓存会存在我们disk cache(磁盘缓存)或者memory cache(内存缓存)下。

我们先来看看掘金首页的网络请求

0L6hGt.png

可以看到很多资源都是走的缓存获取的。

expires

在早期,也就是HTTP 1.0时代,用的都是expires 实现的。在我们的响应头中将缓存过期时间写入expires

0LIGfe.png

可看到我们这个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(磁盘缓存)。

0Oirq0.png

强缓存已经成功实现。

在这里笔者想介绍一下笔者最近迷上的一种错误处理的写法,很简洁感觉,目前没有发现什么副作用,可以看到server.js异步读取文件的回调函数中有这样一段:

err && console.log(err);

这是通过逻辑与(&&)实现的错误打印,如果存在错误则打印错误。我这里甚至可以将console.log(err)换成一个特定的错误处理函数的调用,非常优雅。

这种写法不光比if语句要简洁许多(当然我没说这种写法可以替代if语句),而且它能够起到一个调用保护的作用。

比如Redux DevTools Extension中有这么一个语句:

0OFgmt.png

这句话的意思是如果window中有__REDUX_DEVTOOLS_EXTENSION__则调用,如果没有则无操作。

很优雅,很喜欢。

cache-control

回到expires,如果心细同学应该会发现expires有个弊端,那就是expires设置的过期时间是我们服务端设置时间,但是查找缓存是是客户端对比客户端当前时间查找的,如果客户端时间更改(比如手动更改时间到过期时间过后)那么强缓存便可能会不命中。而且如果服务端文件发生了变更,但在过期时间内,客户端是不会请求更改后的文件。

所以HTTP 1.1标准引入的cache-control对这点进行了改进,允许配置一个相对时间。通过cache-control中的max-age字段设置相对时间戳。比如max-age设置10000秒,那么在客户端首次加载这个资源的10000秒内都是命中强缓存的。

我们将我们的http server的逻辑更改一下(注意,先清一下缓存,毕竟在过期时间内,强缓存是不会知道我们服务端更改了资源内容的):

0OVMu9.png

0OVrUP.png

100秒后我们重新刷新发现客户端又重新向服务端请求这段js资源,然后继续为其设置一个相对时间戳

0OVOKJ.png

cache-control不光只有max-age这个字段,还有s-maxageno-cacheno-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);
            })
        }     

来看看效果:

0O19i9.png

可以发现,我们虽然设置了协商缓存,但是走的还是强缓存,但是我们没有设置强缓存呀?为什么这样?

这是因为浏览器默认启用了一个启发式缓存,这在设置了 Last-Modified 响应头且没有设置 Cache-Control: max-age/s-maxage 或 Expires 时会触发,它的一个缓存时间是用 Date - Last-Modified 的值的 10% 作为缓存时间

0O16wF.png

我们来修改一下代码:

// server.js
res.writeHead(200, {
    'Content-Type': 'text/javascript',
    'Last-Modified': mtime,
    'Cache-Control': 'max-age=0' // 修改地方
})  

0O8EUe.png

协商缓存成功实现!

ETag

细心的同学可能会发现一个问题,Last-Modified是通过判断文件的状态也就是编辑时间进行判断,如果我们文件重新编辑了但是内容没有发生变化的话,仍会重新请求资源,仍达不到一个性能优化的效果。

为了解决这个问题,ETag出现了。Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。Etag Last-Modified 类似,当首次请求时,我们会在响应头里获取到一个最初的标识符字符串,下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match 的字符串供服务端比对了

我们来实现一下:

0OY8UO.png

通过代码我们也可以看出,ETag会造成很多服务器的开销,所以ETag不能替代Last-Modified,一般我们是同时使用的。Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。