静态资源更新检测与自动刷新实现方案
一、背景
在 Web 应用开发中,我们经常面临这样的问题:静态资源(CSS、JS)更新后,用户刷新页面看到的仍是旧版本。这是因为浏览器缓存机制导致已加载的资源被复用。
常见场景:
- 紧急修复线上 bug,用户需手动刷新才生效
- CDN 资源更新,部署后部分用户仍加载旧文件
- 单页应用(SPA)运行时,HTML 壳文件更新了但 JS Bundle 未更新
核心痛点:静态资源更新后,如何让用户无感知地获取到最新版本?
二、解决思路概述
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 强制刷新(Ctrl+F5) | 绕过缓存 | 简单直接 | 用户操作成本高 |
版本化命名(main.js?v=1.0.1) | URL 变化绕过缓存 | 彻底 | 需要构建工具支持 |
| Cache-Control 头 | 设置 no-cache | 每次验证 | 性能损耗 |
| 运行时检测 + 自动刷新 | 定期比对本地与远程资源差异 | 用户无感、灵活可控 | 增加复杂度 |
本文方案属于第四种:运行时检测 + 自动刷新,通过定时比对当前页面资源与服务器资源,判断是否有更新并自动刷新。
三、核心问题分析
3.1 需要解决的关键问题
- 资源采集:如何获取当前页面已加载的所有静态资源(CSS/JS)?
- 远程比对:如何获取服务器端当前页面的资源列表?
- 差异检测:如何判断本地资源与远程资源是否一致?
- 更新通知:检测到更新后,如何优雅地提示用户?
- 自动刷新:如何让用户无刷新获取最新资源?
3.2 技术挑战
四、技术设计与实现
4.1 整体架构
4.2 关键代码实现
4.2.1 配置与初始化
javascript
function UpdateChecker(options = {}) {
const _options = {
interval: 30000, // 检测间隔 30 秒
autoStart: true, // 自动启动
isInitAutoRefresh: false, // 首次检测到更新是否自动刷新
onChecked: null, // 每次检测后的回调
onUpdateDetected: null, // 检测到更新时的回调
selector: `link[rel="stylesheet"],script[src]`, // 要检测的资源选择器
...options
}
// ...
}要点说明:
interval默认 30 秒,平衡检测频率与性能开销autoStart确保页面加载后自动开始检测selector可配置化,支持检测任意资源类型
4.2.2 本地资源采集
javascript
async function getLocalResources() {
const resources = parseResources(doc) // doc = window.document
return resources
}
function parseResources(doc) {
const resources = [];
doc.querySelectorAll(_options.selector).forEach(el => {
resources.push({
tag: el.tagName.toUpperCase(), // LINK 或 SCRIPT
url: el.getAttribute('href') || el.getAttribute('src') // 资源路径
});
});
return resources
}关键点:
- 直接查询当前页面的 DOM,获取已加载资源的 URL
tagName转为大写统一格式(如LINK、SCRIPT)- 兼容
href(CSS)和src(JS)两种属性
4.2.3 远程资源采集
javascript
async function getRemoteResources() {
// 1. 获取服务器当前的 HTML
const html = await fetchRemoteResources();
// 2. 解析 HTML 为 DOM
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 3. 从中提取资源列表
const resources = parseResources(doc);
return resources
}
async function fetchRemoteResources() {
// 关键:为绕过缓存,添加时间戳参数
const response = await fetch(generateBustUrl(), {
headers: {
'Cache-Control': 'no-cache, no-store',
'Pragma': 'no-cache'
}
})
return await response.text()
}重要技巧 - 绕过缓存:
javascript
function generateBustUrl() {
const url = new URL(location.href)
url.searchParams.set('_t', new Date().getTime()) // 添加时间戳
return url.toString();
}为什么需要这三层保险:
Cache-Control: no-cache— 告诉浏览器不要使用缓存Pragma: no-cache— HTTP/1.0 兼容_t=时间戳— URL 级别绕过缓存
4.2.4 资源比对算法
javascript
function compareResources(localResources, remoteResources) {
const updateResources = []
remoteResources.forEach(rr => {
// 如果远程资源在本地找不到(tag + url 都相同),说明需要更新
if (!defaultCompare(rr, localResources)) {
updateResources.push(rr)
}
})
return {
update: !!updateResources.length, // 是否有更新
details: updateResources // 具体哪些资源需要更新
}
}
function defaultCompare(remoteResource, localResources) {
// 在本地资源中找是否有 tag 和 url 都匹配的资源
return !!localResources.find(localResource =>
localResource.tag === remoteResource.tag &&
localResource.url === remoteResource.url
)
}比对逻辑解读:
4.2.5 更新通知弹窗
javascript
function showUpdateNotification(details, done) {
// 1. 创建遮罩层(全屏半透明黑色背景)
const overlay = doc.createElement('div');
overlay.style = `
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99999999;
display: flex;
align-items: center;
justify-content: center;
`;
// 2. 创建弹窗主体
const dialog = doc.createElement('div');
dialog.style = `
width: 420px;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
`;
// ... 头部、内容、底部的 DOM 构建省略
// 3. "稍后刷新"按钮 - 关闭弹窗,停止检测
closeBtn.onclick = () => {
doc.body.removeChild(overlay);
};
// 4. "立即刷新"按钮 - 执行强制刷新
refreshBtn.onclick = () => {
forceRefresh();
};
overlay.appendChild(dialog);
doc.body.appendChild(overlay);
done(); // 回调,停止检测
}4.2.6 强制刷新
javascript
function forceRefresh() {
// 使用 location.replace 而非 location.href
// 区别:replace 不会在历史记录中留下旧页面
// 用户点击"返回"时不会回退到旧版本页面
win.location.replace(generateBustUrl())
}五、完整使用示例
javascript
const checker = new UpdateChecker({
interval: 10000, // 每 10 秒检测一次
autoStart: true, // 页面加载后自动开始
isInitAutoRefresh: false, // 首次检测到更新不自动刷新,弹窗询问
// 检测完成回调(可用来做统计或日志)
onChecked: (result, local, remote) => {
console.log(`检测完成: ${result.update ? '有更新' : '无更新'}`);
},
// 自定义更新检测后的处理(覆盖默认弹窗)
onUpdateDetected: (details, stopFn) => {
// 自定义逻辑,比如发埋点、跳转等
alert('检测到更新!');
stopFn(); // 停止检测
}
});
// 手动控制
// checker.start(); // 启动
// checker.stop(); // 停止
// checker.isRunning(); // 获取运行状态六、方案评估
优势
| 优势 | 说明 |
|---|---|
| 用户无感 | 检测和刷新过程对用户透明,无需手动操作 |
| 零依赖 | 纯原生 JS 实现,不依赖任何框架 |
| 可配置 | 回调、高度自定义检测行为 |
| 防缓存 | 多层缓存绕过机制确保获取真实资源 |
局限
| 局限 | 说明 |
|---|---|
| 依赖 HTML | 必须保证服务器 HTML 能获取到当前资源列表 |
| 同源限制 | fetch 受同源策略限制,需服务端配合 |
| 无法检测非 DOM 资源 | 只能检测通过 DOM 加载的资源 |
七、完整原始代码
javascript
new UpdateChecker({
autoStart: true,
isInitAutoRefresh: true
// interval: 1000
})
function UpdateChecker(options = {}) {
const win = window, doc = win.document;
const _options = {
interval: 30000,
autoStart: true,
isInitAutoRefresh: false,
autoStartCallback: (p) => { },
selector: `link[rel="stylesheet"],script[src]`,
...options
}
let isRunning = false, timer = null
if (_options.autoStart) {
const onAutoStart = () => start(_options.isInitAutoRefresh)
doc.addEventListener('DOMContentLoaded', () => {
onAutoStart();
doc.removeEventListener('DOMContentLoaded', onAutoStart)
})
}
function start(autoRefresh = false) {
if (isRunning) return
isRunning = true;
check(autoRefresh)
timer = setInterval(() => {
check()
}, _options.interval);
}
function stop() {
clearInterval(timer)
timer = null
isRunning = false
}
async function check(autoRefresh = false) {
try {
const localResources = await getLocalResources();
const remoteResources = await getRemoteResources();
const compareResult = compareResources(localResources, remoteResources)
_options.onChecked && _options.onChecked(compareResult, localResources, remoteResources)
if (compareResult.update) {
if (autoRefresh) {
forceRefresh()
} else {
(_options.onUpdateDetected || showUpdateNotification)(compareResult.details, stop);
}
}
}
catch (error) {
console.error(error)
}
}
async function getLocalResources() {
const resources = parseResources(doc)
return resources
}
async function getRemoteResources() {
const html = await fetchRemoteResources();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const resources = parseResources(doc);
return resources
}
async function fetchRemoteResources() {
const response = await fetch(generateBustUrl(), {
headers: {
'Cache-Control': 'no-cache, no-store',
'Pragma': 'no-cache'
}
})
return await response.text()
}
function parseResources(doc) {
const resources = [];
doc.querySelectorAll(_options.selector).forEach(el => {
resources.push({
tag: el.tagName.toUpperCase(),
url: el.getAttribute('href') || el.getAttribute('src')
})
});
return resources
}
function compareResources(localResources, remoteResources) {
const updateResources = []
remoteResources.forEach(rr => {
if (!(_options.compare || defaultCompare)(rr, localResources)) {
updateResources.push(rr)
}
})
return {
update: !!updateResources.length,
details: updateResources
}
}
function defaultCompare(remoteResource, localResources) {
return !!localResources.find(localResource => localResource.tag === remoteResource.tag && localResource.url === remoteResource.url)
}
function showUpdateNotification(details, done) {
// 创建遮罩层
const overlay = doc.createElement('div');
overlay.style = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99999999;
display: flex;
align-items: center;
justify-content: center;
`;
// 创建弹窗
const dialog = doc.createElement('div');
dialog.style = `
width: 420px;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
`;
// 弹窗头部
const header = doc.createElement('div');
header.style = `
padding: 20px;
border-bottom: 1px solid #ebeef5;
background-color: #f5f7fa;
font-size: 18px;
color: #303133;
font-weight: 500;
position: relative;
display: flex;
align-items: center;
`;
const icon = doc.createElement('div');
icon.style = `
width: 20px;
height: 20px;
background-color: #409eff;
border-radius: 50%;
margin-right: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
`;
icon.textContent = '!';
const title = doc.createElement('span');
title.textContent = '检测到新版本';
header.appendChild(icon);
header.appendChild(title);
// 弹窗内容
const content = doc.createElement('div');
content.style = `
padding: 25px;
color: #606266;
line-height: 1.5;
`;
const message = doc.createElement('div');
message.textContent = '系统已经发布新版本,请刷新页面以获取最新功能。';
message.style = `margin-bottom: 20px;`;
const resourcesDiv = doc.createElement('div');
resourcesDiv.innerHTML = '<strong>更新的资源:</strong>';
const list = doc.createElement('ul');
list.style = `
margin-top: 10px;
padding-left: 20px;
max-height: 200px;
overflow-y: auto;
`;
// 找出需要更新的资源
for (let i = 0, len = details.length; i < len; i++) {
const detail = details[i]
const li = doc.createElement('li');
li.textContent = `${detail.url} (${detail.tag})`;
li.style = 'margin-bottom: 5px;';
list.appendChild(li);
}
resourcesDiv.appendChild(list);
content.appendChild(message);
content.appendChild(resourcesDiv);
// 弹窗底部按钮
const footer = doc.createElement('div');
footer.style = `
padding: 15px 20px;
text-align: right;
border-top: 1px solid #ebeef5;
`;
const closeBtn = doc.createElement('button');
closeBtn.style = `
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
padding: 9px 15px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
margin-right: 10px;
`;
closeBtn.textContent = '稍后刷新';
closeBtn.onmouseover = () => closeBtn.style.backgroundColor = '#f5f7fa';
closeBtn.onmouseout = () => closeBtn.style.backgroundColor = '#fff';
closeBtn.onclick = () => {
doc.body.removeChild(overlay);
};
const refreshBtn = doc.createElement('button');
refreshBtn.style = `
background: #409eff;
color: #fff;
border: none;
padding: 9px 15px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
`;
refreshBtn.textContent = '立即刷新';
refreshBtn.onmouseover = () => refreshBtn.style.backgroundColor = '#66b1ff';
refreshBtn.onmouseout = () => refreshBtn.style.backgroundColor = '#409eff';
refreshBtn.onclick = () => {
forceRefresh();
};
footer.appendChild(closeBtn);
footer.appendChild(refreshBtn);
// 组装弹窗
dialog.appendChild(header);
dialog.appendChild(content);
dialog.appendChild(footer);
overlay.appendChild(dialog);
doc.body.appendChild(overlay);
done();
}
function generateBustUrl() {
const url = new URL(location.href)
url.searchParams.set('_t', new Date().getTime())
return url.toString();
}
function forceRefresh() {
win.location.replace(generateBustUrl())
}
return {
start,
stop,
isRunning: () => isRunning
}
}