实现版本变更提示弹窗(简易版)

61 阅读14分钟

背景:vite打包

具体的技术选型参考该文章,这里考虑到组内项目整体偏简单+期望快速落地,采用了纯前端轮询。

1. 版本号选择

使用package.json里面的version+import.meta.env.MODE+contentHash作为最终版本号

  • version表示大版本(虽然按照我们项目的体量,大概率就不会再变了)
  • import.meta.env.MODE表示当前使用的环境,因为组内项目存在多种测试环境,测试环境也都是经过jenkins打包的,使用的是打包后的文件,所以这里携带了MODE参数去区分最终的生产环境和中间的测试环境。

我们项目组有两种测试环境,两种都不对用户开放。

但是第一种是dev环境,数据是虚拟数据,单独和生产隔离,一般本地调测除了连后端电脑就是连dev环境;

在上线生产环境前会有一个uat测试环境,是用户生产的真实数据,这里的数据只是做最后的校验,不可以随意得变更。

同时因为我们组里很多人都有打包的权限,有时候大家忙起来可能同样的代码A刚打完,B不知道,一小时后再次打包,这样无效且重复的弹窗更新是可以避免的

其他几种方案作为版本号的优劣:

  1. 时间戳:如上,时间戳是每次打包都会生成新的时间戳,由此会造成冗余的场景识别。
  2. git版本号:这个可以通过插件获取,但是有一个问题是代码提交一定需要通过git执行,可以在某种场景下用于追溯到提交commit,有时候遇到需要回退追溯的版本,然后最新的git还没有打包,可以避免误回退错误的commit,用于辅助快速定位到版本commit

可以git版本号拼contentHash。

如何拿到项目整体代码变更的contentHash数据呢?

如果只是拿出口文件的hash值,只会在APP.vue变化的时候,hash发生变化;为了实现项目代码任何一处发生变化最终的代码都会发生变化,需要取所有文件的hash,组成数组,然后sort排序最后拼起来,然后使用crypto进行MD5加密,生成新的Hash值,这个Hash值是唯一的

  • 这里因为在vite和webapck都配了这个功能,我注意到webpack执行npm run build指令之后会在最后输出一个hash数据,那这个数据是否适合作为版本标识?

    答案是不行。因为这个是全局上下文的Hash值,也就是说任何的

2. vite输出文件改造

将vite的output文件更改为contentHash的模式,因为使用插件获取contentHash的时候需要遍历输出的文件,获取main.js文件名里面的hash值

chunkFileNames是只有做了分块打包处理,才会产生的文件,这里我最终生成文件是js/index.[contentHash].js

 build: {
    rollupOptions: {
      output: {
        // ========== JS 文件(入口/分包) ==========
        // 入口 JS 文件(如 src/main.ts):输出到 js/ 目录,命名为 名称.8位哈希.js
        entryFileNames: 'js/[name].[hash:8].js',
        // 非入口 JS 文件(如分包、异步组件):输出到 js/chunks/ 目录,命名规则同上
        chunkFileNames: 'js/chunks/[name].[hash:8].js',
      }
   }
}

3.vite插件改造

vite插件是node环境,所以需要在项目目录下单开一个文件夹,不可以放在src的文件夹里面,不然fs和path库没办法解析执行,这里我创建了一个build文件夹

为什么src文件夹下是浏览器环境,外部新建一个文件夹是node环境?

核心是Vite 项目中不同目录的代码运行时环境不同——

  1. src/ 下的代码最终会被编译 / 打包后运行在浏览器环境,这是运行代码;
  2. 项目根目录下的自定义插件目录(如 plugins/)的代码,是作为 Vite 构建流程的一部分运行在Node.js 环境中,这是构建代码。

二者的运行阶段、执行主体完全不同,这也是 fs/path 等 Node.js 内置模块只能在外部目录使用的根本原因。

首先需要明确vite的生命周期:

  1. 解析vite.config.ts,执行config(),如果需要修改config配置或者注入参数一般在这个阶段拦截
  2. 配置解析完成,执行configResolved(config),这个时候能够拿到最终的config配置,如果要获取config上面的参数,比如MODE,可以拦截这个阶段的函数
  3. 开始打包,生成打包产物,执行generateBundle(_,bundle),可以在这个阶段修改拿到打包后的数据,比如打包的文件名,以及对应文件内部的hash值
  4. 写入磁盘,处理最终写入产物writeBundle(_,bundle),不过不推荐在这个阶段再进行数据处理,完全可以再generateBundle阶段处理生成的文件,这样不涉及磁盘的读写
//getContentHash文件
export function getContentHash(): Plugin {
  let resolvedConfig: ResolvedConfig;
  let VersionHash: string;
  return {
    name: "vite-plugin-get-contenthash",
    config() {
      return {
        define: {
        //注入APP_VERSION环境变量
          "import.meta.env.APP_VERSION": JSON.stringify("__VERSION_HASH__"),
        },
      };
    },
    configResolved(config) {
      resolvedConfig = config;
    },
    generateBundle(options, bundle) {
    //1. 获取contentHash,拼接得到最终版本号; 2.替换占位符,注入APP_VERSION; 3.替换的replace一定需要加/g去保证全局替换
    },
  };
}

//vite.config.ts文件
plugin:[getContentHash()]

4. 打包的时候生成version.json文件

generateBundle(options, bundle)阶段fs把关键的版本号写入version.json文件

输出的文件父目录一定需要是vite插件配置outDir的配置目录,如果配置在外面就会出现BUG: 打包拿到的version.json里面的contentHash会是上一次打包的contentHash数值,拿不到最新的数值

const versionJson = {
    version: VersionHash,
    contentHash: indexJsHash,
    buildTime: new Date().toISOString(),
  };

const distDir = resolvedConfig.build.outDir;
fs.writeFileSync(
    path.resolve(distDir, "version.json"),
    JSON.stringify(versionJson, null, 2),
    "utf8"
  );

5. 获取从nginx缓存的前端静态文件

因为这里是从nginx获取前端静态文件,所以走的不是普通的后端接口代理,前端的vite server,需要单独开一个配置去处理静态文件。

"/version.json": {
    target: "https://yuming.com:port/",
    changeOrigin: true,
    secure: false,
}

然后在文件的入口进行请求nginx静态文件

onMounted(async () => {
  const versionListener = async () => {
    const response = await axios.get(`/version.json`, {
      method: "GET",
      headers: {
        "Cache-Control": "no-cache", // 禁用缓存
        Pragma: "no-cache",
      },
    });
    if (import.meta.env.APP_VERSION !== response.data.version) {
      VersionDialogShow = true;
      clearInterval(interval);
    }
  };

  if (import.meta.env.MODE !== "development") {
      interval = setInterval(versionListener,30 * 1000);
  }
});
onUnmounted(() => {
  clearInterval(interval);
});

6.页面版本弹窗组件

这块儿太基础了,就不多赘述了,一个el-dialog的事情

7. Question

  1. 倒计时误差

因为setTimeout本身因为JavaScript是单线程,setTimeout 的回调函数会被放入事件队列,浏览器资源分配中前面任务阻塞延迟的问题会存在一定性的误差,但是误差较小,大部分场景都可以忽略,但是如果切换Tab页或者最小化之后再切换到前台,因为浏览器会识别当前页面需要资源减少,降低定时器的执行频率,直到切回来后才会恢复原本的轮询间隔,所以这样切回来的那一次计算时间会有误差。

对此因为项目会在第一次挂载的时候立刻弹窗以及切换到后台再切回前台的时候再次立刻弹窗,所以只需要在切换回来的时候立刻执行一次versionListener,并删除定时器,重新创建一个新的定时器就能恢复原本的时限误差

如果没有以上的策略,坚持原本的定时器,并且不希望出现误差的话,可以使用web worker单独处理定时器。

Web Worker 是独立于主线程的后台线程,不受前台 / 后台标签的节流规则限制,其事件循环和定时器执行逻辑完全独立,因此切后台后仍能保持接近预期的执行频率

面试官:前端倒计时有误差怎么解决前言 去年遇到的一个问题,也是非常经典的面试题了。能聊的东西还蛮多的 倒计时为啥不准 一 - 掘金 (juejin.cn)

看了一下这个里面的一个评论:

setTimeout 的延时不应当被依赖用来进行倒计时,因为它有非常多的不稳定因素(参见MDN)。
最佳实践是将倒计时结束的时间戳计算出来,再用 setTimeout 或者 requestAnimationFrame 进行更新(计算结束和当前时间戳的时间差,舍入到秒数然后更新到页面上),这种方式误差最小且最节能。至于需要和服务器实时同步的场景则考虑sse授时。

具体为:

  1. 定时器的本质setTimeout 的 delay 是「事件循环中任务的最小等待时间」,如果队列中有其他任务,实际执行时间会更长;
  2. requestAnimationFrame 优势:浏览器会在重绘前执行回调,前台 60fps(约 16ms / 次),后台自动暂停,比 setInterval 节能 90%+;
  3. 时间戳选择performance.now() 是高精度时间戳(微秒级),且不受系统时间修改影响,优先于 Date.now()

由此衍生两种核心方法:

  1. 计算出倒计时结束时间,轮询是否到达结尾时间,进行报时,performanceAPI在Node环境下无法使用,只支持浏览器环境
function preciseCountdown(endTime:number, onUpdate:(seconds:number)=>void, onComplete:()=>void) {
  // 高精度时间戳(优先用 performance.now,无兼容问题时用 Date.now)
  const getNow = () => performance.now() + performance.timeOrigin;

  const update = () => {
    const now = getNow();
    const remaining = Math.max(endTime - now, 0); // 剩余时间(ms)
    const remainingSeconds = Math.floor(remaining / 1000); // 舍入到秒

    // 更新页面(只传最终要显示的秒数,避免浮点误差),在高频报数场景下每秒报数发生变化由此实现倒计时
    //实际上是10->10->...(省略n个10)->10 ->9->9->...->9->8....
    onUpdate(remainingSeconds);

    if (remaining <= 0) {
      onComplete();
      return;
    }

    // 优先用 requestAnimationFrame(更节能,适配屏幕刷新率)
    // 降级用 setTimeout(兼容老环境,延时设为 16ms 接近 60fps)
    if (requestAnimationFrame) {
      //进行高频报数展示
      requestAnimationFrame(update);
    } else {
      setTimeout(update, 16);
    }
  };

  // 立即执行一次更新,避免首屏延迟
  update();
}

// 用法示例:倒计时 10 秒
const endTime = Date.now() + 10 * 1000;
preciseCountdown(
  endTime,
  (seconds) => {
    console.log('剩余秒数:', seconds);
    // 更新 DOM:document.getElementById('countdown').textContent = seconds;
  },
  () => {
    console.log('倒计时结束');
  }
);
  1. 使用后端sse进行处理,相比于普通的接口请求,sse具备高度的实时性
对比维度普通后端接口(HTTP/REST)SSE(Server-Sent Events)
通信方向双向(客户端请求 → 服务端响应),但「请求 - 响应」是单次单向单向(服务端 → 客户端),服务端主动推送
连接模式短连接:请求发起 → 响应返回 → TCP 连接关闭长连接:一次 TCP 握手后,连接持续保持,服务端按需推数据
触发方式客户端主动触发(点击、定时轮询、页面加载)服务端主动触发(数据更新 / 事件发生时推送)
数据传输形式单次完整数据(JSON/Form/ 二进制),响应结束即终止流式文本数据(UTF-8),分块传输(一行 / 多行数据)
实时性低(轮询依赖间隔,有延迟)高(数据更新立即推送,无轮询延迟)
网络开销高(轮询场景下多次 TCP 握手、重复请求头)低(一次连接持续传输,仅首次握手开销)
// SSE:客户端建立连接,监听服务端推送
const sse = new EventSource('/api/sse/countdown');
sse.onmessage = (e) => {
  const data = JSON.parse(e.data);
  console.log('服务端推送的实时数据:', data); // 服务端有更新就会触发
};
  • 普通接口:默认是「短连接」——TCP 三次握手后传输数据,响应完成后四次挥手关闭连接;若要实时性,需用「定时轮询」(如每 1 秒请求一次),但会产生大量重复的 TCP 握手 / 挥手开销,且有轮询间隔的延迟。
  • SSE:是「HTTP 长连接」—— 一次 TCP 握手后,连接保持打开状态,服务端通过「分块传输编码(Chunked Transfer Encoding)」向客户端流式推送数据,直到连接被主动关闭(客户端 close() 或服务端断开)。优势:无轮询的重复开销,实时性接近 “即时”
  • WebSocket 是双向通信(客户端↔服务端)、基于独立的 WebSocket 协议、功能更强但复杂度更高;常用于双向实时交互(如聊天、在线游戏)场景。

目前的电商秒杀倒计时用的主流方案应该是performance计算最终时间-起始时间,而不是sse,因为sse在电商这类高并发场景下需要大量的后端开销去建立链接窗口。

在进行秒杀时间校准的场景下,如何防止用户手动修改本地时间,主要依赖三个参数:

  1. 后端在秒杀开始前会传给前端接口秒杀开始的准确时间戳,比如xxxxxxxxxx
  2. 前端通过performance.timeOrigin获取绝对起始时间,这个时间一旦定下为YYYYYYY,无论中途用户修改多少次本地时间都不会发生变化
  3. 前端在建立performance.timeOrigin的时候会绑定建立performance.now(),为绝对流逝时间,也就是相对performance.timeOrigin绝对起始时间变化的时间,不受本地时间影响

最终xxxxxxxxxx-(performance.timeOrigin+performance.now())就是需要倒计时的时间

题外话: vite生命周期钩子

除了上述四个生命周期钩子,还有一些额外的常见的vite钩子:

  • load:针对文件的加载,可以在这个阶段更改文件的内容

  • transform:主要用于格式转换,比如vite解析vue文件内部的setup里面的代码就是在这个钩子里面。vite会识别setup的开始和结束,然后建立一个script标签,把对应的js代码插入到script标签里面

整体上的生命周期:

[Vite]Vite插件生命周期了解[Vite]Vite插件生命周期了解 Chunk和Bundle的概念 Chunk: - 掘金 (juejin.cn)

开发阶段: 配置解析-> transform -> render

构建阶段: 配置解析-> 构建-> 输出 -> 写入磁盘

缓存问题

浏览器缓存方案:强缓存和协商缓存

  • 强缓存: 标识符cache-control,里面有max-age这项缓存时间的配置; 以及no-cache或者no-store
  • 协商缓存:e-taglast-Modified等字段里面识别是否需要更新缓存资源

no-cache

缓存但 “先用先问”(协商缓存)。不是 “禁止缓存”,而是 “缓存的数据不能直接用,必须先向服务器验证有效性”。

  • 具体行为
  1. 浏览器会把资源缓存到本地;
  2. 下次请求该资源时,不会直接使用缓存,而是先发送一个 “验证请求” 到服务器(携带 ETag/Last-Modified 等标识);
  3. 服务器判断资源是否更新:未更新,返回 304 Not Modified(空响应体),浏览器使用本地缓存;已更新:返回 200 OK(新响应体),浏览器更新缓存并使用新资源。
  • 核心价值:既保证数据新鲜度,又能通过 304 响应减少带宽消耗(不用传输完整资源)。
  • 适用场景:需要实时性但可复用的资源,比如电商商品详情、新闻列表、后台管理系统的动态数据。
  • 响应头示例
Cache-Control: no-cache
ETag: "abc123" // 配合验证的标识

 no-store    完全禁止缓存(彻底不存)

  • 核心行为:浏览器既不能把资源缓存到本地,也不能将缓存写入磁盘,甚至不会在内存中临时存储。
  • 具体行为
  1. 每次请求该资源,浏览器都必须从服务器获取完整的新资源(始终返回 200 OK);
  2. 请求完成后,资源不会被保存,刷新页面 / 重新请求时需再次从服务器获取;
  3. 无任何缓存相关的验证流程,也不会产生 304 响应。 核心价值:绝对保证数据最新,且避免敏感数据留在本地。 适用场景:高敏感数据,比如支付页面、用户隐私信息(手机号 / 身份证)、验证码接口、登录态相关接口。 响应头示例
Cache-Control: no-store

缓存策略

• HTML 文件:禁止缓存,永远从服务器获取最新版本

•静态资源(JS/CSS/图片):带 hash 文件名 + 长期缓存

•静态资源(JS/CSS/图片):非 hash 文件名 + 短期缓存

在出口打包的时候区分两种资源,由此判断资源在nginx的配置,nginx会对不需要缓存的文件配置no-cache,需要的配置max-age

location ^~ /assets/ { 
    expires 1y; 
    add_header Cache-Control "public, max-age=31536000, immutable"; 
    add_header X-Content-Type-Options "nosniff";
    try_files $uri =404; 
}

location / { 
    add_header Cache-Control "no-store, no-cache, must-revalidate"; 
    add_header Pragma no-cache; add_header Expires 0; 
    index.html try_files $uri $uri/ @fallback; 
}

如何处理前端缓存?

  1. 更改输出打包文件名,添加contentHash进文件名
  2. 经常变更的文件nginx设置no-cache,不常变更的文件用cache-control和max-age,同时使用e-tag进行辅助,防止在缓存期内但是还是变更的文件
  3. 使用版本检测工具,识别版本变化,要求用户更新