Skip to content

从"版本号打架"到 30 秒内提醒用户刷新:一个微前端团队的实践

1. 背景与痛点

我们团队维护着一个微前端子应用集群,每个子应用都需要同时服务 dev / test / release / online 多套环境。分支策略(master / release / test / dev / hotfix / feature_x.x.x)加上 Jenkins 自动化,让"一天多次发布"成为常态。但真正影响交付效率的并不是发布次数,而是一个顽固的问题:测试同学常年停留在旧版本页面

1.1 真实场景

  • 测试在早上打开 dev 页面,下午我们发布了新的组件样式;
  • 他们继续在旧页面里回归,反馈的问题我们一眼看出"这是老版本";
  • 群里喊"刷新一下"并不靠谱,于是"无效缺陷 + 反复沟通"成了常态。

更严重的一次事故,是我们在版本检查逻辑里同时使用了 webpack DefinePlugin 与自定义插件,各自调用了一次 getAppVersion()。结果前端控制台打印的是 0.8.3-release-202511210828,而 version.json 里是 0.8.3-release-202511210829。两边只差 1 秒钟,却让线上用户始终被提示刷新,形象地被团队称为"版本号打架"。

1.2 我们的诉求

  1. 用户在 30 秒内感知版本更新;
  2. 弹窗里能看到"当前版本 / 最新版本 / 环境";
  3. 支持"立即刷新 / 稍后再说",不给用户造成中断;
  4. 方案需兼容现有微前端架构与 CI/CD 流程,不依赖后端改造。

2. 方案探索与取舍

在动手前,我们列出几种可行方式:

方案实现复杂度实时性依赖适配场景关键优缺点
纯前端轮询 version.json中(30s)前端 + Nginx多环境微前端成本最低;轻微网络开销
Service Worker/PWA较高现代浏览器PWA 应用缓存控制好,但改造量大
WebSocket 推送最高后端服务强实时场景需要额外服务端开发
后端接口统一管理前后端版本集中管理带来跨团队耦合

综合团队资源与落地速度,我们选择了 纯前端轮询 + 静态版本文件 的做法,并明确两个原则:

  • 版本号唯一,可追溯基础版本号-环境-时间戳
  • 发布零侵入:Jenkins 仍旧运行 npm run build-xxx,无需新增步骤。

3. 技术方案总览

  1. 构建阶段生成 version.json:在 vue.config.js 中提前计算版本号,既注入到前端(process.env.APP_VERSION),也写入输出目录的 version.json
  2. 前端轮询比对:应用启动后每 30 秒请求一次 version.json,禁用缓存并携带时间戳,比较版本号;
  3. 交互提示:复用 Ant Design Vue 的 Modal.confirm,展示当前/最新版本与环境;
  4. 缓存策略:Nginx 对 HTML/version.json 禁止缓存,对 JS/CSS/图片继续长缓存;
  5. CI/CD 配合:推荐在构建命令中传入统一时间戳,确保同一构建任务的所有进程使用相同版本号。

4. 关键落地细节

4.1 跨平台构建脚本配置(Cross Platform Build Script Configuration)

为了确保 Windows/Linux/macOS 环境都能正确设置时间戳,我们在 package.json 中使用 cross-env 和独立的时间戳脚本:

json
{
  "scripts": {
    "build": "cross-env BUILD_TIMESTAMP=$(node scripts/get-timestamp.js) NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build --mode production",
    "build-develop": "cross-env BUILD_TIMESTAMP=$(node scripts/get-timestamp.js) NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build --mode develop",
    "build-testing": "cross-env BUILD_TIMESTAMP=$(node scripts/get-timestamp.js) NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build --mode testing",
    "build-release": "cross-env BUILD_TIMESTAMP=$(node scripts/get-timestamp.js) NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build --mode release"
  }
}
javascript
// scripts/get-timestamp.js
// 跨平台获取时间戳
const timestamp = new Date().toISOString().slice(0, 16).replace(/[-T:]/g, "");
console.log(timestamp);

关键点

  • cross-env 确保环境变量在不同操作系统下正确传递
  • 独立的时间戳脚本避免 shell 命令兼容性问题

4.2 版本号只生成一次(Build-time Deterministic Versioning)

为了彻底解决"版本号打架"问题,我们在 vue.config.js 中采用了模块加载时确定时间戳的策略:

javascript
const fs = require("fs");
const path = require("path");
const packageJson = require("./package.json");

// ⚠️ 重要:在模块加载时确定构建时间戳,避免多进程构建时版本号不一致
// Webpack/Vite 多进程构建时,每个 worker 进程独立执行代码,如果多次调用 new Date()
// 可能在不同进程、不同时间点生成不同的时间戳,导致同一构建产物版本号不一致
// 方案:优先使用 CI/CD 传入的 BUILD_TIMESTAMP,否则在模块加载时确定统一时间戳
const BUILD_TIMESTAMP =
  process.env.BUILD_TIMESTAMP ||
  (() => {
    const now = new Date();
    return now.toISOString().slice(0, 16).replace(/[-T:]/g, "");
  })();

// 格式化时间戳:年月日时分(如202310301500)
function getFormattedTimestamp() {
  // 使用模块加载时确定的统一时间戳
  return BUILD_TIMESTAMP;
}

// 获取当前环境名称
function getEnvName() {
  return process.env.VUE_APP_ENV || "develop";
}

// 生成复合版本号:基础版本+环境+时间戳
function getAppVersion() {
  const baseVersion = packageJson.version;
  const envName = getEnvName();
  const timestamp = getFormattedTimestamp();
  return `${baseVersion}-${envName}-${timestamp}`;
}

// ⚠️ 关键:构建阶段仅生成一次版本号和环境标识,保持前端注入与 version.json 完全一致
const buildEnvName = getEnvName();
const buildVersion = getAppVersion();

module.exports = {
  configureWebpack: {
    plugins: [
      new webpack.DefinePlugin({
        "process.env.APP_VERSION": JSON.stringify(buildVersion),
        "process.env.APP_ENV": JSON.stringify(buildEnvName),
      }),
    ],
  },
  chainWebpack(config) {
    config.plugin("generate-version-json").use({
      apply(compiler) {
        compiler.hooks.done.tap("GenerateVersionJsonPlugin", () => {
          fs.writeFileSync(
            path.resolve(__dirname, "edu/version.json"),
            JSON.stringify(
              {
                version: buildVersion,
                env: buildEnvName,
                timestamp: new Date().toISOString(),
                publicPath: "/child/edu",
              },
              null,
              2
            )
          );
        });
      },
    });
  },
};

核心改进点

  1. 优先使用 CI/CD 传入的时间戳:通过 BUILD_TIMESTAMP 环境变量,让 Jenkins 统一管理版本号;
  2. 模块加载时确定兜底时间戳:如果没有传入环境变量,在模块加载时(而非函数调用时)确定时间戳,避免多进程构建时产生不同版本号;
  3. 全局复用版本号buildVersionbuildEnvName 在模块顶层确定后,被 DefinePlugin 和 version.json 生成逻辑共同使用。

这样即使构建过程持续 5 ~ 10 分钟,注入的版本号和静态文件里的版本仍保持一致。这其实是把"构建产物视为不可变工件"的原则落地——保证任何使用该工件的入口看到的元数据都是同一个快照。

4.3 版本检查器(Runtime Polling & Cache Busting)

javascript
class VersionChecker {
  currentVersion = process.env.APP_VERSION;
  publicPath = "/child/edu";
  checkInterval = 30 * 1000;

  init() {
    console.log(
      `📌 当前前端版本:${this.currentVersion}${process.env.APP_ENV})`
    );
    this.startChecking();
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" && !this.hasNotified) {
        this.checkForUpdate();
      }
    });
  }

  async checkForUpdate() {
    const url = `${this.publicPath}/version.json?t=${Date.now()}`;
    const response = await fetch(url, { cache: "no-store" });
    if (!response.ok) return;
    const latestInfo = await response.json();
    if (latestInfo.version !== this.currentVersion && !this.hasNotified) {
      this.hasNotified = true;
      this.stopChecking();
      this.showUpdateModal(latestInfo.version, latestInfo.env);
    }
  }
}

这里有两个容易被忽略的细节:

  1. fetch 显式加 cache: "no-store",再叠加时间戳参数,防止 CDN / 浏览器任何一层干预;
  2. visibilitychange 监听,保证窗口重新激活时立即比对,避免用户在后台等了很久才看到弹窗。

入口 main.ts 在应用 mount 之后调用 versionChecker.init(),即可把整个检测链路串起来。

4.4 Nginx 缓存策略(Precise Cache Partition)

nginx
location / {
    if ($request_filename ~* \.html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location /child/edu {
    if ($request_filename ~* \.html$) {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

location ~* /child/edu/version\.json$ {
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    add_header Surrogate-Control "no-store";
}

这一层的思路是把资源分成两类:需要实时性(HTML、version.json)就 no-store,其余走长缓存。再配合 try_files 兜底 history 路由,微前端子应用的独立部署不会互相影响。

4.5 CI/CD 配置(Zero-touch Pipeline)

推荐方案:在 Jenkins 构建命令中传入统一的构建时间戳,确保同一构建任务的所有进程使用相同版本号:

环境构建命令(推荐)构建命令(简化版)说明
developBUILD_TIMESTAMP=$(date +%Y%m%d%H%M) npm install && npm run build-developnpm run build-develop日常开发验证
testingBUILD_TIMESTAMP=$(date +%Y%m%d%H%M) npm install && npm run build-testingnpm run build-testing集成测试
releaseBUILD_TIMESTAMP=$(date +%Y%m%d%H%M) npm install && npm run build-releasenpm run build-release预发布
productionBUILD_TIMESTAMP=$(date +%Y%m%d%H%M) npm install && npm run build-productionnpm run build-production线上

Windows Jenkins 节点(PowerShell)

powershell
$env:BUILD_TIMESTAMP = (Get-Date -Format "yyyyMMddHHmm"); npm install; npm run build-develop

说明

  • 推荐方案:传入 BUILD_TIMESTAMP 环境变量,由 CI/CD 流水线统一管理时间戳,保证同一构建任务版本号完全一致
  • 简化方案:不传入时间戳时,代码会在模块加载时确定时间戳,在多进程场景下仍可能出现细微差异(概率较低)
  • 如需精确控制版本号,建议采用推荐方案

所有命令都带 cross-env NODE_OPTIONS=--openssl-legacy-provider,以兼容不同系统的 OpenSSL 版本。更重要的是,这套方案没有"要求运维多做一步"——构建产物天然携带 version.json,任何环境拿到包即可上线。

5. 测试与验证

我们定义了一个完整的回归流程,确保方案不会给测试和上线带来额外负担:

  1. 首次访问:打开 dev 环境页面,确认控制台打印版本号,Network 里能看到 version.json 且响应头无缓存;
  2. 触发新版本:调整任意文案,重新发布,保持旧页面不刷新;
  3. 轮询验证:30 秒内弹出提示框,展示当前/最新版本和环境;
  4. 交互路径
    • 点击"立即刷新":页面强制 reload,新版本生效;
    • 点击"稍后刷新":记录取消动作并重新开启轮询;
  5. 边界场景:切 tab / 清缓存 / 新设备访问 / 短时间连续发布,均能正确感知最新版本。

6. 注意事项与常见问题

现象可能原因解决方案
没有弹窗version.json 404 或版本未变检查部署路径、确认构建是否生成文件
弹窗后刷新仍旧版本静态资源被缓存核实 Nginx 缓存策略、查看浏览器缓存设置
构建失败cross-env 未安装或权限不足补充依赖、确保 Jenkins 工作目录可写
持续误报更新构建阶段多次生成版本号vue.config.js 顶部缓存 buildVersion 并全局复用

7. 落地成效

  • 旧页面用户在 30 秒内收到提醒,测试效率显著提升;
  • "幽灵弹窗"彻底消失,版本对比逻辑稳定;
  • 方案只触碰前端与 Nginx 配置,发布流程无需改造;
  • 文档化后,其他子应用无需重复思考,直接复用。

8. 展望

下一步我们计划:

  1. 封装通用 SDK:抽象版本生成、轮询、弹窗逻辑,支持 Vue CLI / Vite;
  2. 可视化版本面板:在主应用汇总所有环境的版本和发布时间;
  3. 差异化策略:针对高优先级版本强制刷新,普通版本允许用户自行选择。

这次实践让我再次意识到:真正的坑往往藏在看似"微不足道"的细节里。当我们把问题和思考写成文档、沉淀成模板,团队就能以更小的代价获得更稳定的交付。如果你也在推进微前端版本同步,欢迎交流、互相借鉴。

基于 VitePress 构建