前言

HTTP 缓存机制作为 Web 应用性能优化的重要手段,对于从事 Web 开发的同学们来说,应该是知识体系的基础环节,也是想要成为前端架构的必备技能。

缓存的作用

我们为什么使用缓存,是因为缓存可以给我们的 Web 项目带来以下好处,以提高性能和用户体验。

  • 加快了浏览器加载网页的速度;
  • 减少了冗余的数据传输,节省网络流量和带宽;
  • 减少服务器的负担,大大提高了网站的性能。

由于从本地缓存读取静态资源,加快浏览器的网页加载速度是一定的,也确实的减少了数据传输,就提高网站性能来说,可能一两个用户的访问对于减小服务器的负担没有明显效果,但如果这个网站在高并发的情况下,使用缓存对于减小服务器压力和整个网站的性能都会发生质的变化。

缓存规则简介

为了方便理解,我们认为浏览器存在一个缓存数据库,用于存储缓存信息(实际上静态资源是被缓存到了内存和磁盘中),在浏览器第一次请求数据时,此时缓存数据库没有对应的缓存数据,则需要请求服务器,服务器会将缓存规则和数据返回,浏览器将缓存规则和数据存储进缓存数据库。


缓存流程图
缓存流程图


当浏览器地址栏输入地址后请求的 index.html 是不会被缓存的,但 index.html 内部请求的其他资源会遵循缓存策略,HTTP 缓存有多种规则,根据是否需要向服务器发送请求主要分为两大类,强制缓存和协商缓存。

强制缓存

强制缓存流程

强制缓存是第一次访问服务器获取数据后,在有效时间内不会再请求服务器,而是直接使用缓存数据,强制缓存的流程如下。


强制缓存流程图
强制缓存流程图


强制缓存判断到期时间

那么如何判断缓存是否到期呢?其实还是根据第一次访问时服务器的响应头来实现的,在 HTTP 1.0 版本和 HTTP 1.1 版本有所不同。

  • HTTP 1.0 版本,服务器使用的响应头字段为 Expires,值是将来的绝对时间(时间戳),浏览器请求时的当前时间超过了 Expires 设置的时间,代表缓存失效,需要再次向服务器发送请求,否则都会直接从缓存数据库中获取数据。
    -
  • HTTP 1.1 版本,服务器使用的响应头字段为 Cache-Control,有多个值,意义各不相同。
    • private:客户端可以缓存;
    • public:客户端和代理服务器都可以缓存(对于前端而言,可以认为与 private 效果相同);
    • max-age=xxx:缓存的内容将在 xxx 秒后过期(相对时间,秒为单位);
    • no-cache:需要使用协商缓存(后面介绍)来验证数据是否过期;
    • no-store:所有内容都不会缓存,强制缓存和协商缓存都不会触发。

Cache-Control 的值中最常用的为 max-age=xxx,缓存本身就是为了数据传输的优化和性能而存在的,所以 no-store 很少使用。

注意:在 HTTP 1.0 版本中,Expires 字段的绝对时间是从服务器获取的,由于请求需要时间,所以浏览器的请求时间与服务器接收到请求所获取的时间是存在误差的,这也导致了缓存命中的误差,在 HTTP 1.1 版本中,因为 Cache-Control 的值 max-age=xxx 中的 xxx 是以秒为单位的相对时间,所以在浏览器接收到资源后开始倒计时,规避了 HTTP 1.0 中缓存命中存在误差的缺点,为了兼容低版本 HTTP 协议,两种响应头会同时使用,HTTP 1.1 版本优先级高于 HTTP 1.0

通过 Network 查看强制缓存

我们通过 Chrome 浏览器的开发者工具,打开 NetWork 查看强制缓存的相关信息。


缓存响应头
缓存响应头


上面是百度网站 Logo 图片的响应,我们可以清楚的看到,其中兼容了 HTTP 1.0HTTP 1.1 版本,并使用强制缓存存储了 10 年。

下面看一看通过缓存取出的数据在 Network 中与其他资源的区别。


命中缓存
命中缓存


其实缓存的储存是内存和磁盘两个位置,由当前浏览器本身的策略决定,比较随机,从内存的缓存中取出的数据会显示 (from memory cache),从磁盘的缓存中取出的数据会显示 (from disk cache)

Node.js 服务器实现强制缓存

/* 强制缓存 */
const http = require('http');
const url = require('url');
const path = require('path');
const mime = require('mime');
const fs = require('fs');

const server = http.createServer((req, res) => {
  let { pathname } = url.parse(req.url, true);
  pathname = pathname !== '/' ? pathname : '/index.html';

  // 获取读取文件的绝对路径
  const filePath = path.join(__dirname, pathname);

  // 查看路径是否合法
  fs.access(filePath, err => {
    // 路径不合法则直接中断连接
    if (err) return res.end('Not Found');

    // 设置强制缓存
    res.setHeader('Expires', new Date(Date.now() + 30000).toGMTString());
    res.setHeader('Cache-Control', 'max-age=30');

    // 设置文件类型并响应给浏览器
    res.setHeader('Content-Type', mime.getType(filePath) + ';charset=utf8');
    fs.createReadStream(filePath).pipe(res);
  });
});

server.listen(3000, () => {
  console.log('server start 3000');
});

上面 mime 模块的 getType 方法可以成功返回传入路径下文件对应的文件类型,如 text/htmlapplication/javascript 等,是第三方模块,使用之前需要安装。

$ npm install mime

协商缓存

协商缓存流程

协商缓存又叫对比缓存,设置协商缓存后,第一次访问服务器获取数据时,服务器会将数据和缓存标识一起返回给浏览器,客户端会将数据和标识存入缓存数据库中,下一次请求时,会先去缓存中取出缓存标识发送给服务器进行询问,当服务器数据更改时会更新标识,所以服务器拿到浏览器发来的标识进行对比,相同代表数据未更改,响应浏览器通知数据未更改,浏览器会去缓存中获取数据,如果标识不同,代表服务器更改过数据,所以会将新的数据和新的标识返回浏览器,浏览器会将新的数据和标识存入缓存中,协商缓存的流程如下。


协商缓存流程图
协商缓存流程图


协商缓存和强制缓存不同的是,协商缓存每次请求都需要跟服务器通信,而且命中缓存服务器返回状态码不再是 200,而是 304

协商缓存判断标识

强制缓存是通过过期时间来控制是否访问服务器,而协商缓存每次都要与服务器交互对比缓存标识,同样的,对于协商缓存的实现在 HTTP 1.0 版本和 HTTP 1.1 版本也有所不同。

HTTP 1.0 版本中,服务器通过 Last-Modified 响应头来设置缓存标识,通常取请求数据的最后修改时间(绝对时间)作为值,而浏览器将接收到返回的数据和标识存入缓存,再次请求会自动发送 If-Modified-Since 请求头,值为之前返回的最后修改时间(标识),服务器取出 If-Modified-Since 的值与数据的上次修改时间对比,如果上次修改时间大于了 If-Modified-Since 的值,说明被修改过,则通过 Last-Modified 响应头返回新的最后修改时间和新的数据,否则未被修改,返回状态码 304 通知浏览器命中缓存。

HTTP 1.1 版本中,服务器通过 Etag 响应头来设置缓存标识(唯一标识,像一个指纹一样,生成规则由服务器来决定),浏览器接收到数据和唯一标识后存入缓存,下次请求时,通过 If-None-Match 请求头将唯一标识带给服务器,服务器取出唯一标识与之前的标识对比,不同,说明修改过,返回新标识和数据,相同,则返回状态码 304 通知浏览器命中缓存。

HTTP 协商缓存策略流程图如下:


缓存策略流程图
缓存策略流程图


注意:使用协商缓存时 HTTP 1.0 版本还是不太靠谱,假设一个文件增加了一个字符后又删除了,文件相当于没更改,但是最后修改时间变了,会被当作修改处理,本应该命中缓存,服务器却重新发送了数据,因此 HTTP 1.1 中使用的 Etag 唯一标识是根据文件内容或摘要生成的,保证了只要文件内容不变,则一定会命中缓存,为了兼容低版本 HTTP 协议,开发中两种响应头也会同时使用,同样 HTTP 1.1 版本的实现优先级高于 HTTP 1.0

通过 Network 查看协商缓存

我们同样通过 Chrome 浏览器的开发者工具,打开 NetWork 查看协商缓存的相关信息。

再次请求服务器的请求头信息:


再次请求服务器的请求头信息
再次请求服务器的请求头信息


命中协商缓存的响应头信息:


命中协商缓存的响应头信息
命中协商缓存的响应头信息


下面看一看通过协商缓存取出的数据在 Network 中与第一次加载的区别。

第一次请求:


第一次请求
第一次请求


缓存后请求:


缓存后请求
缓存后请求


通过两图的对比,我们可以发现,协商缓存生效时的状态码为 304,并且报文大小和请求时间大大减少,原因是服务端在进行标识比对后只返回了 header 部分,通过状态码来通知浏览器使用缓存,不再需要将报文主体部分一起返回给浏览器。

Node.js 服务器实现协商缓存

/* 协商缓存 */
const http = require('http');
const url = require('url');
const path = require('path');
const mime = require('mime');
const fs = require('fs');0
const crytpo = require('crytpo');

const server = http.createServer((req, res) => {
  let { pathname } = url.parse(req.url, true);
  pathname = pathname !== '/' ? pathname : '/index.html';

  // 获取读取文件的绝对路径
  const filePath = path.join(__dirname, pathname);

  // 查看路径是否合法
  fs.stat(filePath, (err, statObj) => {
    // 路径不合法则直接中断连接
    if (err) return res.end('Not Found');

    const md5 = crypto.createHash('md5'); // 创建加密的转换流
    const rs = fs.createReadStream(filePath); // 创建可读流

    // 读取文件内容并加密
    rs.on('data', data => md5.update(data));

    rs.on('end', () => {
      const ctime = statObj.ctime.toGMTString(); // 获取文件最后修改时间
      const flag = md5.digest('hex'); // 获取加密后的唯一标识

      // 获取协商缓存的请求头
      const ifModifiedSince = req.headers['if-modified-since'];
      const ifNoneMatch = req.headers['if-none-match'];

      if (ifModifiedSince === ctime || ifNoneMatch === flag) {
        res.statusCode = 304;
        res.end();
      } else {
        // 设置协商缓存
        res.setHeader('Last-Modified', ctime);
        res.setHeader('Etag', flag);

        // 设置文件类型并响应给浏览器
        res.setHeader('Content-Type', mime.getType(filePath) + ';charset=utf8');
        rs.pipe(res);
      }
    });
  });
});

server.listen(3000, () => {
  console.log('server start 3000');
});

在上面的代码中是通过可读流读取文件内容,并通过 crypto 模块进行了 md5 加密后的结果作为了唯一标识,这样就能保证只要文件内容不变,就会命中缓存,其中兼容了 HTTP 1.0HTTP 1.1 两个版本,只要满足一个则直接返回 304 通知浏览器命中缓存。

注意:其实读取文件内容加密这种做法并不可取,假如读取的是大文件,在读取文件内容和进行 md5 加密这个过程会非常消耗时间,所以在开发中要针对业务的实际情况选择可以保证服务器性能的方式生成唯一标识,比如根据文件的摘要。

总结

为了使缓存策略更加健壮、灵活,HTTP 1.0 版本 和 HTTP 1.1 版本的缓存策略会同时使用,甚至强制缓存和协商缓存也会同时使用,对于强制缓存,服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接使用缓存,超出有效时间,执行协商缓存策略,对于协商缓存,将缓存信息中的 EtagLast-Modified 通过请求头 If-None-MatchIf-Modified-Since 发送给服务器,由服务器校验同时设置新的强制缓存,校验通过并返回 304 状态码时,浏览器直接使用缓存,如果协商缓存也未命中,则服务器重新设置协商缓存的标识。