一、背景与目标
1.1 问题场景
普通 H5(非 App 内嵌、非离线包)部署新版本后,已经打开页面的用户仍在使用旧版本代码,无法感知到更新。这会导致:
- 功能不一致:新旧版本 API 不兼容,旧页面调用接口报错
- Bug 未修复:用户停留在有 Bug 的旧版本,持续受影响
- 体验割裂:不同用户看到不同的页面效果
1.2 核心目标
检测新版本 → 通知用户 → 引导更新 → 保障数据安全
1.3 与 App 内嵌 H5 的区别
| 维度 | App 内嵌 H5 | 普通浏览器 H5 |
|---|---|---|
| 离线包 | 有,可静默更新 | 无,刷新即拉取最新 |
| 更新检测 | App 原生层控制 | 纯前端 JS 实现 |
| 更新时机 | 启动时/后台静默 | 页面可见时/定时轮询 |
| 用户感知 | 可做到无感 | 必须用户主动刷新 |
二、整体架构
bash
┌─────────────────────────────────────────────────────┐
│ 服务端 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 版本接口 │ │ WebSocket│ │ 构建时注入 │ │
│ │ /version │ │ 推送 │ │ 版本号/构建时间 │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────┤
│ 前端层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 轮询检测 │ │ 可见性 │ │ 用户状态感知 │ │
│ │ │ │ 检测 │ │ (表单/编辑中) │ │
│ ├──────────┤ ├──────────┤ ├──────────────────┤ │
│ │ 版本对比 │ │ 提示策略 │ │ 数据保全 │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────┘
三、版本检测机制
3.1 版本号注入(构建时)
在项目构建时,将版本信息写入前端可访问的位置:
方案 A:index.html 注入 meta 标签
html
<!-- vite / webpack 构建时通过插件自动注入 -->
<meta name="app-version" content="20240513120000">
<meta name="build-hash" content="a1b2c3d4">
方案 B:公共版本 JSON 文件
arduino
public/
└── version.json # 内容:{ "version": "20240513120000", "hash": "a1b2c3d4" }
方案 C:JS 全局变量注入
html
<script>
window.__APP_VERSION__ = '20240513120000';
window.__BUILD_HASH__ = 'a1b2c3d4';
</script>
推荐方案 A + C 组合:meta 标签供通用检测脚本使用,全局变量供业务代码使用。
3.2 版本检测策略
策略 1:定时轮询(最通用)
javascript
class VersionChecker {
constructor(options = {}) {
this.currentVersion = options.currentVersion || window.__APP_VERSION__;
this.checkUrl = options.checkUrl || '/version.json';
this.interval = options.interval || 60 * 1000; // 默认 60s
this.timer = null;
this.listeners = [];
}
start() {
this.check(); // 立即检查一次
this.timer = setInterval(() => this.check(), this.interval);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async check() {
try {
// 添加随机参数避免缓存
const resp = await fetch(`${this.checkUrl}?t=${Date.now()}`, {
cache: 'no-cache',
headers: { 'Cache-Control': 'no-cache' }
});
const data = await resp.json();
if (data.version !== this.currentVersion) {
this.notify(data);
}
} catch (err) {
// 静默失败,不影响正常业务
console.warn('[VersionCheck] check failed:', err.message);
}
}
onUpdate(callback) {
this.listeners.push(callback);
}
notify(newVersion) {
this.listeners.forEach(fn => fn(newVersion));
}
}
策略 2:页面可见性检测
用户切回页面时检测,更精准、减少无效请求:
javascript
class VisibilityVersionChecker extends VersionChecker {
constructor(options) {
super(options);
this.handleVisibility = this.handleVisibility.bind(this);
}
start() {
super.start();
document.addEventListener('visibilitychange', this.handleVisibility);
}
stop() {
super.stop();
document.removeEventListener('visibilitychange', this.handleVisibility);
}
handleVisibility() {
if (document.visibilityState === 'visible') {
// 切回可见时立即检查
this.check();
}
}
}
推荐组合:定时轮询(60s~120s) + 页面可见性检测,兼顾实时性与资源消耗。
策略 3:WebSocket 推送(服务端支持时)
服务端部署新版本后主动推送:
javascript
class WsVersionChecker {
constructor(options) {
this.currentVersion = options.currentVersion;
this.wsUrl = options.wsUrl;
this.listeners = [];
}
connect() {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'NEW_VERSION' && data.version !== this.currentVersion) {
this.listeners.forEach(fn => fn(data));
}
};
this.ws.onclose = () => {
// 断线重连
setTimeout(() => this.connect(), 5000);
};
}
}
策略 4:Service Worker(PWA)
javascript
// sw.js
self.addEventListener('install', (event) => {
// 新 SW 安装后,通知页面有新版本
self.skipWaiting();
});
// 页面侧监听
navigator.serviceWorker.addEventListener('controllerchange', () => {
// SW 已更新,提示用户刷新
showUpdateNotice();
});
3.3 各策略对比
| 策略 | 实时性 | 服务端成本 | 浏览器兼容性 | 适用场景 |
|---|---|---|---|---|
| 定时轮询 | 分钟级 | 低 | 100% | 所有场景,基础方案 |
| 页面可见性 | 用户感知强 | 极低 | 100% | 配合轮询使用 |
| WebSocket | 秒级 | 高 | 99%+ | 强实时性要求 |
| Service Worker | 页面跳转时 | 无额外成本 | 95%+ | PWA 场景 |
四、用户更新提示策略
4.1 策略分级
静默更新 ──→ 温和提示 ──→ 强制更新
(低) (高)
4.2 温和提示(推荐默认方案)
弹窗/横幅提示用户有新版本可用,由用户决定是否刷新:
javascript
function showUpdateNotice(newVersion) {
// 1. 先检查用户状态
const userState = detectUserState();
// 2. 如果用户在编辑中,做特殊处理
if (userState.isEditing) {
showEditingAwareNotice(newVersion, userState);
return;
}
// 3. 正常提示
showNormalNotice(newVersion);
}
function showNormalNotice(newVersion) {
const banner = document.createElement('div');
banner.className = 'version-update-banner';
banner.innerHTML = `
<div class="update-content">
<span>有新版本可用,建议刷新页面获取最新内容</span>
<div class="update-actions">
<button class="btn-refresh" onclick="location.reload()">立即刷新</button>
<button class="btn-dismiss" onclick="this.closest('.version-update-banner').remove()">
稍后提醒
</button>
</div>
</div>
`;
banner.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
background: #1890ff; color: #fff; padding: 12px 16px;
display: flex; justify-content: center; align-items: center;
font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: slideDown 0.3s ease;
`;
document.body.prepend(banner);
}
对应的 CSS:
css
.version-update-banner .btn-refresh {
margin-left: 12px;
padding: 4px 16px;
border: 1px solid #fff;
border-radius: 4px;
background: rgba(255,255,255,0.2);
color: #fff;
cursor: pointer;
font-size: 13px;
}
.version-update-banner .btn-dismiss {
margin-left: 8px;
padding: 4px 12px;
background: transparent;
border: none;
color: rgba(255,255,255,0.8);
cursor: pointer;
font-size: 13px;
}
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
4.3 模态弹窗提示(中等强度)
需要用户确认后才能继续操作:
javascript
function showModalNotice(newVersion) {
const mask = document.createElement('div');
mask.className = 'update-modal-mask';
mask.innerHTML = `
<div class="update-modal">
<div class="modal-icon">🔔</div>
<h3>发现新版本</h3>
<p>刷新页面即可更新到最新版本</p>
<div class="modal-actions">
<button class="btn-primary" onclick="location.reload()">
立即刷新
</button>
<button class="btn-secondary"
onclick="this.closest('.update-modal-mask').remove()">
我知道了
</button>
</div>
</div>
`;
mask.style.cssText = `
position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
`;
document.body.appendChild(mask);
}
4.4 倒计时强制刷新(高优先级,仅用于兼容性 Breaking Change)
javascript
function showForceUpdateNotice(newVersion, countdown = 30) {
let remaining = countdown;
const mask = document.createElement('div');
mask.className = 'force-update-mask';
mask.innerHTML = `
<div class="force-update-dialog">
<h3>⚠️ 重要更新</h3>
<p>检测到重大版本更新,需要刷新页面以保证功能正常</p>
<p class="countdown-text">
页面将在 <strong id="countdown-timer">${remaining}</strong> 秒后自动刷新
</p>
<p style="color:#999;font-size:12px;">如有未保存内容,请先保存</p>
<button class="btn-refresh-now" onclick="location.reload()">
立即刷新
</button>
</div>
`;
const timerEl = mask.querySelector('#countdown-timer');
const timer = setInterval(() => {
remaining--;
timerEl.textContent = remaining;
if (remaining <= 0) {
clearInterval(timer);
location.reload();
}
}, 1000);
document.body.appendChild(mask);
}
五、表单/编辑场景下的数据保全策略(核心)
5.1 问题分析
用户正在填写表单时收到更新通知,如果直接刷新:
- ❌ 用户已填写数据全部丢失
- ❌ 用户体验极差,甚至造成业务损失
- ❌ 用户对"自动更新"产生不信任感
5.2 用户状态检测
在发出更新通知前,先检测用户是否处于"关键操作"状态:
javascript
/**
* 检测用户当前状态
* 各业务模块主动注册自己的状态
*/
class UserStateManager {
constructor() {
this.state = new Map();
// 状态定义:
// 'form-input' - 正在填写表单
// 'rich-editor' - 正在使用富文本编辑器
// 'file-upload' - 正在上传文件
// 'video-play' - 正在播放视频/直播
// 'payment' - 正在支付流程
// 'idle' - 空闲状态
}
/** 业务模块进入关键操作时调用 */
enter(moduleId, stateType) {
this.state.set(moduleId, {
type: stateType,
timestamp: Date.now(),
});
}
/** 业务模块退出关键操作时调用 */
leave(moduleId) {
this.state.delete(moduleId);
}
/** 检查是否有任何活跃的关键操作 */
isInCriticalState() {
return this.state.size > 0;
}
/** 获取最危险的状态类型描述 */
getCriticalDescription() {
if (this.state.size === 0) return null;
const descriptions = [...this.state.values()].map(s => {
const map = {
'form-input': '您正在填写表单',
'rich-editor': '您正在编辑内容',
'file-upload': '您正在上传文件',
'video-play': '您正在观看视频',
'payment': '您正在进行支付',
};
return map[s.type] || '您正在进行重要操作';
});
return descriptions.join(';');
}
}
// 全局单例
window.__userState = new UserStateManager();
业务模块接入示例:
javascript
// 表单页面
const form = document.querySelector('#user-form');
form.addEventListener('focusin', () => {
window.__userState.enter('user-form', 'form-input');
});
form.addEventListener('focusout', (e) => {
// 延迟检查,避免焦点在表单内部元素间切换时频繁触发
setTimeout(() => {
if (!form.contains(document.activeElement)) {
window.__userState.leave('user-form');
}
}, 100);
});
// 富文本编辑器
editor.on('focus', () => {
window.__userState.enter('article-editor', 'rich-editor');
});
editor.on('blur', () => {
window.__userState.leave('article-editor');
});
5.3 分级通知策略
javascript
function smartUpdateNotify(newVersion) {
const isCritical = window.__userState?.isInCriticalState();
const description = window.__userState?.getCriticalDescription();
if (isCritical) {
// 🔴 关键操作中:仅轻量提示,绝不打断
showSubtleUpdateNotice(newVersion, description);
} else {
// 🟢 空闲状态:正常弹窗提示
showNormalUpdateNotice(newVersion);
}
}
5.4 轻量提示(关键操作中)
用户正在编辑时,采用非阻塞式提示:
javascript
function showSubtleUpdateNotice(newVersion, userActionDesc) {
// 右下角 Toast,3~5 秒自动消失
const toast = document.createElement('div');
toast.className = 'update-toast-subtle';
toast.innerHTML = `
<span>📌 新版本已发布</span>
<small>${userActionDesc},完成后再刷新即可</small>
`;
toast.style.cssText = `
position: fixed; right: 20px; bottom: 20px; z-index: 99999;
background: #fff; border-radius: 8px; padding: 12px 20px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
font-size: 14px; max-width: 320px;
animation: slideUp 0.3s ease;
`;
document.body.appendChild(toast);
// 5 秒后自动消失
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
5.5 数据自动保全策略
核心思路:在用户收到更新提醒但尚未刷新期间,持续同步表单项到 localStorage,刷新后自动恢复。
javascript
class FormDataPreserver {
constructor(formSelector, storageKey) {
this.form = document.querySelector(formSelector);
this.storageKey = storageKey || `form_data_${location.pathname}`;
this.saveTimer = null;
}
/** 开始监听表单变化,自动保存 */
start() {
if (!this.form) return;
// 监听所有表单元素的变化
this.form.addEventListener('input', () => {
this.debounceSave();
});
this.form.addEventListener('change', () => {
this.debounceSave();
});
// 页面卸载时保存
window.addEventListener('beforeunload', () => {
this.save();
});
// 页面加载时恢复
this.restore();
}
debounceSave() {
clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), 500);
}
save() {
const data = {};
const formData = new FormData(this.form);
for (const [key, value] of formData.entries()) {
data[key] = value;
}
// 同时存储富文本编辑器内容
const editors = this.form.querySelectorAll('[data-editor]');
editors.forEach(el => {
data[el.dataset.editor] = el.innerHTML || el.value;
});
localStorage.setItem(this.storageKey, JSON.stringify({
data,
timestamp: Date.now(),
url: location.href,
}));
}
restore() {
const saved = localStorage.getItem(this.storageKey);
if (!saved) return;
try {
const { data, timestamp } = JSON.parse(saved);
// 超过 24 小时的数据不恢复
if (Date.now() - timestamp > 24 * 3600 * 1000) {
localStorage.removeItem(this.storageKey);
return;
}
// 恢复表单数据
Object.entries(data).forEach(([key, value]) => {
const el = this.form.elements[key];
if (el) {
if (el.type === 'checkbox' || el.type === 'radio') {
el.checked = (value === 'on' || value === true);
} else {
el.value = value;
}
}
// 恢复编辑器内容
const editorEl = this.form.querySelector(`[data-editor="${key}"]`);
if (editorEl) {
if (editorEl.innerHTML !== undefined) editorEl.innerHTML = value;
else editorEl.value = value;
}
});
} catch (e) {
localStorage.removeItem(this.storageKey);
}
}
clear() {
localStorage.removeItem(this.storageKey);
}
}
5.6 页面卸载前拦截(beforeunload)
对于有未保存数据的页面,利用浏览器原生的 beforeunload 机制:
javascript
window.addEventListener('beforeunload', (e) => {
// 检测是否有未保存的更改
if (window.__userState?.isInCriticalState()) {
// 现代浏览器不会显示自定义消息,但会弹出原生确认框
e.preventDefault();
e.returnValue = ''; // Chrome 需要
return '';
}
});
六、版本检测时机矩阵
| 时机 | 检测方式 | 频率 | 用户感知 | 适用场景 |
|---|---|---|---|---|
| 页面加载时 | 请求 version.json | 首次 | 无 | 首次打开检查 |
| 定时轮询 | 请求 version.json | 60~120s | 无 | 长时间停留 |
| 页面切回 | visibilitychange 事件 | 每次切回 | 无 | 多标签页切换 |
| 路由切换 | 前端路由守卫 | 每次路由跳转 | 无 | SPA 应用 |
| API 拦截 | 拦截响应头 x-app-version |
每次请求 | 无 | 接口版本不一致 |
| WebSocket | 服务端推送 | 实时 | 无 | 强实时性 |
6.1 推荐组合检测方案
javascript
// 完整的版本检测管理器
class AppVersionManager {
constructor(options = {}) {
this.versionUrl = options.versionUrl || '/version.json';
this.pollInterval = options.pollInterval || 120 * 1000; // 2分钟
this.currentVersion = this.getCurrentVersion();
this.newVersion = null;
this.timer = null;
}
getCurrentVersion() {
// 从多个来源获取当前版本
const meta = document.querySelector('meta[name="app-version"]');
return meta?.content || window.__APP_VERSION__ || '0.0.0';
}
async init() {
// 1. 首次检查
await this.check();
// 2. 定时轮询
this.startPolling();
// 3. 页面可见性
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.check();
}
});
// 4. 路由切换(SPA)
this.interceptRouteChange();
// 5. API 响应头拦截
this.interceptApiResponse();
}
startPolling() {
this.timer = setInterval(() => this.check(), this.pollInterval);
}
async check() {
try {
const resp = await fetch(`${this.versionUrl}?t=${Date.now()}`, {
cache: 'no-cache',
});
const { version, hash, forceUpdate, changelog } = await resp.json();
if (version !== this.currentVersion) {
this.newVersion = { version, hash, forceUpdate, changelog };
this.handleUpdate();
}
} catch (e) {
// 静默失败
}
}
handleUpdate() {
const { forceUpdate } = this.newVersion;
if (forceUpdate) {
// Breaking change:强制更新
this.showForceUpdate();
} else if (window.__userState?.isInCriticalState()) {
// 用户正在操作:轻量提示
this.showSubtleNotice();
} else {
// 正常提示
this.showNormalNotice();
}
}
interceptRouteChange() {
// SPA 路由拦截
const originalPush = history.pushState;
const originalReplace = history.replaceState;
const self = this;
history.pushState = function (...args) {
originalPush.apply(this, args);
self.check();
};
history.replaceState = function (...args) {
originalReplace.apply(this, args);
self.check();
};
window.addEventListener('popstate', () => self.check());
}
interceptApiResponse() {
// 拦截所有 fetch,检查响应头中的版本信息
const originalFetch = window.fetch;
const self = this;
window.fetch = async function (...args) {
const resp = await originalFetch.apply(this, args);
const serverVersion = resp.headers.get('x-app-version');
if (serverVersion && serverVersion !== self.currentVersion) {
self.newVersion = { version: serverVersion };
self.handleUpdate();
}
return resp;
};
}
showNormalNotice() { /* 见 4.2 */ }
showSubtleNotice() { /* 见 5.4 */ }
showForceUpdate() { /* 见 4.4 */ }
}
七、服务端配合方案
7.1 版本接口
json
// GET /version.json
// 构建时自动生成,每次部署更新
{
"version": "20240513120000", // 版本号(推荐时间戳格式)
"hash": "a1b2c3d4e5f6", // 构建 hash
"forceUpdate": false, // 是否强制更新
"changelog": "修复了订单提交问题", // 更新日志
"minVersion": "20240510120000" // 最低兼容版本(低于此版本强制更新)
}
7.2 响应头注入(Nginx)
nginx
# 在所有 HTML 响应中添加版本头
location / {
add_header x-app-version "20240513120000";
}
7.3 构建时自动生成 version.json
javascript
// scripts/generate-version.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const version = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
const hash = crypto.randomBytes(4).toString('hex');
const versionInfo = {
version,
hash,
forceUpdate: false,
changelog: process.env.CHANGELOG || '',
buildTime: new Date().toISOString(),
};
fs.writeFileSync(
path.resolve(__dirname, '../public/version.json'),
JSON.stringify(versionInfo, null, 2)
);
console.log(`Version generated: ${version} (${hash})`);
在 package.json 中配置:
json
{
"scripts": {
"build": "node scripts/generate-version.js && vite build"
}
}
八、完整流程状态机
arduino
┌──────────────┐
│ 页面加载 │
└──────┬───────┘
│
┌──────▼───────┐
│ 首次版本检测 │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌────▼───┐ ┌────▼───┐ ┌─────▼─────┐
│ 无更新 │ │ 有更新 │ │ 检测失败 │
└────┬───┘ └────┬───┘ └─────┬─────┘
│ │ │
┌────▼───┐ ┌────▼──────────────────┐
│进入轮询│ │ 判断 forceUpdate? │
└────────┘ └────────┬──────────────┘
│
┌──────────┼──────────┐
│ │ │
┌────▼───┐ ┌───▼────┐ ┌───▼────┐
│force= │ │force= │ │force= │
│ true │ │ false │ │ false │
└────┬───┘ │(idle) │ │(editing│
│ └───┬───┘ └───┬────┘
│ │ │
┌────▼───┐ ┌───▼────┐ ┌───▼────┐
│强制刷新│ │正常弹窗│ │轻量提示│
│倒计时 │ │ │ │Toast │
└────────┘ └────────┘ └────────┘
九、边界情况与兜底策略
9.1 边界情况清单
| 场景 | 风险 | 处理策略 |
|---|---|---|
| 用户填写表单 | 数据丢失 | 轻量提示 + localStorage 自动保存 |
| 视频/直播播放中 | 播放中断 | 轻量提示,不打断 |
| 支付流程中 | 重复支付 | 支付中不弹窗,支付完成后提示 |
| 多标签页同时打开 | 重复提示 | 通过 BroadcastChannel 协调,只主标签提示 |
| 检测接口超时 | 误判无更新 | 静默重试,不影响用户 |
| 用户拒绝刷新 | 持续使用旧版 | 仅在下次页面可见时再提示 |
| 弱网环境 | 检测失败 | 降低检测频率,不频繁请求 |
| localStorage 满 | 数据保全失败 | IndexedDB 兜底 |
9.2 多标签页协调
javascript
class CrossTabVersionManager {
constructor() {
this.channel = new BroadcastChannel('app_version');
this.isMaster = false;
}
async init() {
// 尝试获取主标签锁
this.isMaster = await this.tryAcquireLock();
if (this.isMaster) {
// 只有主标签执行定时检测
this.startPolling();
}
// 监听其他标签的消息
this.channel.onmessage = (event) => {
if (event.data.type === 'NEW_VERSION') {
this.handleUpdate(event.data.version);
}
};
}
handleUpdate(version) {
// 即使不是主标签,也提示用户(各标签独立判断是否在编辑中)
smartUpdateNotify(version);
}
}
9.3 IndexedDB 兜底存储
当 localStorage 空间不足或数据结构复杂时:
javascript
class IndexedDBPreserver {
constructor(dbName = 'AppDataPreserver') {
this.dbName = dbName;
}
async save(key, data) {
const db = await this.openDB();
const tx = db.transaction('formData', 'readwrite');
const store = tx.objectStore('formData');
store.put({ key, data, timestamp: Date.now() });
}
async restore(key) {
const db = await this.openDB();
const tx = db.transaction('formData', 'readonly');
const store = tx.objectStore('formData');
const result = await new Promise((resolve) => {
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
if (result && Date.now() - result.timestamp < 24 * 3600 * 1000) {
return result.data;
}
return null;
}
openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('formData')) {
db.createObjectStore('formData', { keyPath: 'key' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e.target.error);
});
}
}
十、完整接入指南
10.1 接入步骤
bash
步骤 1:构建时注入版本号
├── 安装/编写 generate-version 脚本
├── 修改 build 命令,构建前自动生成 version.json
├── index.html 注入 <meta name="app-version" ...>
└── 确认 /version.json 可正常访问(带 Cache-Control: no-cache)
步骤 2:引入版本检测 SDK
├── 引入 AppVersionManager(或按需加载)
├── 页面初始化时调用 AppVersionManager.init()
└── 配置检测间隔、检测地址等参数
步骤 3:接入用户状态检测
├── 在表单页面注册 UserStateManager.enter/leave
├── 在富文本编辑器页面注册状态
└── 配置 FormDataPreserver 自动保全
步骤 4:自定义提示 UI
├── 替换默认提示 UI 为项目风格
├── 配置 showNormalNotice / showSubtleNotice / showForceUpdate
└── 确认所有提示场景覆盖
步骤 5:测试验证
├── 部署新版本,验证旧页面能检测到更新
├── 模拟表单填写场景,验证轻量提示
├── 验证 localStorage 数据恢复
└── 验证多标签页场景
10.2 最小化接入(仅核心功能,约 40 行代码)
html
<!-- 在 index.html 的 <head> 中 -->
<meta name="app-version" content="20240513120000">
<script>
(async function() {
const CURRENT = document.querySelector('meta[name="app-version"]').content;
async function check() {
try {
const resp = await fetch('/version.json?t=' + Date.now());
const { version } = await resp.json();
if (version !== CURRENT) {
// 检测到新版本
var editing = document.querySelector('form input:focus, [contenteditable]:focus');
if (editing) {
// 用户正在输入,轻量提示
console.log('[Update] 新版本可用,完成当前操作后请刷新页面');
} else {
// 正常弹窗
if (confirm('发现新版本,是否刷新页面?')) {
location.reload();
}
}
}
} catch(e) {}
}
// 立即检查 + 每 2 分钟轮询 + 页面切回检查
check();
setInterval(check, 120000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') check();
});
})();
</script>
十一、方案对比与选型建议
| 方案 | 实现难度 | 更新及时性 | 用户打扰程度 | 适用场景 |
|---|---|---|---|---|
| 纯轮询检测 | ⭐ 低 | 分钟级 | 可控 | 所有 H5 项目 |
| + 页面可见性 | ⭐ 低 | 分钟级+切回时 | 可控 | 推荐组合 |
| + 用户状态感知 | ⭐⭐ 中 | 分钟级 | 极低 | 表单/编辑类页面 |
| + 数据自动保全 | ⭐⭐ 中 | 分钟级 | 极低 | 表单密集型应用 |
| + WebSocket 推送 | ⭐⭐⭐ 高 | 秒级 | 可控 | 实时性要求高 |
| + Service Worker | ⭐⭐⭐ 高 | 页面跳转时 | 极低 | PWA 应用 |
推荐组合方案
markdown
Level 1(基线 - 所有项目必做):
定时轮询 + 页面可见性检测 + 简单弹窗提示
覆盖 90% 场景
Level 2(进阶 - 表单密集型项目):
+ 用户状态感知 + 分级提示 + localStorage 数据保全
表单数据零丢失
Level 3(极致 - 大型项目):
+ WebSocket 推送 + API 响应头拦截 + 多标签协调 + IndexedDB
近乎实时的无感更新体验
十二、注意事项与踩坑
- version.json 缓存问题 :CDN/Nginx 可能缓存 version.json,务必配置
Cache-Control: no-cache, no-store, must-revalidate,前端 fetch 带上随机参数 - 轮询频率权衡:太频繁浪费带宽(尤其是移动端),太慢用户长时间使用旧版本。推荐 60~120s 间隔
- confirm/alert 不要滥用:浏览器原生弹窗体验差,建议自定义 UI 组件
- beforeunload 限制:现代浏览器不支持在 beforeunload 中显示自定义消息,仅能触发默认弹窗。数据保全应走 localStorage/IndexedDB
- SPA 路由切换:Hash 路由和 History 路由都需拦截,确保每次路由跳转都能触发检测
- localStorage 配额:通常 5MB,复杂表单需评估存储量,超限降级 IndexedDB
- 用户多次拒绝:记录拒绝次数,超过阈值(如 3 次)后降低提示频率或改为仅页面切回时提示
- 灰度发布场景:version.json 可根据用户 ID/地区返回不同版本号,实现灰度更新
十三、参考资料
- Page Lifecycle API - 页面生命周期
- Broadcast Channel API - 多标签页通信
- IndexedDB API - 客户端大容量存储
- Service Worker Lifecycle - SW 生命周期
- beforeunload Event - 页面卸载拦截
- WebSocket API - 服务端推送