做后台管理系统的小伙伴应该都遇到过:后端部署了新版本,打开浏览器发现界面怎么还是旧的?因为 SPA 不刷新,用户压根不知道。下面聊聊这个问题的痛点和几种处理方案。
问题出在哪
现代前端系统普遍采用 SPA 架构,路由切换无刷新,体验好了,但部署更新后麻烦就来了。用户长时间停留在系统里,不会触发页面整体刷新,浏览器一直用的是首次加载的旧资源,完全感知不到已经发布了新版。
另一个坑是 hash 资源覆盖部署:打包时每个文件带哈希,上线时旧文件被同名但哈希不同的新文件替换,用户缓存的旧请求就指向了不存在的资源,出现白屏、菜单卡死、接口报错。这类问题在后台管理系统、企业办公平台里尤其突出,用户不会主动关页面,旧版前端配上新版接口,数据提交都可能出错。
不主动通知刷新,直接导致功能不一致、页面异常、业务报错,甚至影响数据准确性,还徒增排查成本。
准备一个版本指纹
无论用哪种方案,都需要一个能被前端拉到的版本文件。在 public 下放个 manifest.json:
{
"version": 1774319356413,
"appVersion": "3.8.5",
"buildEnv": "production",
"buildHash": "19d1dacc9fd",
"needRefresh": false,
"msg": "更新内容如下:\n--1.更新提示机制"
}
每次构建自动写入时间戳,可以用 webpack 插件:
// vue.config.js
const fs = require('fs');
const path = require('path');
const buildManifestContent = {
appVersion: require('./package.json').version,
version: new Date().getTime(),
buildEnv: process.env.NODE_ENV,
buildHash: new Date().getTime().toString(16),
needRefresh: false,
msg: "更新内容如下:\n--1.更新提示机制"
};
module.exports = {
configureWebpack: {
plugins: [
{
apply(compiler) {
const writeManifest = () => {
const manifestPath = path.resolve(__dirname, 'public/manifest.json');
try {
let originalContent = {};
if (fs.existsSync(manifestPath)) {
const fileStr = fs.readFileSync(manifestPath, 'utf-8');
originalContent = JSON.parse(fileStr);
}
const finalContent = { ...originalContent, ...buildManifestContent };
fs.writeFileSync(manifestPath, JSON.stringify(finalContent, null, 2), 'utf-8');
console.log('✅ 成功合并并写入 public/manifest.json');
} catch (error) {
console.error('❌ 写入 manifest.json 失败:', error);
}
};
compiler.hooks.beforeRun.tap('WriteManifestPlugin', writeManifest);
compiler.hooks.watchRun.tap('WriteManifestPlugin', writeManifest);
}
}
]
}
};
方案一:路由切换时检查
最简单的办法:在 Vue 根组件的 watch 里监测路由变化,每次切换去拉 manifest.json,和本地缓存对比。发现不一致就 reload。代码不多,但缺点是只有切路由才会触发,长时间停在同一个页面可能还检测不到。
// App.vue 文件
watch: {
$route(to, from) {
fetch(`/manifest.json?v=${Date.now()}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
const newVersion = data?.version;
const oldVersion = Number(localStorage.getItem('lastVersion'));
if (!oldVersion || oldVersion !== data?.version) {
console.log('版本升级了,强制刷新');
localStorage.setItem('lastVersion', newVersion);
setTimeout(() => {
window.location.reload(true);
}, 1000);
}
})
.catch(err => {
console.error('There was a problem with the fetch operation:', err);
});
}
}
方案二:定时轮询
用 setInterval 定时拉取版本文件,新旧比较。可以封装成一个 VersionManager,给个回调。轮询间隔按需定,5 分钟一次够用了,太频繁增加请求。
flowchart TD
A[start] -->B[加载 manifest.json]
B--> C{与缓存版本比较?}
C -- Yes --> D[提示用户或是静默刷新界面]
C -- No --> E[轮询 manifest.json,继续此流程]
// 版本管理模块:VersionManager.js
class VersionManager {
constructor() {
this.currentVersion = null;
this.updateCallback = null;
this.pollingInterval = 300000; // 5 分钟轮询一次
this.isUpdateAvailable = false;
}
init(versionUrl, onUpdate) {
this.updateCallback = onUpdate;
return this.fetchVersion(versionUrl)
.then(version => {
this.currentVersion = version;
this.startPolling(versionUrl);
return version;
});
}
fetchVersion(url) {
return fetch(url, {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
.then(res => res.json())
.then(data => data.version || data.buildTime)
.catch(err => {
console.error('版本获取失败:', err);
return null;
});
}
startPolling(url) {
setInterval(() => {
this.fetchVersion(url)
.then(newVersion => {
if (newVersion && this.isNewVersion(newVersion)) {
this.isUpdateAvailable = true;
this.promptUserToUpdate();
}
});
}, this.pollingInterval);
}
isNewVersion(newVersion) {
if (!this.currentVersion) return true;
if (this.currentVersion.includes('.')) {
const current = this.currentVersion.split('.').map(Number);
const latest = newVersion.split('.').map(Number);
for (let i = 0; i < current.length; i++) {
if (latest[i] > current[i]) return true;
if (latest[i] < current[i]) return false;
}
return false;
}
return newVersion > this.currentVersion;
}
promptUserToUpdate() {
if (!this.updateCallback) return;
const updateConfirm = confirm('检测到新版本,是否立即刷新页面?');
if (updateConfirm) {
this.updateCallback();
}
}
}
const versionManager = new VersionManager();
versionManager.init('manifest.json', () => {
location.reload();
});
方案三:服务器推送 SSE
轮询是客户端主动,SSE 让服务端有新版本时直接推给浏览器。服务器端监听版本文件变化,通过 EventStream 下发给客户端。前端收到事件后对比版本,决定是否提示更新。好处是实时性高,也没有轮询的额外开销。
服务器端(Node.js 示例)
const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer((req, res) => {
if (req.url === '/updates' && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.write(': keep-alive\n\n');
let lastVersion = '1.0.0';
fs.watch(path.join(__dirname, 'version.json'), (event, filename) => {
if (event === 'change') {
const newVersion = JSON.parse(fs.readFileSync(path.join(__dirname, 'version.json'))).version;
if (newVersion !== lastVersion) {
res.write(`event: update\n`);
res.write(`data: ${newVersion}\n\n`);
lastVersion = newVersion;
}
}
});
req.on('close', () => {
console.log('客户端连接关闭');
});
}
});
server.listen(3000, () => {
console.log('服务器运行在 3000 端口');
});
客户端实现
class SSEUpdateNotifier {
constructor() {
this.sse = null;
this.currentVersion = null;
}
init(versionUrl, sseUrl, onUpdate) {
return this.fetchVersion(versionUrl)
.then(version => {
this.currentVersion = version;
this.startListening(sseUrl, onUpdate);
return version;
});
}
fetchVersion(url) {
return fetch(url, { cache: 'no-cache' })
.then(res => res.json())
.then(data => data.version);
}
startListening(url, onUpdate) {
this.sse = new EventSource(url);
this.sse.onmessage = event => {
if (event.data && this.isNewVersion(event.data)) {
onUpdate();
}
};
this.sse.onerror = error => {
console.error('SSE 连接错误:', error);
setTimeout(() => {
this.startListening(url, onUpdate);
}, 5000);
};
}
isNewVersion(newVersion) {
return !this.currentVersion || newVersion > this.currentVersion;
}
close() {
if (this.sse) {
this.sse.close();
}
}
}
const notifier = new SSEUpdateNotifier();
notifier.init('manifest.json', '', () => {
const updateModal = document.createElement('div');
updateModal.innerHTML = `
<div>
<h3>发现新版本</h3>
<p>点击"更新"按钮体验新功能</p>
<button id="update-now">立即更新</button>
<button id="update-later">稍后更新</button>
</div>
`;
document.body.appendChild(updateModal);
document.getElementById('update-now').addEventListener('click', () => {
location.reload();
updateModal.remove();
});
document.getElementById('update-later').addEventListener('click', () => {
updateModal.remove();
setTimeout(() => {
notifier.promptUserToUpdate();
}, 1800000);
});
});
方案四:WebSocket 全双工推送
比 SSE 更灵活,WebSocket 支持双向通信,前端连接后可以上报当前版本,服务端有更新时下发通知。断线了还能自动重连,适合对实时性要求更高的场景。
class WebSocketUpdateNotifier {
constructor() {
this.ws = null;
this.currentVersion = null;
this.reconnectAttempts = 0;
this.maxReconnects = 10;
}
init(versionUrl, wsUrl, onUpdate) {
this.onUpdate = onUpdate;
return this.fetchVersion(versionUrl)
.then(version => {
this.currentVersion = version;
this.connectToWebSocket(wsUrl);
return version;
});
}
fetchVersion(url) {
return fetch(url, { cache: 'no-cache' })
.then(res => res.json())
.then(data => data.version);
}
connectToWebSocket(url) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('WebSocket 连接已建立');
this.reconnectAttempts = 0;
this.ws.send(JSON.stringify({ type: 'version', data: this.currentVersion }));
};
this.ws.onmessage = event => {
const message = JSON.parse(event.data);
if (message.type === 'update' && this.isNewVersion(message.data)) {
this.onUpdate();
}
};
this.ws.onclose = (event) => {
console.log('WebSocket 连接关闭:', event);
if (this.reconnectAttempts < this.maxReconnects) {
this.reconnectAttempts++;
setTimeout(() => {
this.connectToWebSocket(url);
}, 2000 * this.reconnectAttempts);
}
};
this.ws.onerror = error => {
console.error('WebSocket 错误:', error);
};
}
isNewVersion(newVersion) {
return !this.currentVersion || newVersion > this.currentVersion;
}
}
优化提示体验
检测到更新就弹个 confirm 太粗暴了,可以渐进式提醒。第一次只在角落挂个通知,忽略后又过一段时间才弹出模态框,用户还是不理,最后一次强制更新。这里给个简单分级示例:
let updatePromptLevel = 0;
const MAX_PROMPT_LEVEL = 3;
function showUpdatePrompt() {
updatePromptLevel++;
if (updatePromptLevel === 1) {
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = '发现新版本,点击查看详情';
notification.onclick = showUpdateModal;
document.body.appendChild(notification);
} else if (updatePromptLevel === 2) {
showUpdateModal();
} else if (updatePromptLevel === 3) {
showForceUpdateModal();
}
}
function showUpdateModal() {
// 带更多信息的模态框
}
function showForceUpdateModal() {
const modal = document.createElement('div');
modal.className = 'force-update-modal';
modal.innerHTML = `
<h2>必须更新</h2>
<p>旧版本已不再支持,请刷新页面使用最新版本</p>
<button onclick="location.reload()">立即更新</button>
`;
document.body.appendChild(modal);
}
也可以在弹窗里带上具体的更新日志,把 manifest.json 的 msg 或者 changelog 渲染出来,让用户知道改了什么,更愿意刷新。
fetch('manifest.json')
.then(res => res.json())
.then(versionInfo => {
if (versionInfo.changelog) {
const changelog = versionInfo.changelog.map(item => `
<div>
<h4>${item.title}</h4>
<p>${item.description}</p>
</div>
`).join('');
updateModal.innerHTML = `
<h3>版本 ${versionInfo.version} 已更新</h3>
<div>${changelog}</div>
<button>立即更新</button>
<button>1 小时后提醒</button>
`;
}
});
智能延迟策略:等用户空闲时才弹窗,避免打断操作。比如检测页面是否可见、最近有无点击滚动,隔一段时间没动作再提示。
function shouldShowPrompt() {
const isActive = document.hidden === false;
const userActivity = {
clicks: 0,
scrolls: 0,
lastAction: 0
};
document.addEventListener('click', () => {
userActivity.clicks++;
userActivity.lastAction = Date.now();
});
// 根据活跃度决定是否提示
return isActive && (Date.now() - userActivity.lastAction > 60000);
}
现成轮子
如果不想自己写,社区有 version-polling 和 plugin-web-update-notification 这样的插件,开箱即用,可以根据需要选用。

