前端上线新版本之后,用户在浏览器里还在跑旧代码,这种问题估计不少同行都遇到过。最近我把一个用于实时检测 web 应用更新的 JavaScript 库从想法落地成了可复用工具,这里把整个设计和实现过程完整分享一下。这个库主要解决前端资源在用户端“感知不到新版本”的痛点,核心能力是定时探测线上版本标识、比对差异、触发更新提示或自动刷新,适合对发布体验有要求的工程化团队使用,尤其是 SPA 项目和微前端场景。写这篇文章的初衷,是想把“检测、提示、刷新”这条链路里的坑都讲透,而不是只丢一个 Demo。
1. 为什么需要“检测 web 应用更新”这个库
1.1 前端上线后用户还在跑旧代码,问题出在哪
传统后端应用每次请求都有服务端逻辑兜底,用户重新打开页面基本就是最新逻辑。前端静态资源不一样,浏览器为了性能会对 JS、CSS 做缓存,再加上 SPA 页面本身不会自动重新拉取页面骨架,导致用户可能几天甚至几周都停留在旧版本上。
我在实际项目中遇到过几次典型情况:产品经理上午发布了一个修复严重 Bug 的版本,下午客服反馈仍有大量用户遇到同一个 Bug;运营确认线上已经换成新 banner,但用户手机里还是旧页面。初看像是发布没成功,实际上代码已经部署到 CDN,只是用户浏览器没有去拿新文件。
这类问题的本质不是发布流程出错,而是“用户端版本状态不可知”。如果做一个页面,能主动去问服务端“现在的最新版本是多少”,再跟本地跑着的版本比对,就能知道用户是否在旧环境里。这个库做的是后面这半件事。
1.2 这个库解决的三个核心问题
第一个问题是怎么“及时发现”。不是让用户手动清缓存,而是页面自己定时检查,发现版本不一致时主动提醒。第二个问题是怎么“不打扰”。如果每次检测到版本号变化都弹 alert,用户会很反感,所以需要可配置的提示频率和 UI 展示方式。第三个问题是怎么“低侵入接入”。很多团队不想为这个功能专门改造后端,或者不想给前端工程引入过重的框架,所以这个库在设计上完全基于前端侧完成,后端只需要提供一个静态版本标识接口就行。
我把这三件事拆成了三个模块:探测模块负责定时请求版本标识;状态模块负责保存当前版本与历史版本;回调模块负责把“发现新版本”的事件通知给业务方。业务方拿到事件后,可以弹提示条、强制刷新,也可以自己跳转。
1.3 它和“热更新”“版本更新插件”的边界
这里要澄清一个容易被混淆的点:这个库不是热更新工具,不会替你“热替换”页面里的代码模块,它只负责发现“上游有新版本”并引导刷新。热更新通常在开发态生效,比如编译进程监听文件变化后通知浏览器局部替换;线上环境的版本切换还需要依赖浏览器重新加载页面资源。
另外,它也不是应用商店式的自动升级器。用户如果切到后台很久再回来,库通过页面可见性变化触发的一次检查,能快速发现版本变化,但它不会替你维护离线包、增量包这些复杂的资源管理。它更像一个“哨兵”,站在页面层守着版本状态,一旦发现变化就喊一嗓子。
2. 实时检测的原理与实现方案选型
2.1 常见方案横向对比:轮询、SSE、Service Worker
做“实时检测”可以选的方案不少,我在动手前把主流几条路线都考虑了一遍。
| 方案 | 基本原理 | 后端依赖 | 实时性 | 侵入程度 | 代表性场景 |
|---|---|---|---|---|---|
| 短轮询 | 定时 get 一个版本接口 | 需要提供接口 | 秒级到分钟级 | 低 | 大多数 web 应用更新提醒 |
| SSE | 服务端推送事件流 | 需要专门实现 | 毫秒级 | 高 | 强实时需求 |
| Service Worker | 监听 fetch,缓存与后台同步 | 需要可控缓存策略 | 分钟级 | 高 | PWA 离线更新 |
| WebSocket | 长连接推送消息 | 需要维护连接状态 | 毫秒级 | 高 | 实时协作场景 |
轮询方案看着最“笨”,但胜在实现简单、后端几乎零改造,只要求能获取到版本标识即可。SSE 和服务端推送虽然更实时,但对于一个“检测 web 应用是否有新版本”的场景来说有些重,尤其很多小团队根本不想维护一个长连接服务。Service Worker 能做得非常优雅,但它在浏览器兼容、缓存更新策略上坑很多,适合单独做一个 PWA 工程,而不是作为一个通用轻量库的基础方案。
2.2 为什么选择“版本标识轮询”方案
我最终选择的是“短轮询 + 版本标识比对”的组合,核心原因是它可以在不引入任何构建期魔法、不依赖长连接的情况下,实现可接受的实时性。
具体做法是让构建流程在发布时生成一个版本标记,部署后前端定时请求一个极小的文本接口,拿到当前线上版本号,与本地保存的版本号做比较。比较有差异就触发更新回调。这个方案有几个直观优点:请求体非常小,可能就几十个字节,不会给服务器增加压力;静态接口可以被 CDN 缓存,对源站冲击也小;版本标识的生成可以由构建插件完成,不要求团队成员手动维护。
轮询会让人担心延迟,实际上通过调整间隔可以控制实时粒度。普通后台管理系统 5 分钟检查一次完全够用,活动页面如果需要更及时,可以缩短到 30 秒,代价仅是每分钟多两次极小请求,对用户流量消耗可以忽略。
2.3 版本标识的生成与获取方式
版本标识需要“每次构建内容有变化时都不一样”,最简单的是用构建时间戳,但它有误差:如果同一个产物重发多次,时间戳一样,但用户可能因为 CDN 节点不同拿到不同资源,时间戳无法表示资源内容差异。更好的做法是用构建内容摘要,比如把打包入口文件的内容做一次 hash,截取前 8 位作为版本号。
获取方式我推荐两种,一种是把版本号写入构建产物中的全局变量,另一种是单独输出一个 version.json 文件。前者的额外请求次数为零,只要页面加载完,window 上就有版本号;代价是需要在入口 HTML 里内联一段脚本。后者则是一个独立请求,但好处是可以配合 CDN 缓存,并且不依赖 HTML 内容。
实际项目中我更推荐“入口 HTML 内联全局变量 + 轮询 version.json”的组合。首次加载时使用内联变量,避免无谓请求;轮询时请求 version.json,用文件内容覆盖全局变量。这样既能快速启动,又能持续感知更新。
2.4 整体工作流程解析
把整个工作流串起来看,每一轮检测的步骤如下。
- 初始化时读取本地缓存版本号,如果不存在,就取页面内联版本值存下。
- 定时请求远端版本接口,请求 URL 带一个防缓存参数,比如
?t=当前时间戳。 - 拿到响应后统一解析成字符串版本号,与本地版本比对。
- 如果不一致,说明线上有新版本,触发
onUpdate回调。 - 回调里根据业务配置,选择弹出提示条或自动调用
location.reload()。
这里最容易被忽略的是第 1 步的“本地版本号从哪来”。如果用户是全新访问,本地没有缓存版本,那么页面内联版本就是基准;如果用户是二次访问,就应该以缓存版本为基准,因为用户当前真正执行的是缓存里的代码逻辑。
3. 库的核心实现与 API 设计
3.1 对外 API 与配置项设计
为了让接入方不记太多 API,我把入口收敛成一个工厂函数,所有配置用一个对象传入。
interface UpdateDetectorOptions { versionUrl?: string; // 远端版本接口地址 localVersionKey?: string; // 本地缓存版本的 storage key versionParser?: (res: any) => string; // 从响应里解析版本号 checkInterval?: number; // 轮询间隔,单位 ms,默认 60_000 onUpdate?: (newVersion: string, oldVersion: string) => void; onError?: (error: Error) => void; immediate?: boolean; // 初始化后马上检查一次 storage?: Storage; // 自定义存储,默认 localStorage }为什么要把版本解析做成可配置函数,是因为团队后端返回的格式不统一。有的是{ "version": "20250112" },有的放在data.buildHash里。如果库写死结构,反而增加了接入成本。
checkInterval有一个细节需要说明:实际执行时我会做一个最小间隔保护,低于 10 秒直接按 10 秒处理,避免有人误配成 1 毫秒导致后端被刷爆。
3.2 核心检测逻辑代码骨架
核心部分很短,但每一行都有它存在的意义。下面是我整理出来的简化版实现。
function createUpdateDetector(options: UpdateDetectorOptions) { const { versionUrl, localVersionKey = 'app-version', versionParser = (res: any) => String(res.version || res), checkInterval = 60_000, onUpdate, onError, immediate = true, storage = localStorage, } = options; let timer: number | null = null; let checking = false; function getLocalVersion(): string | null { return storage.getItem(localVersionKey); } function setLocalVersion(version: string) { storage.setItem(localVersionKey, version); } async function checkUpdate() { if (checking) return; if (!versionUrl) return; checking = true; const requestUrl = `${versionUrl}${versionUrl.includes('?') ? '&' : '?'}_=${Date.now()}`; try { const res = await fetch(requestUrl, { headers: { 'Cache-Control': 'no-cache' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const remoteVersion = versionParser(data); const localVersion = getLocalVersion(); if (localVersion && remoteVersion && localVersion !== remoteVersion) { setLocalVersion(remoteVersion); onUpdate?.(remoteVersion, localVersion); } else if (!localVersion && remoteVersion) { // 首次访问,只记录不通知 setLocalVersion(remoteVersion); } } catch (e) { onError?.(e as Error); } finally { checking = false; } } function start() { if (immediate) checkUpdate(); timer = window.setInterval(checkUpdate, checkInterval); } function stop() { if (timer) window.clearInterval(timer); timer = null; } return { start, stop, checkUpdate }; }请求地址加时间戳参数是刻意为之。虽然我在 fetch headers 里带了Cache-Control: no-cache,但部分浏览器和 CDN 节点不完全听它的,URL 上直接加随机参数才是真正有效的防缓存手段。
关于本地版本存储的时间点,我选择在“检测到新版本且回调成功”之后更新本地缓存,而不是一拿到远端版本就更新。这样如果回调里抛了异常,下次检查还能再次触发更新逻辑,不会丢事件。
3.3 更新提示与自动刷新的策略设计
库本身不强制指定 UI,但会约定一个更新处理的通用策略:通知为主,刷新为辅。
默认推荐的做法是在页面上方贴一个非侵入提示条,内容类似“页面有新版本,点击刷新”,仅在用户点击后才执行刷新。这比自动刷新更稳妥,因为用户可能正在填写表单,强制刷新会丢数据。
如果确实需要自动刷新,我会建议加一个条件:仅在当前页面不是表单输入状态时自动执行;如果检测到用户正在输入,就把提醒状态挂起,等用户离开当前页面焦点后再处理。有一个字段叫autoReload,默认 false,置为 true 后库会尝试对页面做一次 reload,同时允许提供一个shouldAutoReload钩子让业务方做最后判断。
刷新后的一个关键操作是清除可能导致回退的“页面历史缓存状态”,这个后面单独讲。
3.4 异常与边界情况处理
写这个库时我给自己定了一个原则:检测功能绝对不能影响主业务。因此所有请求异常都只走onError回调,绝不 throw 到业务层。
另一个边界是局域网环境或弱网环境下的请求超时,我会给 fetch 包一层 AbortController,超时默认是 3 秒。因为用户可能打开页面后一直停在那里,如果网络长期抖动,轮询一直在等响应,会堆积大量 pending 请求。虽然checking标志能防止并发,但超时控制能尽快释放连接。
版本字符串的比较也有坑。假设后端从1.0.0升级到1.0.10,字符串逐位比较会判断为不同版本,这没问题;但如果后端返回的是数字时间戳,1672502400000,前端缓存里却是字符串"1672502400000",类型不一致会导致逻辑错误。解决办法是统一在versionParser里做一次规范化,强制转成稳定字符串格式。
4. 在真实项目中快速接入
4.1 构建阶段生成版本标识
要让库检测更新,得先把版本标识暴露出来。常见的前端工程都在 Vite 或 Webpack 上,可以利用插件在构建完成时生成一个版本文件。
以 Vite 举例,可以在buildEnd钩子里写入一个 JSON 文件:
import { defineConfig } from 'vite'; import { writeFileSync } from 'node:fs'; function versionPlugin() { return { name: 'generate-version-json', buildEnd() { const version = Buffer.from( `vite-${Date.now()}` ).toString('base64').slice(0, 8); this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version }), }); }, }; } export default defineConfig({ plugins: [versionPlugin()], });这里用时间戳拼上前缀再转 hash,是为了让版本号具备可读性。更严谨的做法是从打包输出的文件名里提取 chunk hash,但实现复杂度会高一些。对检测更新而言,只要每次构建产生的新版本号与旧版本号不同即可。
4.2 部署阶段的环境配置
版本标识生成了,部署环节的缓存配置反而更重要。如果入口 HTML 被 CDN 强制缓存了一个小时,那再短的轮询间隔也检测不到新版本。生产中我建议对 HTML 文件设置Cache-Control: max-age=0, must-revalidate;对带 hash 的 JS、CSS 资源可以放心长缓存。version.json 属于高频轮询的文件,建议同样禁用缓存,或者它的 TTL 不超过轮询间隔的一半。
有人会把 version.json 和 HTML 一起放在 CDN 的同一条规则下,结果发现轮询也拿不到新版本,就是因为 CDN 命中了缓存。这个要特别留意。
4.3 业务代码接入示例
接入代码放在应用入口文件里执行,以 React 为例:
import { useEffect } from 'react'; import { createUpdateDetector } from 'some-update-detector'; function App() { useEffect(() => { const detector = createUpdateDetector({ versionUrl: '/version.json', localVersionKey: 'my-app-version', checkInterval: 60_000, onUpdate() { // 业务侧可以弹提示条 window.dispatchEvent(new CustomEvent('app:new-version')); }, }); detector.start(); return () => detector.stop(); }, []); return <MainPage />; }如果你的项目是 Vue,写在created或mounted钩子里即可。重要的是避免在路由守卫里重复创建多个实例,全局最好只有一个检测器。我在一个微前端主应用里接入时,用单例模式把 detector 存在 module 级别的变量里,避免子应用切来切去导致轮询重复启动。
4.4 多环境与灰度场景下的玩法
这个库在环境切换场景也很好用:开发环境、联调环境、生产环境各自部署不同的 version.json,前端通过 location.host 动态拼接版本地址即可。灰度发布时,可以让部分用户命中灰度环境的 version.json,前端拿到与基线版本不同的版本号后,再根据配置决定是可感知提示还是静默兼容。
微前端场景下,每个子应用其实可以独立出子应用的版本标识,主应用只做聚合通知。比如某个子应用更新了,提示条提示“某某模块发布新版本”,点击后刷新整个页面会稍微粗暴,但至少能确保用户切到新版本。在我之前的实践中,这比让子应用各自弹窗要体感好很多。
5. 上线后的踩坑记录与调参建议
5.1 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 明明发布了新版本,却一直没提示 | version.json 被 CDN 缓存 | 给 HTML 和 version.json 设置短缓存时间 |
| 一打开页面就提示更新,过一会又提示一次 | 首次访问时本地没有版本记录,检查逻辑太激进 | immediate 首次检查只记录不回调 |
| 提示条出现后点击刷新,仍然加载旧代码 | 页面刷新后读取的还是本地缓存的 HTML | 检查 Service Worker 缓存并 skipWaiting |
| 轮询请求 404,没有版本文件 | 构建插件没有把文件发布到对应路径 | 确认 public 目录与插件 emitFile 的目录关系 |
| 用户反馈页面卡顿 | 轮询请求造成高频率 fetch | 合理拉长 checkInterval,以及使用 visibilitychange 补充检查 |
这里要强调的是“首次访问只记录不通知”的设计,很多初版容易忽略。用户第一次打开页面,本地没有版本基准,此时远端版本号无论是什么都不应该触发更新回调,否则会造成新用户一进页面就看到“更新提示”的尴尬体验。
5.2 自动刷新后仍加载旧页面的根因排查
自动刷新最隐蔽的坑是:刷新动作本身执行了,但 Service Worker 还在使用旧缓存响应了同路径请求。因为普通location.reload()不一定会绕过 Service Worker,它依然走 fetch 事件,而旧 Service Worker 里缓存了 HTML,直接返回了旧版本。
排查方法很简单:打开开发者工具,看 Network 面板里主文档请求是来自 Service Worker 还是网络。如果来自 SW,就需要在前端逻辑里处理版本更新时的 SW 更新流程,或者干脆在刷新前调用注册在 scope 内的registration.update(),确保 SW 脚本和缓存都是最新。如果项目没用 SW,则通常检查 CDN 是否对 HTML 设置了max-age=0即可。
另一个我不太建议但也见过有人用的做法是“缓存 version.json 结果到 localStorage”。放 localStorage 没问题,但要注意清理时机。如果用户手动清理浏览器存储,版本缓存被清掉,首次检查会再次触发一次记录,导致更新事件被抑制一次,这属于可接受的损失,不必专门处理。
5.3 轮询频率、用户打扰度与容错的平衡
轮询间隔的设置没有统一答案,大多数 B 端产品 1 分钟一次够用,活动页、秒杀页可以短到 10 秒,普通内容站 5 分钟一次足以。我个人的压测结论是:一分钟一次的请求量,对一台小型静态服务器的压力可以忽略,但对移动端用户电量有一定影响,因此更推荐“后台不检查,回到前台时立即检查一次”的策略。
实现方式很简单,通过visibilitychange监听页面从 hidden 变成 visible 时主动调用一次checkUpdate()。这样用户在离开页面的时间越久,回到页面的瞬间越快感知到版本更新。再配合 interval 兜底,检测的及时性和资源消耗都能兼顾。
容错方面要记住:检测请求失败不能报错打断用户操作。我在onError里通常只是记录一次日志,如果连续失败多次,可以降低轮询频率,比如指数退避到 5 分钟一次,避免用户弱网环境下被无效请求消耗流量。
最后再分享一个小技巧。这个库发布后我不建议立即对所有用户全量开启自动刷新,最好是一个开关配置。我在项目里按环境读取配置中心变量,灰度环境自动开启,生产环境默认只提示、不刷新,等观察一段时间无异常后再放开自动刷新策略。毕竟“能检测到新版本”和“能安全切换到新版本”之间,还有一道很长的验证流程。