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 配置
方案五(终极方案):构建注入版本 + 智能感知轮询
前四个方案各有短板,终极方案的思路是:把各方案的优点组合在一起,针对每个缺点单独补强。
核心改进点:
- 构建时把版本号直接注入 HTML 的 meta 标签,无需额外的 version.json 请求
- ETag HEAD 请求优先:先发 HEAD 请求对比 ETag,只有 ETag 变化才拉取完整 HTML,最小化流量
- Page Visibility API:Tab 不可见时暂停轮询,切回来立刻检测,彻底消灭空转
- 指数退避 + 抖动:请求失败时不立即重试,避免服务器部署瞬间的请求风暴
- 用户行为感知:检测到更新后不立即弹提示,等用户停止操作后再通知,不打断关键流程
第一步: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替换为HtmlWebpackPlugin的templateParameters注入同样的 meta 标签即可,检测器部分代码完全通用,无需任何修改。
以上文章来源来claudeAI