返回文章

H5 版本检测与热修复后的页面重载

从构建时注入版本号到运行时轮询 version.json,梳理一套 H5 增量发布的自动检测方案:如何判断版本差异、在哪些时机触发检查,以及为什么只在安全路由上弹出重载提示。

1. 背景

在一个长期驻留的 H5 里,发布这件事并不像「重新部署,用户刷新一下」这么简单。

典型场景是这样的:运营修了一个线上问题,前端重新构建、把 dist 覆盖到服务器。但此时大量用户的页面还是老的——微信内置浏览器没有关闭、App 的 WebView 没有被销毁、或者用户只是把标签页放在后台。这些用户的页面不会因为服务端换了文件就自动更新。

带来的后果有两类:

  1. 交互不一致:用户看到的功能还是旧的,反馈「改了没生效」,排查成本很高;
  2. 更严重的是运行时崩溃:SPA 是按需加载 chunk 的,老页面持有的 index.html 引用的是带 hash 的老文件名。如果部署时把旧文件清理掉了,用户再进一个新路由,就会去请求一个已经不存在的 chunk,直接白屏。

所以需要的不是「提示用户手动强刷」,而是让 应用自己能感知到自己已经过时了,并在一个合适的时机温和地推动用户重载。

本文记录的是水务智慧系统 H5 端(Vue3 + Vite + Vant)里这套机制的完整实现:核心代码只有一个 src/utils/h5-update.ts,配套一个构建期 Vite 插件和一个版本声明文件。

2. 整体方案

方案的核心思路是准备两个版本号,一个在构建时被编译进代码,一个在部署时作为静态文件产出,运行时把两者拉出来对一对:

版本号 来源 存放位置 谁读它
当前版本 构建时的 git describe --tags 编译进代码,挂在 window.__H5_Ver 运行中的应用自己
线上版本 同一次构建产出的 tag 字段 构建产物 version.json 运行时 fetch 拉取

只要这两个 tag 不一致,就说明「线上已经有一份新产物,而我手里的是旧的」,此时触发一次重载。

整条链路分成构建期和运行期两段:

【构建期】
  git describe --tags --abbrev=0
          │
          ├──► version-plugin.transform  ──►  替换 src/utils/version.ts 里的占位符
          │                                         │
          │                                         └──► getBuildInfo().tag
          │                                                   │
          │                                                   └──► window.__H5_Ver   ← 当前版本
          │
          └──► version-plugin.generateBundle  ──►  emitFile('version.json')      ← 线上版本

【运行期】
  initH5VersionCheck(router)
          │
          ├─ 启动后延迟 5s        ─┐
          ├─ 每 5 分钟轮询一次     ─┼──► triggerVersionCheck()  ──► fetch version.json(穿透缓存)
          └─ 每次路由切换后        ─┘                                 │
                                                                     ▼
                                                        checkH5Version()  比对 tag
                                                                     │
                                                          tag 不一致 ─┘
                                                                     ▼
                                                        handleReloadView()
                                                          ├─ 必须处于「安全路由」
                                                          ├─ showDialog 提示
                                                          ├─ delAllCachedViews()
                                                          └─ window.location.reload()

下面按「构建期 → 运行期 → 弹窗重载」的顺序逐个拆开。

3. 构建期:让版本号能被读到

3.1 用 Vite 插件把版本注入代码并产出文件

scripts/version-plugin.ts 是一个自写的 Vite 插件,它只做两件事:往代码里填版本,以及往产物里丢一个 version.json。

import { execSync } from 'child_process';
import type { Plugin } from 'vite';

interface VersionInfo {
  tag: string;
  branch: string;
  buildTime: string;
}

/**
 * 封装执行 Git 命令,增加容错处理
 */
const runGitCommand = (command: string): string => {
  try {
    return execSync(command, {
      cwd: process.cwd(),
      encoding: 'utf-8',
      stdio: ['ignore', 'pipe', 'ignore']
    }).trim();
  } catch {
    return 'unknown';
  }
};

/**
 * 获取 Git 核心信息(Tag, Branch, BuildTime)
 */
const resolveVersionInfo = (): VersionInfo => ({
  // 获取最近的标签
  tag: runGitCommand('git describe --tags --abbrev=0') || 'no-tag',
  // 获取当前分支名
  branch: runGitCommand('git rev-parse --abbrev-ref HEAD'),
  // 使用标准的 ISO 格式时间
  buildTime: new Date().toISOString()
});

然后是插件本体:

export const versionPlugin = (): Plugin => {
  const info = resolveVersionInfo();

  return {
    name: 'version-plugin',

    // 代码注入逻辑
    transform: (code: string, id: string) => {
      if (id.includes('src/utils/version.ts')) {
        let transformedCode = code;

        // 替换对应的占位符
        transformedCode = transformedCode
          .replace('__APP_TAG__', JSON.stringify(info.tag))
          .replace('__APP_BRANCH__', JSON.stringify(info.branch))
          .replace('__APP_BUILD_TIME__', JSON.stringify(info.buildTime));

        return {
          code: transformedCode,
          map: null
        };
      }
      return null;
    },

    // 生成产物文件
    generateBundle: function () {
      this.emitFile({
        type: 'asset',
        fileName: 'version.json',
        source: JSON.stringify(
          {
            tag: info.tag,
            branch: info.branch,
            buildTime: info.buildTime
          },
          null,
          2
        )
      });
    }
  };
};

这里有两个值得说的设计点。

第一,版本信息只算一次。 resolveVersionInfo() 是在 versionPlugin() 被调用时执行一次,而不是在 transform 里每次都跑 execSync。transform 会对每个被匹配到的模块触发,如果把它放在 transform 里,就是无数次同步子进程调用,构建会明显变慢。

第二,transform 只认 src/utils/version.ts 这一个文件。 通过 id.includes(...) 精确匹配,把占位符替换限定在一个文件内。这比全局 define 更可控:如果哪天需要加一个 commitId,只需要在这个文件的占位符列表里加一行,其它代码完全不用感知。

注意 transform 用的是字符串替换而不是 AST,所以占位符必须写成带引号的字符串字面量形式,替换进去的也是 JSON.stringify() 过的合法字面量:

.replace('__APP_TAG__', JSON.stringify(info.tag))

JSON.stringify('platform-h5-1.0.60.0001') 得到 "platform-h5-1.0.60.0001",连引号一起塞进去,替换后正好是一个合法的 TS 字符串字面量。

3.2 声明占位符

src/utils/version.ts 只有十几行,它的作用是给占位符一个类型声明,让 TS 不报错,同时暴露一个读取入口:

declare const __APP_TAG__: string;
declare const __APP_BRANCH__: string;
declare const __APP_BUILD_TIME__: string;

/**
 * 构建时由 Vite 插件注入当前构建版本信息
 */
export const getBuildInfo = () => {
  return {
    tag: __APP_TAG__,
    branch: __APP_BRANCH__,
    buildTime: __APP_BUILD_TIME__
  };
};

被替换前的源码里 __APP_TAG__ 是一个裸标识符,靠 declare const 让类型检查通过;构建时插件把它整体替成字符串字面量,跑起来就是常量。

3.3 在入口挂到 window

main.ts 里挂载完成后,把 tag 写到一个全局变量上:

import { getBuildInfo } from "@/utils/version";
import { initH5VersionCheck } from "@/utils/h5-update";

// ... app.use / mount ...

window.__H5_Ver = getBuildInfo().tag;
initH5VersionCheck(router);

配套的全局类型在 src/typings/global.d.ts:

declare global {
  interface Window {
    __H5_Ver: string;
  }
}

为什么绕一圈挂到 window 上,而不是直接从 h5-update.ts 里 import { getBuildInfo }?

因为 h5-update.ts 需要同时工作在两种环境下。走 window 读值,可以在控制台里直接:

window.__H5_Ver          // 看当前页面是哪个版本

排查「用户到底跑的是哪一版」时非常方便,不用去翻 sourcemap。这也是这个字段最初就存在的形态——改造前它是从 README.md 里正则读出来的(getVersionFromReadme()),后来才换成构建时注入 git tag。

3.4 为什么版本号取 git tag

三个候选来源对比下来,git tag 是唯一「部署动作」和「版本号」强绑定的:

来源 问题
package.json 的 version 手动维护,容易忘记改;一次迭代常发多次版本
README.md 里记一行(旧方案) 靠正则解析,格式一变就崩;同样依赖人工
git describe --tags --abbrev=0 由发布流程产生,打 tag 就意味着「这是一个要发布的版本」

用 git describe --tags --abbrev=0 而不是 git describe --tags,是要拿最近的 tag 本身(platform-h5-1.0.60.0001),而不是「tag + 距离 + hash」这种描述串(platform-h5-1.0.60.0001-12-gab12cd3)。因为我们只在打 tag 发版时才构建部署,tag 相同即版本相同。

另外 runGitCommand 里 catch 后返回 'unknown' 而不是抛错,是为了让构建在非 git 环境(比如用 tar 包构建、CI 里没配 git)也能跑通,不至于因为拿不到版本号就整体失败。

3.5 产物长什么样

构建完成后,产物根目录会多出一个 version.json(和 index.html 同级):

{
  "tag": "platform-h5-1.0.60.0001",
  "branch": "60J",
  "buildTime": "2026-10-15T10:12:46.265Z"
}

branch 和 buildTime 目前只在排查问题时用得到(确认线上跑的是哪个分支、什么时候构建的),真正的判定依据只有 tag。

4. 运行期:三个触发时机

运行期的全部逻辑在 src/utils/h5-update.ts。先看入口:

export const initH5VersionCheck = (router: Router): void => {
    if (import.meta.env.MODE === "development") {
        return;
    }

    if (updateState.initialized) {
        return;
    }

    updateState.initialized = true;

    setTimeout(() => {
        void triggerVersionCheck(router);
    }, STARTUP_DELAY);

    updateState.intervalId = setInterval(() => {
        void triggerVersionCheck(router);
    }, VERSION_CHECK_INTERVAL);

    router.afterEach(to => {
        if (isSafeRoute(to.name)) {
            void triggerVersionCheck(router);
        }

        void handleReloadView(router);
    });
};

四个设计意图:

① 开发环境直接短路。 import.meta.env.MODE === "development" 就 return。本地开发时 version.json 是上一次构建的残留,tag 必然和当前(未经构建注入的)版本对不上,如果不拦掉,本地调试会疯狂弹重载提示。

② 幂等。 initialized 标志保证重复调用 initH5VersionCheck 不会注册出多个 setInterval。这在 HMR 场景下很关键。

③ 启动延迟 5 秒。

const STARTUP_DELAY = 5 * 1000; // 启动延迟

不在应用启动瞬间就发起检测。首屏要抢带宽(接口、图片、chunk),此时再插一个网络请求是纯粹的负收益;而「用户已经在用旧版本」这件事,早 5 秒发现和晚 5 秒发现没有区别。所以让首屏先跑完。

④ 三种触发来源,互为补充。

触发源 间隔 解决的问题
启动延迟后检查一次 一次性,5s 用户刚打开就在旧版本的情况
setInterval 轮询 5 分钟 用户页面一直开着、没有导航动作
router.afterEach 每次跳转 用户开始操作时的即时感知

三者叠加不是冗余。浏览器对后台标签页的 setInterval 有强制节流(最小间隔会被拉长到 1 分钟以上,且在后台标签被冻结时可能完全不执行),所以「只靠轮询」在移动端是不可靠的。而用户一旦开始操作,必然发生路由变化,afterEach 就是那个最可靠的补偿点。

注意 triggerVersionCheck 和 handleReloadView 都是 void 调用的——它们内部已经各自 try/finally 处理了异常,不需要也不应该在路由钩子这种同步返回的位置 await,否则会拖住导航。而 triggerVersionCheck 的第一次调用(setTimeout 里)用 void 是为了显式标注「这个 Promise 是故意不处理的」。

5. 请求与节流

5.1 拉取 version.json:三层缓存穿透

const triggerVersionCheck = async (router: Router): Promise<void> => {
    const now = Date.now();
    if (updateState.isChecking || now - updateState.lastCheckAt < MIN_CHECK_GAP) {
        return;
    }

    updateState.lastCheckAt = now;
    updateState.isChecking = true;

    try {
        const latestManifest = await getLatestVersionConfig();
        await checkH5Version(router, latestManifest);
    } finally {
        updateState.isChecking = false;
    }
};

这里是双重节流:

  • isChecking:防止并发。上一次请求还没回来,就不发第二个。
  • lastCheckAt + MIN_CHECK_GAP(30 秒):防止高频触发。因为三个触发源是并行的,用户连续快速切 5 个 tab,afterEach 会连着触发 5 次;有 30 秒的最小间隔兜底,实际只会发一次请求。

这两个状态都是先更新再请求(lastCheckAt = now; isChecking = true; 都在 await 之前),保证并发场景下第二次进入时能立刻被拦掉。finally 里复位 isChecking,避免请求抛错后永久卡死。

真正的网络请求:

const getVersionJsonUrl = (): string => {
    const base = getBaseUrl();
    return `${base}${VERSION_FILE_PATH}?_ts=${Date.now()}`;
};

const getLatestVersionConfig = async (): Promise<VersionManifest | null> => {
    try {
        const response = await fetch(getVersionJsonUrl(), {
            cache: "no-store",
            headers: {
                Pragma: "no-cache",
                "Cache-Control": "no-cache, no-store, must-revalidate",
            },
        });

        if (!response.ok) {
            console.warn("[H5 Update] 获取 version.json 失败:", response.status);
            return null;
        }

        return (await response.json()) as VersionManifest;
    } catch (error) {
        console.warn("[H5 Update] 请求 version.json 异常:", error);
        return null;
    }
};

这里是整套机制里最容易踩坑的地方:如果 version.json 被缓存了,整个方案就失效了——每次拿到的都是浏览器里那份旧的,tag 永远和本地的相同,永远不会提示更新。

所以做了三重保险:

  1. URL 加时间戳:?_ts=${Date.now()},每次请求的 URL 都不同,直接绕过浏览器缓存和大部分 CDN 的 URL 级缓存;
  2. cache: "no-store":告诉浏览器的 HTTP 缓存层不要存储也不要复用这份响应;
  3. 显式请求头:Pragma / Cache-Control 是给那些不认 fetch 的 cache 选项的中间代理(老网关、反向代理)看的。

而 getBaseUrl() 会处理 base 结尾有没有斜杠:

const getBaseUrl = (): string => {
    const base = import.meta.env.BASE_URL || "/";
    return base.endsWith("/") ? base : `${base}/`;
};

因为项目的 base 来自环境变量(VITE_PUBLIC_PATH),可能是 / 也可能是 /platform-h5,不补斜杠就会拼出 /platform-h5version.json。

5.2 失败要静默

getLatestVersionConfig 的两个 catch 都返回 null,只打 console.warn,不抛错、不弹 toast。

原因是这个检查是旁路逻辑:它失败不影响用户使用任何功能。如果网络抖动就弹一个错误提示,只会制造噪音。返回 null 之后,checkH5Version 会因为拿不到 latestVersion 而直接 return,等价于「这次不检查」,自然降级。

6. 版本比对与安全路由

6.1 比对逻辑

export const checkH5Version = async (router: Router, manifest: VersionManifest | null): Promise<void> => {
    const latestVersion = manifest?.tag?.trim();
    const currentVersion = getCurrentBuildVersion().trim();

    if (!latestVersion || !currentVersion) {
        return;
    }

    if (latestVersion === currentVersion) {
        updateState.hasNewVersion = false;
        updateState.latestVersion = "";
        return;
    }

    updateState.hasNewVersion = true;
    updateState.latestVersion = latestVersion;

    await handleReloadView(router);
};

几点说明:

  • 两边都 trim():version.json 里的 tag 是 git describe 出来的,可能带换行残留;window.__H5_Ver 在非构建环境下可能是 undefined,.trim() 会…等等,这里其实有个隐患——getCurrentBuildVersion() 返回的是 (window as any).__H5_Ver || "",|| "" 保证了它是字符串,所以 .trim() 不会炸。
  • 任一侧为空就跳过:拿不到线上版本(请求失败)或拿不到本地版本(没经过构建注入)时,不下任何结论。宁可漏报,不可误报——误报的代价是让一个本来就是新版本的用户被强制重载。
  • 相等时显式复位:hasNewVersion = false; latestVersion = "";。因为 updateState 是模块级单例,如果本地曾经判定「有新版本」但用户一直没重载(比如一直待在非安全路由),后续检查发现已经一致了,必须把这个标志清掉。

6.2 只有「安全路由」才弹窗

const SAFE_ROUTE_NAMES = new Set(["home", "concern", "message", "alarm", "user"]);

// 检查是否为安全路由
const isSafeRoute = (routeName?: string | symbol | null): boolean => {
    return typeof routeName === "string" && SAFE_ROUTE_NAMES.has(routeName);
};

这五个名字正好是 H5 的五个 tabbar 主页面。之所以要白名单,是因为 window.location.reload() 是破坏性操作:它会丢掉页面上所有未提交的状态。

想象一下用户正在「工单上报」表单里填了三百字的故障描述,此时因为后台轮询发现新版本,弹窗一按,内容全没了。这不是体验问题,是数据丢失事故。

所以策略是:

  • 不在安全路由上,什么都不做,只是把 hasNewVersion = true 记在内存里;
  • 一旦用户回到 tabbar 页面,router.afterEach 里立刻调 handleReloadView,此时再弹。
router.afterEach(to => {
    if (isSafeRoute(to.name)) {
        void triggerVersionCheck(router);
    }

    void handleReloadView(router);
});

注意这里两个调用的判断条件是不一样的,容易看漏:

  • triggerVersionCheck(去请求线上版本):只在进入安全路由时才做——因为发起请求的目标是后续能弹窗,在表单页请求没意义;
  • handleReloadView(去判断要不要弹窗):每次路由跳转都调。

第二个之所以不判断,是因为 handleReloadView 内部自己会查 isSafeRoute。这样写的好处是:如果这次跳转的目标不是安全路由,函数会在第一行附近就 return,成本只是一次函数调用;而如果目标是安全路由,它就一定能被检查到。反过来如果外层也加 if,两个条件就容易在后续维护中走偏。

6.3 updateState 的六个字段

const updateState = {
    hasNewVersion: false,     // 是否有新版本
    latestVersion: "",        // 最新版本
    dialogVisible: false,     // 弹窗是否可见
    reloadConfirmed: false,   // 是否已确认刷新
    initialized: false,       // 初始化状态
    lastCheckAt: 0,           // 上次检查时间
    isChecking: false,        // 是否检查中
    intervalId: null as ReturnType<typeof setInterval> | null, // 轮询间隔ID
};

把它们按用途分组后,职责很清楚:

字段 分组 作用
hasNewVersion 判定结果 是否已经确认存在新版本
latestVersion 判定结果 线上版本号,用于调试/上报
dialogVisible 弹窗互斥锁 防止同时弹出多个重载弹窗
reloadConfirmed 弹窗互斥锁 用户确认后置位,避免重载前再弹
initialized 初始化幂等 防止重复注册 interval 和路由钩子
lastCheckAt / isChecking 请求节流 时间窗口 + 并发控制
intervalId 生命周期 持有轮询句柄

用模块级对象而不是 reactive,是因为这个状态不需要驱动 UI。它只被 h5-update.ts 内部读写,不参与渲染,用普通对象就避免了无意义的响应式开销。

7. 弹窗与重载

这是最核心的一段,也正好是文章开头那行 showDialog 所在的位置:

const handleReloadView = async (router: Router): Promise<void> => {
    if (updateState.hasNewVersion === false || updateState.dialogVisible || updateState.reloadConfirmed) {
        return;
    }

    const currentRouteName = router.currentRoute.value.name;
    if (!isSafeRoute(currentRouteName)) {
        return;
    }

    updateState.dialogVisible = true;

    try {
        await showDialog({
            title: "提示",
            message: "页面已更新,请重新加载以获取最新内容",
        });

        updateState.reloadConfirmed = true;
        cachedViewStore.delAllCachedViews();
        window.location.reload();
    } finally {
        updateState.dialogVisible = false;
    }
};

7.1 三重前置守卫

函数开头的两个 if 一共挡了四种情况,每一种都对应一个真实的重复弹窗场景:

条件 挡住的问题
hasNewVersion === false 没有新版本,什么都不用做
dialogVisible 弹窗已经开着,别再弹一个(并发/重入)
reloadConfirmed 用户已经点过确认,reload() 生效前的窗口期内不要再弹
!isSafeRoute(...) 当前页可能是表单页,不能打断

其中 reloadConfirmed 这个锁是必要的,因为 window.location.reload() 不是同步完成的——从调用到页面真正开始卸载,中间还有一小段时间。如果这段窗口里刚好又触发了一次 afterEach,没有这个锁就会再弹一次。

注意这里判断的是 router.currentRoute.value.name,也就是当前实际停留的路由,而不是 afterEach(to => ...) 里的 to。这保证 handleReloadView 在任何调用点(无论是路由钩子还是别的地方)判断依据都是同一个,行为可预期。

7.2 用 showDialog 而不是直接 reload

关键取舍在这里:明明可以 window.location.reload() 一把刷新,为什么要多一个弹窗?

因为「检测到新版本」和「现在必须刷新」是两件事。用户可能正在打电话记录数据、正在等一个查询结果、正在看一段视频。自动刷新会直接打断他。

弹窗把决定权交给用户——点「确认」才刷。这是牺牲一点时效性换来的体验安全。并且因为已经有安全路由白名单兜底,能弹窗的页面(首页、关注、消息、告警、我的)本身就是「刷新不会丢东西」的页面,风险是可控的。

7.3 finally 里的复位

} finally {
    updateState.dialogVisible = false;
}

这个 finally 覆盖了两条路径:

  • 用户点了确认:showDialog resolve → 设置 reloadConfirmed、清缓存、reload()。虽然随后页面就卸载了,但 finally 仍会执行,状态是干净的;
  • 用户拒绝/关闭了弹窗:showDialog 的 Promise 在 Vant 里是会 reject 的(点遮罩、按返回、点关闭图标都算取消)。此时 await 抛错,reloadConfirmed 不会置位,reload() 不会执行,异常继续向上抛——但 dialogVisible 会在 finally 里复位为 false。

于是下一次路由切换时,handleReloadView 又能重新进入并弹窗。这实际上是一个隐式的「稍后提醒」:用户点了取消,下次换页面会再问一次。

(不过严格说,这里 showDialog reject 后异常会从 handleReloadView 抛出去,而 handleReloadView 的调用点基本都是 void handleReloadView(router),会留下一个 unhandled rejection。目前它只在控制台可见、不影响功能,若要做到干净,可以在 await 外面包一层 catch,或者给 showDialog 传 showCancelButton: false 让它只在确认时 resolve。)

7.4 delAllCachedViews 与 keepAlive

cachedViewStore.delAllCachedViews();

这行调用的是 cached-view store:

export const useCachedViewStore = defineStore({
  id: "cached-view",
  state: () => ({
    // 缓存页面 keepAlive
    cachedViewList: [] as string[]
  }),
  actions: {
    addCachedView(view: Partial<RouteLocationNormalized>) {
      if (this.cachedViewList.includes(view.name as string)) return;
      if (view?.meta?.cache) {
        this.cachedViewList.push(view.name as string);
      }
    },
    delCachedView(view: Partial<RouteLocationNormalized>) {
      const index = this.cachedViewList.indexOf(view.name as string);
      index > -1 && this.cachedViewList.splice(index, 1);
    },
    delAllCachedViews() {
      this.cachedViewList = [] as string[];
    }
  }
});

cachedViewList 是给 <keep-alive :include="cachedViewList"> 用的白名单,列表里存的是路由名。项目里大量路由标了 meta.cache: true(concern、message、alarm、curve、diagnosis、equ 等,覆盖面很广),这些页面被 keep-alive 缓存住,切走再切回不会重建。

清空它的意图很明确:让缓存页面在重载后拿到全新的组件实例,避免残留的旧状态。

不过要诚实地说,严格来看这行在当前实现下是冗余的。因为:

  1. cached-view 是普通 Pinia store,src/store/index.ts 里就是朴素的 createPinia(),没有接任何持久化插件;
  2. 紧接着调用的 window.location.reload() 会重建整个 JS 运行环境,内存里的 store 自然全部归零。

也就是说,即使不调 delAllCachedViews(),重载后的 cachedViewList 一样是空数组。

那为什么还留着?两个理由:

  • 防御性:这行代码表达的是「重载前必须让所有页面缓存失效」这个业务意图。如果将来把 reload() 换成更温和的方案(比如做局部热替换、或者改成 router.replace + 重新拉取数据),这行调用立刻就从冗余变成必需;
  • 顺序自解释:delAllCachedViews() → reload() 这个顺序读起来就是「先清缓存,再重载」,语义完整。删掉它反而会让后来者疑惑「重载后 keep-alive 的旧缓存是怎么处理的」。

这类「当前无用但语义必要」的代码,判断标准是:它是否在表达一个领域中真实存在的约束。是,就留着;只是为了「以防万一」,就该删。

8. 完整代码

src/utils/h5-update.ts 全文如下:

import { showDialog } from "vant/es";

import type { Router } from "vue-router";
import { useCachedViewStoreHook } from "@/store/modules/cachedView";

const SAFE_ROUTE_NAMES = new Set(["home", "concern", "message", "alarm", "user"]);
const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // 轮询间隔
const MIN_CHECK_GAP = 30 * 1000; // 最短检查间隔
const STARTUP_DELAY = 5 * 1000; // 启动延迟
const VERSION_FILE_PATH = "version.json";

const cachedViewStore = useCachedViewStoreHook();

interface VersionManifest {
    tag?: string;
    branch?: string;
    buildTime?: string;
}

const updateState = {
    hasNewVersion: false, // 是否有新版本
    latestVersion: "", // 最新版本
    dialogVisible: false, // 弹窗是否可见
    reloadConfirmed: false, // 是否已确认刷新
    initialized: false, // 初始化状态
    lastCheckAt: 0, // 上次检查时间
    isChecking: false, // 是否检查中
    intervalId: null as ReturnType<typeof setInterval> | null, // 轮询间隔ID
};

const getBaseUrl = (): string => {
    const base = import.meta.env.BASE_URL || "/";
    return base.endsWith("/") ? base : `${base}/`;
};

const getVersionJsonUrl = (): string => {
    const base = getBaseUrl();
    return `${base}${VERSION_FILE_PATH}?_ts=${Date.now()}`;
};

// 获取最新版本配置
const getLatestVersionConfig = async (): Promise<VersionManifest | null> => {
    try {
        const response = await fetch(getVersionJsonUrl(), {
            cache: "no-store",
            headers: {
                Pragma: "no-cache",
                "Cache-Control": "no-cache, no-store, must-revalidate",
            },
        });

        if (!response.ok) {
            console.warn("[H5 Update] 获取 version.json 失败:", response.status);
            return null;
        }

        return (await response.json()) as VersionManifest;
    } catch (error) {
        console.warn("[H5 Update] 请求 version.json 异常:", error);
        return null;
    }
};

// 获取当前构建版本
const getCurrentBuildVersion = (): string => {
    return (window as any).__H5_Ver || "";
};

// 检查是否为安全路由
const isSafeRoute = (routeName?: string | symbol | null): boolean => {
    return typeof routeName === "string" && SAFE_ROUTE_NAMES.has(routeName);
};

const handleReloadView = async (router: Router): Promise<void> => {
    if (updateState.hasNewVersion === false || updateState.dialogVisible || updateState.reloadConfirmed) {
        return;
    }

    const currentRouteName = router.currentRoute.value.name;
    if (!isSafeRoute(currentRouteName)) {
        return;
    }

    updateState.dialogVisible = true;

    try {
        await showDialog({
            title: "提示",
            message: "页面已更新,请重新加载以获取最新内容",
        });

        updateState.reloadConfirmed = true;
        cachedViewStore.delAllCachedViews();
        window.location.reload();
    } finally {
        updateState.dialogVisible = false;
    }
};

export const checkH5Version = async (router: Router, manifest: VersionManifest | null): Promise<void> => {
    const latestVersion = manifest?.tag?.trim();
    const currentVersion = getCurrentBuildVersion().trim();

    if (!latestVersion || !currentVersion) {
        return;
    }

    if (latestVersion === currentVersion) {
        updateState.hasNewVersion = false;
        updateState.latestVersion = "";
        return;
    }

    updateState.hasNewVersion = true;
    updateState.latestVersion = latestVersion;

    await handleReloadView(router);
};

const triggerVersionCheck = async (router: Router): Promise<void> => {
    const now = Date.now();
    if (updateState.isChecking || now - updateState.lastCheckAt < MIN_CHECK_GAP) {
        return;
    }

    updateState.lastCheckAt = now;
    updateState.isChecking = true;

    try {
        const latestManifest = await getLatestVersionConfig();
        await checkH5Version(router, latestManifest);
    } finally {
        updateState.isChecking = false;
    }
};

export const initH5VersionCheck = (router: Router): void => {
    if (import.meta.env.MODE === "development") {
        return;
    }

    if (updateState.initialized) {
        return;
    }

    updateState.initialized = true;

    setTimeout(() => {
        void triggerVersionCheck(router);
    }, STARTUP_DELAY);

    updateState.intervalId = setInterval(() => {
        void triggerVersionCheck(router);
    }, VERSION_CHECK_INTERVAL);

    router.afterEach(to => {
        if (isSafeRoute(to.name)) {
            void triggerVersionCheck(router);
        }

        void handleReloadView(router);
    });
};

export const getH5VersionUpdateState = () => {
    return {
        hasNewVersion: updateState.hasNewVersion,
        latestVersion: updateState.latestVersion,
    };
};

getH5VersionUpdateState 目前没有调用方,是一个预留的口子:外部(比如一个「发现新版本」的小红点或底部条)可以拿它做非阻塞提示,而不必等到 handleReloadView 弹窗。这一点在下一节展开。

9. 可扩展与改进方向

这套实现能用、也够稳,但有几处如果继续演进可以做得更好。

9.1 tag 相同但内容不同

当前判定完全依赖 tag 相等。如果发布流程是「打了 tag 之后再往同一 tag 上补一个热修提交」,那么线上版本号没变,检测就失效了。

更严格的方案是比对构建产物的指纹,比如:

  • 用 buildTime 一起比对(但 buildTime 精度到毫秒,每次构建必不同,会导致「只要重新构建就提示」,对热修友好、对常规重建太激进);
  • 或者产出一个 version.hash(比如入口 chunk 的文件名,本身带 content hash),用它做判定。

选择哪个取决于发布纪律:如果团队严格遵守「发版必打新 tag」,现在的方案是最干净的。

9.2 从「强制弹窗」到「非阻塞提示」

showDialog 是一个模态弹窗,用户必须先处理它。更轻的做法是:

  • 在「我的」页面或 tabbar 上挂一个红点,点击才弹窗;
  • 或者底部弹一条 showNotify,用户可以忽略。

这需要把内部状态暴露出去——getH5VersionUpdateState 已经准备好了,把它接到 UI 上,再把 handleReloadView 拆成「检测」和「执行重载」两步即可:

// 检测阶段:只置标志、不弹窗
// UI 阶段:订阅 state,决定用红点还是弹窗
// 执行阶段:用户点击后 handleReloadView()

9.3 生命周期清理

updateState.intervalId 已经保存了 setInterval 的句柄,但目前没有任何地方清理它。在单页应用里这通常无所谓(页面的生命周期等于应用的生命周期),但如果将来要做「登出后停止检测」或者把这段逻辑跑在组件作用域里,就需要补一个:

export const destroyH5VersionCheck = (): void => {
    if (updateState.intervalId) {
        clearInterval(updateState.intervalId);
        updateState.intervalId = null;
    }
    updateState.initialized = false;
};

9.4 抽成可配置的模块

现在几个关键参数都是硬编码常量:

const SAFE_ROUTE_NAMES = new Set(["home", "concern", "message", "alarm", "user"]);
const VERSION_CHECK_INTERVAL = 5 * 60 * 1000;
const MIN_CHECK_GAP = 30 * 1000;
const STARTUP_DELAY = 5 * 1000;
const VERSION_FILE_PATH = "version.json";

抽象成 createH5VersionChecker({ safeRoutes, interval, minGap, startupDelay, filePath }) 之后,就能在两个仓库(H5 和 PC)之间复用了——尤其是 SAFE_ROUTE_NAMES,换个项目必然不一样,是最该外置的一项。

9.5 服务端配合

客户端做了三层缓存穿透,但服务端和 CDN 也要配合:version.json 的响应头应该带 Cache-Control: no-cache(或者很短的 max-age),否则请求到了 CDN 边缘节点,被节点缓存住了,客户端的 no-store 也救不回来。这一条属于部署配置,很容易在排查「检测不生效」时被忽略。

10. 小结

把这套机制压缩成几个判断,是这样几条:

  • 两个版本号,一个来源:都来自同一次构建的 git describe --tags,一个编译进代码(window.__H5_Ver),一个作为静态文件产出(version.json),比对才有意义;
  • 三个触发时机:启动延迟、5 分钟轮询、路由切换后。三者互补,因为浏览器会节流后台的定时器;
  • 两层节流:isChecking 防并发,MIN_CHECK_GAP 防高频;
  • 三重缓存穿透:时间戳 URL、cache: no-store、显式 Cache-Control 请求头;
  • 安全路由白名单:reload() 会丢状态,所以只在 tabbar 这类「刷新无损失」的页面上弹;
  • 弹窗而不是自动刷新:把重载的决定权留给用户;
  • 全程静默降级:请求失败只打 warn,绝不因为旁路逻辑失败打扰用户。

而这套东西的全部核心,就是文章最初那几行:

await showDialog({
    title: "提示",
    message: "页面已更新,请重新加载以获取最新内容",
});

它的价值不在 showDialog 本身,而在于这句话被允许在什么时机、什么页面上说出来——前面所有的状态机、节流、白名单和缓存穿透,都是为了让这一句提示既不会误报,也不会在不合适的时候打断用户。