Skip to content

静态资源更新检测与自动刷新实现方案

一、背景

在 Web 应用开发中,我们经常面临这样的问题:静态资源(CSS、JS)更新后,用户刷新页面看到的仍是旧版本。这是因为浏览器缓存机制导致已加载的资源被复用。

常见场景:

  • 紧急修复线上 bug,用户需手动刷新才生效
  • CDN 资源更新,部署后部分用户仍加载旧文件
  • 单页应用(SPA)运行时,HTML 壳文件更新了但 JS Bundle 未更新

核心痛点:静态资源更新后,如何让用户无感知地获取到最新版本?


二、解决思路概述

方案原理优点缺点
强制刷新(Ctrl+F5)绕过缓存简单直接用户操作成本高
版本化命名(main.js?v=1.0.1URL 变化绕过缓存彻底需要构建工具支持
Cache-Control 头设置 no-cache每次验证性能损耗
运行时检测 + 自动刷新定期比对本地与远程资源差异用户无感、灵活可控增加复杂度

本文方案属于第四种:运行时检测 + 自动刷新,通过定时比对当前页面资源与服务器资源,判断是否有更新并自动刷新。


三、核心问题分析

3.1 需要解决的关键问题

  1. 资源采集:如何获取当前页面已加载的所有静态资源(CSS/JS)?
  2. 远程比对:如何获取服务器端当前页面的资源列表?
  3. 差异检测:如何判断本地资源与远程资源是否一致?
  4. 更新通知:检测到更新后,如何优雅地提示用户?
  5. 自动刷新:如何让用户无刷新获取最新资源?

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 转为大写统一格式(如 LINKSCRIPT
  • 兼容 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();
}

为什么需要这三层保险

  1. Cache-Control: no-cache — 告诉浏览器不要使用缓存
  2. Pragma: no-cache — HTTP/1.0 兼容
  3. _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
    }
}