关于项目部署后自动更新提示

5 阅读5分钟

theme: jzman

前端部署后自动更新提示:从"能用"到"最优解"全方案对比

前言

相信做前端开发的小伙伴都遇到过这种问题:项目部署后更新了新的 JS 包,但当前用户还停留在旧页面,浏览器找不到对应的 JS 文件(文件名 hash 变了),轻则功能异常,重则白屏崩溃。

这时候最好的体验是:悄悄检测到版本更新,弹出一个友好提示,让用户自己选择刷新,而不是强制跳转或让用户在报错中摸索。

网上流传的方案很多,各有取舍。本文把常见的纯前端方案全部梳理一遍,分析优缺点,最后给出一个适合生产环境的完整实现。


方案一:轮询 index.html,对比 Script 标签 Hash

这是目前讨论最多的方案。核心原理:构建工具(Vite/Webpack)打包时会给 JS 文件名加上 contenthash,每次部署后 index.html 里 <script> 标签的 src 就会变化。 定时拉取 / 路由的 HTML 内容,对比 script 标签是否变化,就能感知新版本。

interface Options {
  timer?: number
}

export class Updater {
  oldScript: string[] // 存储首次加载时 script 的 hash 信息
  newScript: string[] // 获取新的 script hash 信息
  dispatch: Record<string, Function[]> // 小型发布订阅通知用户更新

  constructor(options: Options) {
    this.oldScript = []
    this.newScript = []
    this.dispatch = {}
    this.init()
    this.timing(options?.timer)
  }

  async init() {
    const html: string = await this.getHtml()
    this.oldScript = this.parserScript(html)
  }

  async getHtml() {
    const html = await fetch('/').then(res => res.text())
    return html
  }

  parserScript(html: string) {
    const reg = new RegExp(/<script(?:\s+[^>]*)?>(.*?)</script\s*>/ig)
    return html.match(reg) as string[]
  }

  on(key: 'no-update' | 'update', fn: Function) {
    ;(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
    return this
  }

  compare(oldArr: string[], newArr: string[]) {
    const base = oldArr.length
    const arr = Array.from(new Set(oldArr.concat(newArr)))
    if (arr.length === base) {
      this.dispatch['no-update']?.forEach(fn => fn())
    } else {
      this.dispatch['update']?.forEach(fn => fn())
    }
  }

  timing(time = 10000) {
    setInterval(async () => {
      const newHtml = await this.getHtml()
      this.newScript = this.parserScript(newHtml)
      this.compare(this.oldScript, this.newScript)
    }, time)
  }
}

使用方式:

const updater = new Updater({ timer: 30000 })
updater.on('update', () => {
  showUpdateNotification()
})
✅ 优点
  • 实现最简单,无需改动任何构建配置
  • 不依赖后端接口,纯前端闭环
  • 天然兼容 Vite、Webpack 等主流构建工具
❌ 缺点
  • 每次轮询都完整拉取 HTML 文档,流量浪费,弱网和移动端下不友好
  • 固定间隔空转:Tab 切换走、页面最小化后,setInterval 依然在请求,白白消耗资源
  • CDN 缓存陷阱:若 CDN 缓存了 /,每次拿到的可能都是旧 HTML,检测失效
  • script 正则匹配不稳定,对 ESM type="module" 或动态 chunk 无法覆盖
  • 无法感知用户当前是否在进行关键操作(填表单、上传文件),贸然弹提示体验差

方案二:构建时生成 version.json,轮询对比版本号

方案一的轻量改进版:构建时生成一个极小的 version.json,轮询时只请求这个文件,而不是完整 HTML。

第一步:Vite 插件在构建时生成 version.json

// plugins/vite-plugin-version.ts
import fs from 'fs'
import path from 'path'
import type { Plugin } from 'vite'

export function versionPlugin(): Plugin {
  return {
    name: 'vite-plugin-version',
    closeBundle() {
      const version = {
        version: Date.now().toString(),
        buildTime: new Date().toISOString(),
      }
      fs.writeFileSync(
        path.resolve(process.cwd(), 'dist/version.json'),
        JSON.stringify(version)
      )
    },
  }
}

第二步:前端轮询

class VersionChecker {
  private currentVersion: string = ''

  async init() {
    const data = await this.fetchVersion()
    this.currentVersion = data.version
  }

  private async fetchVersion(): Promise<{ version: string }> {
    // 请求时加时间戳,防止浏览器缓存
    const res = await fetch(`/version.json?t=${Date.now()}`)
    return res.json()
  }

  start(interval = 60000) {
    setInterval(async () => {
      const data = await this.fetchVersion()
      if (data.version !== this.currentVersion) {
        this.onUpdate()
      }
    }, interval)
  }

  onUpdate() {
    // 通知用户
  }
}
✅ 优点
  • version.json 只有几十字节,比请求完整 HTML 节省几十倍流量
  • 版本号逻辑清晰,可扩展(加 changelog、强制刷新标记、灰度信息等)
  • 与构建流程解耦,改动集中在插件
❌ 缺点
  • 同样是固定间隔轮询,Tab 不可见时仍在空转
  • version.json 本身也可能被 CDN 缓存,需配置 Cache-Control: no-cache 或请求加时间戳
  • 需要改动构建配置,增加维护成本
  • 无法感知用户行为状态

方案三:利用 HTTP ETag / Last-Modified 检测更新

这个方案利用 HTTP 缓存协商机制:发送 HEAD 请求,只获取响应头,不拿 body,通过对比 ETag 或 Last-Modified 判断资源是否更新,几乎零流量。

class ETagChecker {
  private etag: string = ''
  private lastModified: string = ''

  async init() {
    const headers = await this.fetchHeaders()
    this.etag = headers.etag
    this.lastModified = headers.lastModified
  }

  private async fetchHeaders() {
    // HEAD 请求:只有请求头,没有响应体
    const res = await fetch('/', { method: 'HEAD', cache: 'no-store' })
    return {
      etag: res.headers.get('etag') || '',
      lastModified: res.headers.get('last-modified') || '',
    }
  }

  async check(): Promise<boolean> {
    const headers = await this.fetchHeaders()
    return headers.etag !== this.etag || headers.lastModified !== this.lastModified
  }
}
✅ 优点
  • 请求体积最小,仅有 HTTP 响应头,接近零流量消耗
  • 原生利用 HTTP 标准,不依赖构建工具,无需修改任何配置
  • 对所有类型的部署(传统服务器、CDN)都理论适用
❌ 缺点
  • 强依赖服务器 / CDN 正确配置 ETag,部分 CDN 默认不透传或会修改 ETag
  • Nginx 的 ETag 基于 Last-Modified + Content-Length 生成,不同服务器生成策略可能不一致
  • HEAD 请求在某些 CORS 配置下会被拦截
  • 只能知道"文件变了",无法携带版本元数据(如版本号、changelog)

方案四:Service Worker 监听更新

Service Worker 自带版本管理机制:每次部署时 SW 文件内容只要有任何变化,浏览器就会检测到新版本,可在 activate 事件中通知页面刷新。

// sw.js
self.addEventListener('install', () => {
  self.skipWaiting()
})

self.addEventListener('activate', event => {
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(windowClients => {
      windowClients.forEach(client =>
        client.postMessage({ type: 'SW_UPDATED' })
      )
    })
  )
})
// main.ts
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then(registration => {
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing
      newWorker?.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          showUpdateNotification()
        }
      })
    })
  })
}
✅ 优点
  • 浏览器原生支持,检测机制可靠,不依赖轮询
  • 可离线缓存资源,提升加载性能(额外收益)
  • 无需频繁发起网络请求
❌ 缺点
  • SW 缓存策略复杂,配置不当会导致"永远拿不到新版本"的灾难性问题
  • 调试困难,开发体验差,需反复 unregister
  • 首次安装后需等到下一次访问才激活,即使调用 skipWaiting() 也需用户刷新两次才能彻底更新
  • 引入 SW 后整个项目缓存策略都需统一规划,改造成本高
  • 与 Vite/Webpack 的构建产物结合需要额外的 workbox 配置

方案五(终极方案):构建注入版本 + 智能感知轮询

前四个方案各有短板,终极方案的思路是:把各方案的优点组合在一起,针对每个缺点单独补强

核心改进点:

  1. 构建时把版本号直接注入 HTML 的 meta 标签,无需额外的 version.json 请求
  2. ETag HEAD 请求优先:先发 HEAD 请求对比 ETag,只有 ETag 变化才拉取完整 HTML,最小化流量
  3. Page Visibility API:Tab 不可见时暂停轮询,切回来立刻检测,彻底消灭空转
  4. 指数退避 + 抖动:请求失败时不立即重试,避免服务器部署瞬间的请求风暴
  5. 用户行为感知:检测到更新后不立即弹提示,等用户停止操作后再通知,不打断关键流程

第一步:Vite 插件 —— 构建时注入版本 meta 标签

// plugins/vite-plugin-inject-version.ts
import type { Plugin, IndexHtmlTransformResult } from 'vite'
import { execSync } from 'child_process'

function getGitHash(): string {
  try {
    return execSync('git rev-parse --short HEAD').toString().trim()
  } catch {
    return Date.now().toString(36)
  }
}

export function injectVersionPlugin(): Plugin {
  const version = getGitHash()
  const buildTime = new Date().toISOString()

  return {
    name: 'vite-plugin-inject-version',
    transformIndexHtml(): IndexHtmlTransformResult {
      return [
        {
          tag: 'meta',
          attrs: { name: 'app-version', content: version },
          injectTo: 'head',
        },
        {
          tag: 'meta',
          attrs: { name: 'app-build-time', content: buildTime },
          injectTo: 'head',
        },
      ]
    },
  }
}

vite.config.ts 中引入:

import { injectVersionPlugin } from './plugins/vite-plugin-inject-version'

export default defineConfig({
  plugins: [vue(), injectVersionPlugin()],
})

构建后 index.html<head> 中会出现:

<meta name="app-version" content="a3f9c12" />
<meta name="app-build-time" content="2024-01-15T08:00:00.000Z" />

第二步:SmartUpdater —— 完整检测器实现

// utils/smart-updater.ts

type UpdaterEvent = 'update' | 'no-update' | 'error'

interface SmartUpdaterOptions {
  /** 轮询间隔,单位 ms,默认 5 分钟 */
  interval?: number
  /** 用户无操作多少 ms 后才弹提示,默认 3s */
  idleDelay?: number
}

export class SmartUpdater {
  private currentVersion: string = ''
  private currentEtag: string = ''
  private timer: ReturnType<typeof setInterval> | null = null
  private retryCount: number = 0
  private readonly maxRetry: number = 5
  private isIdle: boolean = false
  private idleTimer: ReturnType<typeof setTimeout> | null = null
  private pendingUpdate: boolean = false
  private dispatch: Record<string, Function[]> = {}

  constructor(private options: SmartUpdaterOptions = {}) {}

  /** 初始化:从当前页面读取版本信息 */
  async init(): Promise<this> {
    this.currentVersion = this.getMetaVersion()
    this.currentEtag = await this.fetchEtag()

    this.bindVisibilityChange()
    this.bindUserActivity()
    this.start()
    return this
  }

  /** 读取 meta 标签中构建注入的版本号 */
  private getMetaVersion(): string {
    return (
      document
        .querySelector<HTMLMetaElement>('meta[name="app-version"]')
        ?.getAttribute('content') || ''
    )
  }

  /** HEAD 请求获取 ETag,几乎零流量 */
  private async fetchEtag(): Promise<string> {
    try {
      const res = await fetch('/', { method: 'HEAD', cache: 'no-store' })
      return res.headers.get('etag') || res.headers.get('last-modified') || ''
    } catch {
      return ''
    }
  }

  /** 从 HTML 字符串中解析 meta[app-version] */
  private parseVersionFromHtml(html: string): string {
    const match = html.match(
      /<meta\s+name=["']app-version["']\s+content=["']([^"']+)["']/
    )
    return match?.[1] || ''
  }

  /** 核心检测:先 HEAD 检查 ETag,有变化再拉 HTML */
  private async checkUpdate(): Promise<void> {
    try {
      const newEtag = await this.fetchEtag()

      // ETag 未变化 → 文件未更新,直接跳过
      if (newEtag && newEtag === this.currentEtag) {
        this.emit('no-update')
        this.retryCount = 0
        return
      }

      // ETag 变化 → 拉取完整 HTML 解析版本号
      const html = await fetch(`/?t=${Date.now()}`, {
        cache: 'no-store',
      }).then(res => res.text())

      const newVersion = this.parseVersionFromHtml(html)

      if (!newVersion || newVersion === this.currentVersion) {
        this.emit('no-update')
        this.retryCount = 0
        return
      }

      // 确认新版本,更新 etag 记录
      this.currentEtag = newEtag
      this.retryCount = 0
      this.pendingUpdate = true

      // 用户空闲时再弹提示,不打扰正在操作的用户
      if (this.isIdle) {
        this.pendingUpdate = false
        this.emit('update')
      }
    } catch {
      this.handleRetry()
    }
  }

  /** 指数退避:避免部署瞬间的请求风暴 */
  private handleRetry(): void {
    this.retryCount++
    if (this.retryCount >= this.maxRetry) {
      this.stop()
      this.emit('error')
      return
    }
    // 指数退避 + 随机抖动:2s, 4s, 8s, 16s...
    const delay = Math.pow(2, this.retryCount) * 1000 + Math.random() * 1000
    setTimeout(() => this.checkUpdate(), delay)
  }

  /** Page Visibility API:Tab 不可见时暂停,切回来立刻检测 */
  private bindVisibilityChange(): void {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.checkUpdate() // 切回来立刻检测一次
        this.start()
      } else {
        this.stop()
      }
    })
  }

  /** 用户行为感知:有交互 → 活跃,静止超过 idleDelay → idle */
  private bindUserActivity(): void {
    const idleDelay = this.options.idleDelay ?? 3000

    const resetIdle = () => {
      this.isIdle = false
      if (this.idleTimer) clearTimeout(this.idleTimer)
      this.idleTimer = setTimeout(() => {
        this.isIdle = true
        // 如果之前检测到了更新但没弹提示,现在发出
        if (this.pendingUpdate) {
          this.pendingUpdate = false
          this.emit('update')
        }
      }, idleDelay)
    }

    ;['mousedown', 'keydown', 'touchstart', 'scroll'].forEach(event => {
      document.addEventListener(event, resetIdle, { passive: true })
    })

    resetIdle() // 初始化立刻启动 idle 计时
  }

  start(): void {
    if (this.timer) return
    const interval = this.options.interval ?? 5 * 60 * 1000
    this.timer = setInterval(() => this.checkUpdate(), interval)
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer)
      this.timer = null
    }
  }

  on(event: UpdaterEvent, fn: Function): this {
    ;(this.dispatch[event] || (this.dispatch[event] = [])).push(fn)
    return this
  }

  private emit(event: UpdaterEvent): void {
    this.dispatch[event]?.forEach(fn => fn())
  }
}

第三步:挂载到项目入口

// main.ts
import { SmartUpdater } from './utils/smart-updater'

const updater = new SmartUpdater({
  interval: 5 * 60 * 1000, // 5 分钟轮询一次
  idleDelay: 3000,          // 用户 3 秒无操作后才弹提示
})

updater.init().then(instance => {
  instance.on('update', () => {
    showUpdateBanner()
  })
  instance.on('error', () => {
    console.warn('[SmartUpdater] 检测失败,已停止轮询')
  })
})

通知 UI 示例(可替换为你的组件库 Toast / Modal):

function showUpdateBanner() {
  // 避免重复添加
  if (document.getElementById('app-update-banner')) return

  const banner = document.createElement('div')
  banner.id = 'app-update-banner'
  banner.innerHTML = `
    <div style="
      position:fixed;bottom:24px;right:24px;z-index:9999;
      background:#1a1a2e;color:#fff;padding:16px 20px;
      border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.3);
      display:flex;align-items:center;gap:16px;font-size:14px;
    ">
      <span>🚀 发现新版本,刷新后生效</span>
      <button onclick="location.reload()" style="
        background:#4f8ef7;border:none;color:#fff;
        padding:6px 14px;border-radius:6px;cursor:pointer;
      ">立即刷新</button>
      <button onclick="this.closest('#app-update-banner').remove()" style="
        background:transparent;border:none;color:#aaa;
        cursor:pointer;font-size:18px;line-height:1;
      ">×</button>
    </div>
  `
  document.body.appendChild(banner)
}

五个方案横向对比

维度方案一
HTML 轮询
方案二
version.json
方案三
ETag HEAD
方案四
Service Worker
方案五
终极方案
流量消耗高(完整 HTML)极低(几十字节)极低(零 body)极低极低(ETag 优先)
需要构建改动需要插件需要 SW 配置轻量插件
实时性中(固定间隔)高(切 Tab 立刻检测)
CDN 缓存影响中(加时间戳可解决)依赖服务端配置低(ETag + 时间戳双保险)
用户行为感知有(idle 才弹提示)
Tab 不可见时继续空转继续空转继续空转不轮询自动暂停
失败容错浏览器内置指数退避
实现复杂度

总结

一句话概括终极方案的设计思想:

用构建时注入减少运行时解析;用 ETag HEAD 请求最小化网络开销;用 Page Visibility 消灭无效轮询;用用户行为感知在最合适的时机弹提示,而不是粗暴打断用户操作;用指数退避在网络异常时保护服务器。

每一处复杂度都对应一个真实的生产问题,这套方案适合作为项目脚手架的内置能力长期维护。

Webpack 用户提示: 把 Vite 插件中的 transformIndexHtml 替换为 HtmlWebpackPlugintemplateParameters 注入同样的 meta 标签即可,检测器部分代码完全通用,无需任何修改。

以上文章来源来claudeAI