合理缓存单页面应用

Angular、React 和 Vue 的大流行导致现在越来越多的 Web 项目以单页面应用(SPA)的形式进行发布。

在前端文件的缓存方面,由于我早先是使用 PHP + Riot.js 的组合,虽然也是单页面应用,但主入口由 PHP 提供,默认就不缓存,所以只需在 nginx 中将所有静态文件强制缓存到客户端就行。

而换到 Node.js + Vue 之后,主入口换成了 index.html,并且和其他静态文件一起都是通过 express.static 提供给客户端的,所以要针对性处理才能同时满足性能和功能方面的要求。

记得之前有一次搞过头,把 index.html 也缓存了,从而影响到前端的正常更新,最终只能改回默认的缓存规则。为了尽可能加快用户访问速度,又不把事情搞砸,今天终于静下心来对其进行了合理的优化。

缓存的级别

从加载速度方面可以大致分成三种情况:

  1. 最快的是浏览器直接使用本地缓存的文件(存在客户端的内存或硬盘),通常表现为 200 OK (from memory/disk cache)

  2. 其次是与服务端核对文件是否有变化,发现没有变化再使用本地缓存的文件,通常表现为 304 Not Modified

  3. 最慢的是没有缓存可以,必须从服务器下载文件。

当用户第一次访问我们的网站时,无疑只能从服务器下载完整的文件。但是之后用户再刷新时,就要尽可能利用前两种机制,提高页面访问速度。

当前 SPA 项目的特点

以 Vue 为例,分析 Vue 项目编译后的文件,可以发现文件名是类似下面这种情况的:

app
├── css
│   ├── 10.62f583fe.css
│   ├── 3.668eb03e.css
│   ├── app.0e433876.css
│   └── vendor.301e4b97.css
├── favicon.ico
├── fonts
│   ├── KFOkCnqEu92Fr1MmgVxIIzQ.a45108d3.woff
│   ├── KFOlCnqEu92Fr1MmEU9fBBc-.cea99d3e.woff
│   └── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.2987c5cc.woff2
├── img
│   ├── logo.aea1b4b0.png
│   ├── qrcode.ee393ad7.png
│   └── top.f2e94b65.png
├── index.html
└── js
    ├── 1.b1cd2ded.js
    ├── 10.8351fd79.js
    ├── app.005e071e.js
    └── vendor.66af4e5c.js

除了 favicon.ico 和 index.html 之外,其他文件名中已经自带校验(hash),如果文件发生变化,文件名中的十六进制部分也会相应变化。

可以反推:在任意一个时间点,index.html 在文件名不变的情况下,内容可能发生变化;而其他文件只要文件名不变,内容也不变。

因此 index.html 需要和服务器比对确定内容是否变化,适用于上述第 2 种规则,其他文件可以让浏览器放心的直接使用本地缓存,适用于第 1 种规则。

Tip
文件名是否包含 hash 可以通过 filenameHashing 控制
Tip
浏览器对 favicon 的缓存策略比较特殊,且其不影响应用的实际功能,这里不予讨论。

缓存实现策略

一般情况下,配合使用 ETag 和 Cache-Control 就可以实现上面两种需求。

这里先用 Cache-Control 决定浏览器是否需要和服务进行通信来确认文件的变化情况,比如在 header 中添加 Cache-Control: max-age=86400 ,那么在 24 小时(86400秒)内,浏览器就会直接从本地缓存调用这个文件。针对目前流行的 SPA 文件自带 hash 的特点,这个 max-age 可以直接往大了设,弄个几年也无所谓。对于 index.html 之外的文件,都适用这种策略。

然后考虑 index.html 的情况,先通过 Cache-Control: max-age=0 来避免浏览器在未询问服务器的情况下直接使用本地缓存的 index.html ,也就预防了新版本上传之后,用户仍然使用浏览器缓存中的旧版本的问题。然后用 ETag 给文件一个校验码,让浏览器可以先用校验码与服务器进行比对,只在 ETag 发生变化时,才从服务器下载新版本。

Tip
相关理论可参考 https://web.dev/http-cache/

代码实现

下面演示如何在 Express 中实现这个缓存策略。

const express = require('express');
const app = express();

app.use(
  express.static('./public', {                                      //(1)
    etag: true,                                                     //(2)
    maxAge: '1y',                                                   //(3)
    setHeaders(res, path) {
      if (express.static.mime.lookup(path) == 'text/html') {        //(4)
        res.setHeader('Cache-Control', 'public, max-age=0');        //(5)
      }
    },
  }),
);

解释如下:

  1. 首先,我们使用 express.static 来挂载 public 目录作为静态内容目录;

  2. 为所有文件添加 ETag ,ETag 的计算方式不必深究,只要知道文件内容不变 ETag 也不变(etag 选项默认就是 true ,实际代码中可以不加,这里只是为了方便说明);

  3. maxAge: '1y' 是指将 max-age 设置成 1 年,此配置会在 header 中添加 Cache-Control: max-age=31536000

  4. 判断当前发送给客户的文件类型,是否为 html 文件

  5. 重新指定 Cache-Control 为 Cache-Control: public, max-age=0 来阻止浏览器对 index.html 无脑使用缓存。

对比

最后放上三张图来对比缓存的效果(通过 Chrome 的 Throtting 功能模拟了高速 3G 网络下的效果)

无缓存
Figure 1. 无缓存
express.static默认策略
Figure 2. 只使用ETag(express.static默认策略)
优化后的缓存机制
Figure 3. 优化后

可以看到优化之后比默认设置获得了大约 1 秒的加载速度优势