1. 背景
在一个长期驻留的 H5 里,发布这件事并不像「重新部署,用户刷新一下」这么简单。
典型场景是这样的:运营修了一个线上问题,前端重新构建、把 dist 覆盖到服务器。但此时大量用户的页面还是老的——微信内置浏览器没有关闭、App 的 WebView 没有被销毁、或者用户只是把标签页放在后台。这些用户的页面不会因为服务端换了文件就自动更新。
带来的后果有两类:
- 交互不一致:用户看到的功能还是旧的,反馈「改了没生效」,排查成本很高;
- 更严重的是运行时崩溃: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 永远和本地的相同,永远不会提示更新。
所以做了三重保险:
- URL 加时间戳:
?_ts=${Date.now()},每次请求的 URL 都不同,直接绕过浏览器缓存和大部分 CDN 的 URL 级缓存; cache: "no-store":告诉浏览器的 HTTP 缓存层不要存储也不要复用这份响应;- 显式请求头:
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 覆盖了两条路径:
- 用户点了确认:
showDialogresolve → 设置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 缓存住,切走再切回不会重建。
清空它的意图很明确:让缓存页面在重载后拿到全新的组件实例,避免残留的旧状态。
不过要诚实地说,严格来看这行在当前实现下是冗余的。因为:
cached-view是普通 Pinia store,src/store/index.ts里就是朴素的createPinia(),没有接任何持久化插件;- 紧接着调用的
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 本身,而在于这句话被允许在什么时机、什么页面上说出来——前面所有的状态机、节流、白名单和缓存穿透,都是为了让这一句提示既不会误报,也不会在不合适的时候打断用户。