HTTP 缓存动手实践

2021.11.22

先考虑一个问题,就是为什么需要缓存?

缓存有下面几个好处,

  1. 提升页面加载速度。不变的资源可以直接使用本地缓存,不用去服务器重新获取。
  2. 减少服务器负载。因为客户端使用缓存,更少的请求到服务器了。

下面就来介绍两种缓存策略,强制缓存协商缓存

强制缓存

在缓存的有效期内使用缓存,过期的时候从服务器重新获取资源。对于一些不怎么会变动的文件,我们推荐使用强制缓存,当变动的时候我们可以配合协商缓存。

我们先本地启动一个服务器来测试下,demo 给你写好了,clone 下来, npm start 就可以进行测试了。本来是想使用 express 框架来写的,但是发现,会默认加几个请求/响应头,而且弄来弄去还去不掉,就用 node 的 built-in 的 http 模块了,零依赖。

项目启动起来后,什么缓存的请求头/响应头都没有配,所以,每次请求都直接从本地服务器获取。

刷新好几次的结果,都没有缓存
刷新好几次的结果,都没有缓存

Expires

第一种缓存头,服务器可以在响应资源的时候设置一个 Expires 响应头来对资源进行缓存,给定一个资源都有效期,浏览器在第一次请求之后,在某某时间之前(Expires 的值),都会直接从缓存获取资源。

测试:我们在服务器响应的时候设置了一个比较靠后的时间,Wed, 21 Oct 2022 07:28:00 GMT ,在第一次请求之后继续刷新看会不会从缓存读取。

我们再次请求的时候,发现系统时间还没到 Expires 指定的缓存失效时间,所以浏览器会直接从缓存读取。

当然这个请求头也有一定的问题,如果系统时间和服务器时间不同步,如果系统设置了一个很后的时间,那该过期的时候就不会过期了(当然一般这样浏览器会报时间相关的错误,比如时钟过快,如果访问 https 资源的话)。

所以就出现了另一个响应头, Cache-Control,它使用的是相对时间,就解决了系统时间和服务器时间不同步的问题。

Cache-Control

主要使用 max-age 值来指定缓存有效时间,当然这个 HTTP header 还有一些其他的用法。

测试:我们在服务器响应的时候,设置一个 Cache-Control 的响应头,值为 max-age=10,也就是 10秒的有效期,然后我们在请求完成后再次刷新页面,看浏览器是否使用了缓存。如下图所示,浏览器使用了缓存(10秒过后,浏览器就会重新向浏览器获取资源)。

但无论使用 Expires 还是 Cache-Control max-age 进行强制缓存,都会有一个问题,“就是当缓存时间到了需要去服务器重新获取资源的时候,如果服务器上的该资源没有被改动,那么,我们这次向服务器重新获取资源不就是浪费的吗?直接使用本地缓存不就行了吗?”

这里我们就可以配合 协商缓存 来继续优化我们的缓存策略。协商缓存,顾名思义就是和服务器协商,要不要继续使用浏览器的缓存。

协商缓存

Last-Modified

当强制缓存时间到的时候,我们需要向服务器确认我们手上的资源是否被修改过,是的话重新从服务器上获取该资源即可,不是的话,需要一种方式告诉浏览器该资源还是新的,可以继续使用缓存,并且服务器直接在 response 返回空就行了,节省带宽。

对,Last-Modified 响应头就该出场了。我们在请求资源的时候,服务器在响应头里加上 Last-Modified 用来标志资源的上次修改时间,这样当缓存过期的时候,浏览器再次发送请求带上 If-Modified-Since 这个请求头(这个请求头就是 Last-Modified 的值),服务器对比该请求头的值和服务上该资源的最新修改时间来决定是否继续使用本地缓存。

  fs.readFile(filePath, function (error, content) {
    // res.removeHeader("Cache-Control");
    if(error) return;
    res.writeHead(200, {
      "Content-Type": contentType,
      // Expires: "Wed, 21 Oct 2022 07:28:00 GMT",
      // "Cache-Control": "public,max-age=10",
    });
    let status = fs.statSync(filePath);

    let lastModified = status.mtime.toUTCString();
    if (lastModified === req.headers["if-modified-since"]) {
      res.writeHead(304, "Not Modified");
      res.end();
    } else {
      res.writeHead(200, "OK");
      res.writeHead(200, {
        "Cache-Control": "public,max-age=10",
        "Last-Modified": lastModified,
      });
      res.end(content);
    }
  });

首次请求(10秒有效期,文件的最新修改时间也已经知道)

再次请求(因为还在缓存时间内 10秒,所以直接使用缓存)

等待 10s 等缓存过期,再次请求,因为服务器上这个文件一直没有动过,对比服务器上该文件的最新修改时间和请求头里的上次修改时间,发现时间一致,即文件没有改动,返回 304 告诉浏览器可以继续使用缓存,

不过这个响应头使用的时候还是会有一个问题,时间精度问题

Last-Modified 只精确到了秒,如果 1秒内该文件发生了很多次的改动,那么,你会拿不到最新的数据。比如你在 Tue, 23 Nov 2021 02:20:26:111 的时候做了一次变更,在 02:20:26:123 的时候又做了一次变更,那么因为 Last-Modified 的比较只是精确到秒,所以无论是不是第一次比较 02:20:26 的时间,都只会返回 02:20:26 毫秒数最小的那个文件。

switch (extname) {
    case ".js":
      contentType = "text/javascript";
	  // demo.js 会在每次请求的时候写入当前时间到文件中(毫秒级)
      fs.writeFileSync(filePath, new Date().toISOString(), "utf-8");
      break;
  }

  fs.readFile(filePath, function (error, content) {
    // res.removeHeader("Cache-Control");
    if (error) return;
    res.writeHead(200, {
      "Content-Type": contentType,
      // Expires: "Wed, 21 Oct 2022 07:28:00 GMT",
      // "Cache-Control": "public,max-age=10",
    });
    let status = fs.statSync(filePath);
    let lastModified = status.mtime.toUTCString();
    if (lastModified === req.headers["if-modified-since"]) {
      res.writeHead(304, "Not Modified");
      res.end();
    } else {
      res.writeHead(200, "OK");
      res.writeHead(200, {
        "Cache-Control": "public,max-age=0",
        "Last-Modified": lastModified,
      });
      res.end(content);
    }
  });

测试:首先将缓存有效期设置为 0 秒(等同于 Cache-Control: public,no-cache,并且注意 “no-cache” 的作用是不是用本地缓存而不是不缓存),这样每次请求浏览器会直接向服务器获取资源并写入到本地缓存,然后连续刷新好几次页面,你会发现,如果两次刷新发生在某一秒内,那么在该某一秒内的最新的资源变更你就拿不到了,

上面就是一秒内刷新了两次出现的情况,我们设置了每次请求 demo.js 文件都会发生改动,但是由于两次刷新是在 1 秒内完成的,所以当缓存时间到的时候,浏览器发起协商缓存,请求是 200,因为文件变动了,然后 1秒内的再次请求将会返回 304,因为在秒级上做时间对比是通过的,所以服务器 304 告诉浏览器使用本地缓存。

当然这个也是可以解决的,就是下面的 ETag

ETag

时间会有精度问题,那么每次写上我们直接基于文件内容(hash 文件内容对比 hash 即可),不基于时间不就可以了,这就是 ETag 做的事情,它和 Last-Modified 作用一样,你理解上就换了个字段就行。

每次请求文件的时候,响应头会带上表示文件内容 hash 的 ETag 响应头,在需要协商缓存的时候,请求头里加一个 If-None-Match 字段,值是该文件的 ETag 值(一般浏览器会帮你自动带上)。

let app = http.createServer((req, res) => {
  var filePath = "." + req.url;
  if (filePath == "./") filePath = "./index.html";
  var extname = path.extname(filePath);
  var contentType = "text/html";
  switch (extname) {
    case ".js":
      contentType = "text/javascript";
      fs.writeFileSync(filePath, new Date().toISOString(), "utf-8");
      break;
  }

  fs.readFile(filePath, function (error, content) {
    if (error) return;
    res.writeHead(200, {
      "Content-Type": contentType,
    });
    let etag = hash(content);
    if (req.headers["if-none-match"] === etag) {
      res.writeHead(304, "Not Modified");
      res.end();
    } else {
      res.writeHead(200, {
        "Cache-Control": "public,max-age=0",
        ETag: etag,
      });
      res.end(content);
    }
  });
});

还是同样的操作,1秒内多次请求,可以看到第 4 次和第 5 次刷新是发生在 1秒内的,然后你会发现虽然这两条数据都在同 1 秒,但和 Last-Modified 不同的是,2 个响应的响应内容不同,而且也绝不会相同。

第 4 次请求 demo.js 文件的时候,服务器会在响应头里该文件的 Etag 也返回回来,当我们发出第 5 次请求的时候,我们向服务器发起了协商缓存(因为我们设置了 max-age=0),并在请求头上带上了 If-None-Match 的请求头,值是第 4 次请求的 ETag,服务器对服务器上的 demo.js 的 hash(ETag 值)和 If-None-Match 的值进行了对比,因为我们每次请求 demo.js 的内容都会改变,所以这次对比就会就发现文件发生了变动,于是就返回 200,并在响应头里加上了新文件的 ETag 值。

If-None-Match 的意思其实也很好理解,服务器会返回新的文件需要满足一个条件,当且仅当你提供的所有的(none) ETag 都没有命中服务器上的那个资源的 ETag 的值。

到此 2 种缓存模式就介绍完了。

其他

强制缓存和协商缓存的优先级

let app = http.createServer((req, res) => {
  var filePath = "." + req.url;
  if (filePath == "./") filePath = "./index.html";
  var extname = path.extname(filePath);
  var contentType = "text/html";
  switch (extname) {
    case ".js":
      contentType = "text/javascript";
      fs.writeFileSync(filePath, new Date().toISOString(), "utf-8");
      break;
  }

  fs.readFile(filePath, function (error, content) {
    if (error) return;
    res.writeHead(200, {
      "Content-Type": contentType,
    });
    let etag = hash(content);
    if (req.headers["if-none-match"] === etag) {
      res.writeHead(304, "Not Modified");
      res.end();
    } else {
      res.writeHead(200, {
        "Cache-Control": "public,max-age=10",
        ETag: etag,
      });
      res.end(content);
    }
  });
});

可以看到,在一个缓存有效期内(10秒),即便文件变了(目前每次请求都会重新生成 demo.js 的文件内容),浏览器也不会从服务器获取资源,而是直接使用缓存。

所以,强缓存的优先级大于协商缓存

前端缓存策略的运用

看到一种属于强缓存的缓存策略,在 Revved resources 这部分里介绍了,“按照一定规则给资源命名,按资源的 URI 进行缓存”。

比方说,我们的 html 页面里引用了某个 js 文件,这个 js 文件是按照版本号进行命名的,每次请求 html 页面,后端 vm 模版会动态写入这个 js 文件的 CDN 地址,这个 js 文件(设置为永远不会过期,而且只有发布版本的时候,这个 js 文件的 CDN 地址才会更新)。

我们有一套发布系统,发布到正式环境后,生成的 CDN 文件地址就不能修改了,这样我们可以永远使用这个文件的缓存,也不用担心文件缓存失效。

参数

  1. ETag - MDN
  2. Expires - MDN
  3. HTTP caching - MDN
  4. If-None-Match - MDN
  5. If-Modified-Since - MDN
  6. Why-is-http-request-header-if-none-match-called-that
  7. Learn http cache by Hand
  8. Refresh all tabs in Google Chrome
  9. how-to-write-files-in-nodejs
  10. Nodejs api
  11. How do I create a HTTP server?
  12. CDN caching - Cloudflare