静态资源缓存策略及发布更新

前言

在前端,一个应用的部署涉及到的知识点会有很多,下面我们从两个层面来聊一聊前端静态资源部署。

  1. HTTP 缓存:http 请求服务器上的 图片、JS、CSS 等静态资源的缓存机制;
  2. Web 应用部署后如何通知用户更新。

一、HTTP 缓存

浏览器缓存 HTTP 请求资源的作用在于可以:减少网络带宽消耗、降低服务器压力 以及 提高页面打开速度

其中 http 缓存策略分为两种:强缓存协商缓存

1、强缓存

浏览器本地会根据上次读取资源时服务器设置的过期时间 来判断是否使用缓存,未过期则从本地缓存里拿资源,已过期则重新请求服务器获取最新资源。

强缓存交互流程:

  1. 浏览器第一次访问资源时,如果服务器期望浏览器使用「强缓存」,在得到该资源后一段时间内不要再发送请求过来,直接从浏览器缓存中读取;
  2. 服务器可以在 HTTP 响应头中设置 Catch-Control: public, max-age=31536000(max-age 代表缓存时间,单位毫秒),这里表示在一年内浏览器不要向服务器发送请求;
  3. 当浏览器再次请求使用缓存时,可以在控制台中看到 HTTP 状态码 200 后面会标明缓存(from disk cache),这里提一下浏览器缓存资源的地方有两处:
    • 内存缓存(memory cache),读取速度快,在进程关闭时会被清除;
    • 磁盘缓存(disk cache),读取速度慢,需要重新解析文件,进行 I/O 操作,缓存一直保存在电脑磁盘中。

以 NodeJS 为例,服务端期望资源在浏览器下使用「强缓存」,可以设置响应头缓存字段 Catch-Control

js 复制代码
res.setHeader('Cache-Control', 'public, max-age=xxx');

Catch-Control 字段的取值可由两部分组成:修饰作用范围(public、private)和 expires 过期规则(max-age、no-cache、no-store)

  1. private,默认值,只有浏览器客户端能够缓存这个资源;
  2. public,浏览器客户端和代理服务器(CDN)都能缓存这个资源;
  3. max-age,指定强缓存的时长,在指定时间内不要向服务器发送请求,使用本地缓存;
  4. no-cache,等价于 max-age=0,配合协商缓存使用,每次都会向服务器询问是否使用缓存;
  5. no-store,告诉浏览器不使用缓存,即强制从服务器获取最新资源。

强缓存除了使用 Catch-Control 字段(Http1.1规范) 外,还可以在响应头返回 Expires 字段(Http1.0规范)是一个具体时间值(Date 日期格式),代表缓存的有效期。

其实 Expires 字段控制缓存并不精准,它是由浏览器取电脑设备的本地系统时间与 Expires 有效时间进行对比,我们知道电脑设备的系统时间是可以自由修改的。

Catch-Control 优先级高于 Expires,两者同时存在时优先使用 Catch-Control

2、协商缓存

浏览器本地每次都向服务器发起请求(协商),由服务器来告诉浏览器是从缓存里拿资源还是返回最新资源给浏览器使用。

协商缓存交互流程:

  1. 浏览器第一次访问资源时,服务端生成一个 Etag 值(服务端对于一个资源的唯一标识(类似于资源内容的 hash 值))携带在响应头里返回给浏览器;
  2. 当浏览器再次请求资源时,会在请求头里携带 If-None-Match 字段,值为上次服务器生成的 Etag 值;
  3. 服务端收到后,拿该值与资源文件最新的 Etag 值做对比:
    • 若值没有发生变化,返回 304 状态码,告诉浏览器继续使用缓存;
    • 如果发生变化,则返回 200 和最新资源文件给浏览器使用;

以 NodeJS 为例,服务端期望资源在浏览器下使用「协商缓存」,可以这样设置:

js 复制代码
res.setHeader('Cache-Control', 'public, max-age=0'); // 每次向服务器请求,同设置 no-cache
res.setHeader('ETag', xxx);

协商缓存除了使用 Etag 外,还可以使用服务器响应头返回的 Last-Modified 字段(HTTP1.0规范),浏览器再次请求资源时请求头携带 If-Modified-since

Etag 不同的是:它是一个时间值,代表文件的修改时间,服务器根据文件修改时间是否发生变化来判断是否使用缓存。

Etag 的优先级更高,且更准确,因为有时候文件发生了修改,但内容没有变化,Last-Modified 则会重新获取资源。

在强缓存与协商缓存同时拥有的情况下,浏览器先检查强缓存,再检查协商缓存

3、静态资源发生更新时,如何让浏览器缓存失效,去加载最新内容

在更新版本之后,用户重新请求资源时如何能够拿到最新的资源文件?即如何跳过 http 静态资源的缓存,去服务端拉取最新资源。

在服务端可以通过修改响应头缓存字段,而在前端可以通过 修改静态资源路径 来跳过缓存。

修改静态资源路径的方式有两种:

  1. 资源路径后面携带随机参数,如:xxx?version=Date.now()
  2. 资源命名 hash 值,如:webpack 在打包时为构建产物增加 hash 值。

随机参数的方式如下,但要考虑如何在 src 地址字符串中使用 JS 时间戳变量(如 webpack html-webpack-plugin 插件):

html 复制代码
<script language="javascript" src="xxx.js?version=new Date()">

webpack 构建资源输出 hash 命名参考配置如下:

js 复制代码
entry:{
  main: path.join(__dirname,'./main.js'),
  vendor: ['react', 'antd']
},
output:{
  path:path.join(__dirname,'./dist'),
  publicPath: '/dist/',
  filname: 'bundle.[contenthash].js'
}

扩展知识:webpack 提供了三种哈希值计算生成方式:

  1. hash:跟整个项目的构建相关,构建生成的文件 hash 值都是一样的,只要项目里有文件更改,整个项目构建的 hash 值都会更改。(不推荐使用)
  2. chunkhash:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的 chunk,生成对应的 hash 值。(在多入口应用下,按照每个入口内所有文件生成 hash)
  3. contenthash:由文件内容产生的 hash 值,每个产物文件之间 hash 值独立。(推荐使用,每个文件都是一个独立的 hash)

hash 的方式来做版本更新。在代码分包的时候,应该将一些不常变的公共库独立打包出来,使其能够更持久的缓存。

4、小结

浏览器是 根据资源请求响应头的相关字段来决定缓存的方案,这要求服务端根据不同的资源请求返回对应的缓存字段,来告诉浏览器使用哪种缓存策略。

一个较为合理的缓存方案:

  • HTML 使用协商缓存,服务端根据 Etag 来确定是否下发新资源
  • CSS & JS & 图片:使用强缓存,文件命名带上 hash 值
js 复制代码
// 强缓存
res.setHeader('Cache-Control', 'public, max-age=xxx');

// 协商缓存
res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Last-Modified', xxx);
res.setHeader('ETag', xxx);

缓存的最佳实践:尽可能命中强缓存,同时,能保证在更新版本的时候让客户端的缓存失效(通过文件名加 hash 的方式来做版本更新)

二、Web 应用部署后如何通知用户更新

Web 应用相较于 客户端 APP 应用的升级,只要将最新静态资源部署到服务器上,重新加载页面并采用静态资源 hash 策略,即可使用最新版本。

最近项目上遇到这样一个场景:

对于单页面应用,如果用户是在升级之前访问的 Web 应用页面,在升级成功之后(假设 页面路由采用按需加载方案,并且其对应的 js chunk 文件内容发生了变化),用户进行路由跳转时,会出现路由对应的内容不展示的现象,即出现白屏。

经过分析是因为:单页面应用采用「覆盖式发布」,即每次打包时都会清除上个版本的资源并重新生成新的资源。对于这个场景下的用户,在进行路由跳转时,路由对应的 hash 资源已经从服务器上删除,导致访问 404,没有渲染出该有的内容。

面对这类场景,一种做法就是更换打包构建方案,采用「非覆盖式发布」,这样静态资源存留了历史版本从而规避了这类问题。

如果继续采用「覆盖式发布」,可以选择通知用户 Web 应用有更新,选择手动去刷新页面使用最新版本。

单从前端实现考虑,如何能够将版本更新通知给用户?前端领域的一些朋友分享了比较好的处理方式。

第一种是:监听静态资源加载出现错误(如:资源 404 不存在),去提醒用户有版本更新。

js 复制代码
window.addEventListener("error", event => {
  if (["script", "link"].indexOf(event.target.localName) > -1) {
    // Modal 提示用户是否进行页面刷新 location.reload();
  }
});

第二种方案是 :计时器轮询查询 html 文件,匹配 <script src="/assets/index.6a23e128.js"></script> 文件路径 hash 是否发生变化,当文件 hash 前后不一致时,说明 Web 应用进行了部署更新。

这篇文章已经给出了此方案的实现方式,可以参考 前端重新部署如何通知用户刷新网页?;

第三种则是 :每次发版 Web 应用时,去更新一个 version.json 版本配置文件,判断方式与第二种相似,不过轮询的比较对象是这个 JSON 文件中的版本号。

我选用的是这个方案,相较于第二种方案,它轮询请求服务器的 version.json 内容很小,能够最小程度减轻服务器的压力。

下面我们来看看如何通过 version.json 实现通知用户版本更新。

1、部署前升级版本号

考虑到版本升级提醒能够可控,没有随打包构建一起执行(这种每次构建都会发布版本),而是提供 执行命令 供开发者手动打包控制什么时候进行更新。

我们在 package.json 中配置 publish 发布命令,它会执行发布的脚本文件:

json 复制代码
{
  "script": {
    "publish": "node ./scripts/publishVersion",
  }
}

接下来我们要先新建一个 version.json,用于记录 Web 应用版本号,这里我是放在 public 目录下,它会在打包构建时会自动拷贝到 build 目录中。

json 复制代码
{
  "version": "1.0.0"
}

publishVersion.js 发布脚本中,我们要做以下事情:

  • 1)读取当前版本号
  • 2)升级版本号(创建新的版本号)
  • 3)更新版本到文件中,并拷贝到 build 目录下(后者可选)
  • 4)提交 version.json 版本记录(git)

有了思路和步骤,代码实现如下:

js 复制代码
const path = require("path");
const fs = require("fs-extra");
const chalk = require("react-dev-utils/chalk");
const { upgradeVersion, execSyncCommand } = require("./util");
const rootPath = path.resolve(process.cwd());
const buildPath = path.resolve(process.cwd(), "build");
const publicPath = path.resolve(process.cwd(), "public");
const packagePath = path.resolve(process.cwd(), "package.json");
const versionPath = path.resolve(publicPath, "version.json");
const packageJSON = require(packagePath);

/**
 * 升级版本号操作,根据场景来决定发布行为。
 * 1. 随程序构建发布版本,若程序构建次数不可控,用户将会多次收到更新
 * 2. 手动构建发布版本,当需要去提醒用户更新时,手动进行构建(推荐)
 */

// 1. 读取当前版本号
const version = require(versionPath).version;

// 2. 升级版本号
const newVersion = upgradeVersion(version);

// 3. 更新版本到文件中,并拷贝到 build 目录下
const versionJSON = {
  version: newVersion
}
fs.writeFileSync(versionPath, JSON.stringify(versionJSON, null, 2));
fs.copyFileSync(versionPath, path.resolve(buildPath, "version.json")); // 可以选择是否同步最新版本号到 build 目录下

// 4. 提交 version.json 版本记录(git)
const commandOptions = { cwd: rootPath };
execSyncCommand(`git restore --staged .`, commandOptions); // 撤销暂存区的文件
execSyncCommand(`git add ${versionPath}`, commandOptions); // 添加 version.json 到暂存区
execSyncCommand(`git commit -m '${`chore: publish version ${newVersion} by ${packageJSON.name}`}'`, commandOptions); // 提交 commit
execSyncCommand(`git pull origin`, commandOptions); // 拉取远端代码
execSyncCommand(`git push origin`, commandOptions); // 推送

console.log(chalk.green(`publish successfully(${packageJSON.name} ${newVersion}).\n`));

这里用到两个工具函数 upgradeVersionexecSyncCommand,对实现感兴趣的读者可以看这里:

js 复制代码
// util.js
const { execSync } = require('child_process');
/**
 * upgradeVersion 升级版本号
 * @param String oldVersion 当前版本号,如:1.0.12
 * @return String newVersion 升级后的版本号,如:1.0.13
 */
const upgradeVersion = (oldVersion) => {
  const maxRange = 99; // 版本号的每一节最大范围
  const increase = 1; // 每次升级增加的版本号

  // 拿到版本号的每一节数字
  const list = oldVersion.split(".").map(x => Number(x));
  for (let i = list.length - 1; i >= 0; i --) {
    // 1)当每一节版本号升级后大于最大范围,并且不是第一节,重置该节为 0,并前进一节
    if (list[i] + increase > maxRange && i > 0) {
      list[i] = 0;
    } else {
      // 2)版本号升级,结束循环
      list[i] += increase;
      break;
    }
  }

  return list.join('.');
}

const execSyncCommand = (command, options) => {
  options = Object.assign({ stdio: 'ignore' }, options);
  execSync(command, options);
}

module.exports = {
  upgradeVersion,
  execSyncCommand,
}

现在,通过执行 npm run publish 命令,可以看到 public/version.json 中的版本号进行了升级。现在就可以去进行打包构建进行部署。

2、用户端查询比对版本号

在用户端如何实时知道 Web 应用发版了呢?由于只是纯前端去实现,后台支持 websocket 的方案不再考虑。

这里采用 计时器轮询 结合 visibilitychange(页面出现时),向服务器请求版本号文件(采用 fetch 请求),将最新的版本号与本地缓存的版本号进行比对,以此来判断是否发版。

项目采用 React 技术栈,具体实现参考:

tsx 复制代码
const fetchAppVersion = async (nowCache: boolean = false) => {
  const url = process.env.API_ENV === 'development' ? "/version.json" : "/publicPath/version.json";
  const result = await fetch(url);
  const data = await result.json();
  if (result.status === 200) {
    // 1. 获取最新版本号
    const newVersion = data.version;
    const cacheKey = "cache-version";
    // 2. 获取缓存版本号
    const cacheVersion = localStorage.getItem(cacheKey);
    
    // 3. !!! 首次进入立即缓存当前版本号
    if (nowCache) {
      localStorage.setItem(cacheKey, newVersion); // 缓存版本号
    } else {
      // 4. 监控到程序有新版本
      if (newVersion !== cacheVersion) {
        // 4.1 提示用户有发版更新
        // ... 省略你的提醒用户交互方式
        localStorage.setItem(cacheKey, newVersion); // 4.2 缓存版本号
      }
    }
  }
}

useEffect(() => {
  // 首次进入,存储当前最新版本号
  fetchAppVersion(true);
  
  // 查询场景1: 计时器轮询查询(每隔一分钟查询一次)
  setInterval(() => fetchAppVersion(), 60 * 1000);
  // 查询场景2: 程序 visibility 时查询
  const handleVisibilityChange = () => {
    if (!document.hidden) fetchAppVersion();
  }
  document.addEventListener('visibilitychange', handleVisibilityChange);
  return () => {
    document.removeEventListener('visibilitychange', handleVisibilityChange);
  }
}, []);

特别注意,每次重新进入程序时,要先将版本号缓存,因为初次进入一定是拿到的最新版本,只有在页面静置期间发版,去通知用户版本升级。

参考

1. HTML5 缓存问题方案及自动检测更新方案
2. 前端缓存最佳实践
3. 什么是强缓存、协商缓存?
4. 前端重新部署如何通知用户刷新网页?

相关推荐
Achieve前端实验室3 分钟前
【每日一面】async/await 的原理
前端·javascript·面试
姜至7 分钟前
el-calendar实现自定义展示效果
前端·vue.js
烛阴17 分钟前
Lua中的三个点(...):解锁函数参数的无限可能
前端·lua
拉不动的猪22 分钟前
webpack分包优化简单分析
前端·vue.js·webpack
德莱厄斯28 分钟前
没开玩笑,全框架支持的 dialog 组件,支持响应式
前端·javascript·github
非凡ghost1 小时前
Affinity Photo(图像编辑软件) 多语便携版
前端·javascript·后端
非凡ghost1 小时前
VideoProc Converter AI(视频转换软件) 多语便携版
前端·javascript·后端
endlesskiller1 小时前
3年前我不会实现的,现在靠ai辅助实现了
前端·javascript
用户904706683571 小时前
commonjs的本质
前端
Sailing1 小时前
5分钟搞定 DeepSeek API 配置:从配置到调用一步到位
前端·openai·ai编程