普通 H5 新版本部署后通知用户更新方案

一、背景与目标

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
    近乎实时的无感更新体验

十二、注意事项与踩坑

  1. version.json 缓存问题 :CDN/Nginx 可能缓存 version.json,务必配置 Cache-Control: no-cache, no-store, must-revalidate,前端 fetch 带上随机参数
  2. 轮询频率权衡:太频繁浪费带宽(尤其是移动端),太慢用户长时间使用旧版本。推荐 60~120s 间隔
  3. confirm/alert 不要滥用:浏览器原生弹窗体验差,建议自定义 UI 组件
  4. beforeunload 限制:现代浏览器不支持在 beforeunload 中显示自定义消息,仅能触发默认弹窗。数据保全应走 localStorage/IndexedDB
  5. SPA 路由切换:Hash 路由和 History 路由都需拦截,确保每次路由跳转都能触发检测
  6. localStorage 配额:通常 5MB,复杂表单需评估存储量,超限降级 IndexedDB
  7. 用户多次拒绝:记录拒绝次数,超过阈值(如 3 次)后降低提示频率或改为仅页面切回时提示
  8. 灰度发布场景:version.json 可根据用户 ID/地区返回不同版本号,实现灰度更新

十三、参考资料

相关推荐
小陈工2 小时前
Python异步编程进阶:asyncio高级模式与性能调优
开发语言·前端·数据库·人工智能·python·flask·numpy
小小小小宇2 小时前
App 内嵌 H5 秒开技术方案
前端
烛阴2 小时前
TEngine 入门系列(二):三件套环境搭建 -- Unity + TEngine + AI 助手
前端·c#·unity3d
逍遥归来2 小时前
如何在 Jenkins 打包流程中接入 SwiftLint 自动化扫描
前端
wuxianda10302 小时前
uniapp项目上架苹果商店4.3a被拒,3天极速解决方案2026.5.8
前端·人工智能·flutter·uni-app·ios上架·苹果上架·苹果4.3a
前端毕业班2 小时前
前端"枚举"管理指南
前端·javascript
yuandiv2 小时前
告别"薛定谔的测试":Flaky Test 全链路治理实战
前端
明月_清风2 小时前
Claude Code 保姆级入门教程:零基础到 AI 编程高手,看这一篇就够了
前端·后端·claude
ricardo19733 小时前
手写一个虚拟列表,万级数据滚动 FPS 稳定 60 帧
前端