基于 Service Worker 实现前端应用自动更新方案

背景介绍

在前端应用开发中,当我们部署新版本后,往往需要用户手动刷新页面才能获取最新内容。这种体验并不理想,本文将介绍如何使用 Service Worker 结合版本号实现自动检测更新并提示用户刷新页面的功能。

实现原理

  1. 使用 Service Worker 拦截并缓存应用资源
  2. 通过检测 version.json 中的版本号来判断是否有更新
  3. 在检测到更新时通过消息机制通知页面
  4. 提供用户更新提示,并在用户确认后刷新页面

具体实现

1. Service Worker 实现(sw.js)

在 public 目录下创建 sw.js 文件,主要功能包括:

  • 缓存静态资源
  • 拦截网络请求
  • 定期检查版本更新
  • 向页面发送更新通知

关键代码:

const CACHE_FILES = ["/", "/index.html", "./version.json"];

let currentVersion = null;

// 安装 Service Worker
self.addEventListener("install", (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
            return cache.addAll(CACHE_FILES);
        })
    );
});

// 激活 Service Worker
self.addEventListener("activate", (event) => {
    event.waitUntil(
        Promise.all([
            // 清理旧缓存
            caches.keys().then((cacheNames) => {
                return Promise.all(
                    cacheNames
                    .filter((name) => name !== CACHE_NAME)
                    .map((name) => caches.delete(name))
                );
            }),
            // 立即接管页面
            self.clients.claim(),
        ]).then(() => {
            // 激活后立即检查版本
            checkVersion();
        })
    );
});

// 添加请求拦截器
self.addEventListener("fetch", (event) => {
    // 特别处理 version.json 的请求,始终从网络获取最新版本
    if (event.request.url.includes("./version.json")) {
        event.respondWith(
            fetch(event.request, {
                cache: "no-cache",
                headers: {
                    "Cache-Control": "no-cache",
                    Pragma: "no-cache",
                },
            })
            .then((response) => {
                return response;
            })
            .catch((error) => {
                throw error;
            })
        );
        return;
    }

    event.respondWith(
        caches.match(event.request).then((response) => {
            // 如果在缓存中找到响应,则返回缓存的版本
            if (response) {
                return response;
            }
            // 否则发起网络请求
            return fetch(event.request);
        })
    );
});

// 检查版本更新
async function checkVersion() {
    try {
        console.log("[SW] 开始检查版本...");
        const response = await fetch("./version.json", {
            cache: "no-cache",
            headers: {
                "Cache-Control": "no-cache",
                Pragma: "no-cache",
            },
        });

        const data = await response.json();

        if (data.version !== currentVersion) {
            console.log("[SW] 检测到新版本");
            // 使用 includeUncontrolled: true 和 type: 'window' 参数
            const clients = await self.clients.matchAll({
                includeUncontrolled: true,
                type: "window",
            });

            console.log("[SW] 找到的客户端数量:", clients.length);

            if (clients.length === 0) {
                // 如果没有找到客户端,使用 BroadcastChannel
                const bc = new BroadcastChannel("version-update");
                bc.postMessage({
                    type: "VERSION_UPDATE",
                    version: data.version,
                    showAlert: true,
                });
            } else {
                // 如果找到客户端,使用常规方式发送消息
                clients.forEach((client) => {
                    client.postMessage({
                        type: "VERSION_UPDATE",
                        version: data.version,
                        showAlert: true,
                    });
                });
            }

            currentVersion = data.version;
        }
    } catch (error) {
        console.error("[SW] 版本检查失败:", error);
    }
}

// 监听消息
self.addEventListener("message", (event) => {
    console.log("[SW] 收到消息:", event.data);
    if (event.data === "CHECK_VERSION") {
        console.log("[SW] 收到检查版本请求");
        checkVersion();
    }
});

// 每分钟检查一次版本(测试阶段)
setInterval(checkVersion, 5 * 60 * 1000);

// 立即进行首次检查
checkVersion();

2. Service Worker 注册和消息处理(serviceWorker.ts)

utils目录下serviceWorker.ts

创建工具函数处理 Service Worker 的注册和消息通信:

import { ElMessageBox, ElMessage } from "element-plus";
import { Local } from "/@/utils/storage";

// 显示更新提示
function showUpdateNotification() {
    console.log("[Main] 显示更新提示");
    ElMessageBox.confirm(
        "系统发现新版本,为了更好的体验,建议立即更新",
        "更新提示",
        {
            confirmButtonText: "立即更新",
            cancelButtonText: "稍后更新",
            type: "warning",
            distinguishCancelAndClose: true,
        }
    )
        .then(() => {
        console.log("[Main] 用户确认更新");
        // 检查是否支持 Cache API
        if ("caches" in window) {
            return caches.keys().then(function (names) {
                return Promise.all(names.map((name) => caches.delete(name)))
                    .then(() => {
                    console.log("[Main] 缓存已清除,准备刷新页面");
                    Local.clear();
                    window.location.reload(true);
                })
                    .catch((error) => {
                    console.error("[Main] 清除缓存失败:", error);
                    Local.clear();
                    // 如果清除缓存失败,仍然尝试刷新页面
                    window.location.reload(true);
                });
            });
        } else {
            Local.clear();
            // 如果不支持 Cache API,直接刷新页面
            console.log("[Main] 浏览器不支持 Cache API,直接刷新页面");
            window.location.reload(true);
        }
    })
        .catch((err) => {
        if (err === "cancel") {
            console.log("[Main] 用户取消更新");
            ElMessage({
                type: "info",
                message: "您可以继续使用,但可能存在部分功能更新",
            });
        } else {
            console.error("[Main] 更新过程发生错误:", err);
            ElMessage({
                type: "error",
                message: "更新过程发生错误,请稍后重试",
            });
        }
    });
}

// 注册 Service Worker
export const registerServiceWorker = async () => {
    // 本地开发环境不注册 Service Worker
    if (import.meta.env.DEV) {
        console.log("[Main] 本地开发环境,注销 Service Worker");
        if ("serviceWorker" in navigator) {
            navigator.serviceWorker.getRegistrations().then(function (registrations) {
                for (let registration of registrations) {
                    registration.unregister();
                }
            });
        }
        return;
    }
    if ("serviceWorker" in navigator) {
        try {
            console.log("[Main] 开始注册 Service Worker...");

            // 注册 Service Worker
            const registration = await navigator.serviceWorker.register("/sw.js");
            console.log("[Main] Service Worker 注册成功:", registration);

            // 添加常规消息监听
            const messageHandler = (event: MessageEvent) => {
                console.log("[Main] 收到 Service Worker 消息:", event.data);
                if (event.data.type === "VERSION_UPDATE") {
                    console.log("[Main] 收到版本更新通知");
                    showUpdateNotification();
                }
            };

            // 添加 BroadcastChannel 监听
            const bc = new BroadcastChannel("version-update");
            bc.onmessage = (event) => {
                console.log("[Main] 收到广播消息:", event.data);
                if (event.data.type === "VERSION_UPDATE") {
                    console.log("[Main] 收到版本更新通知");
                    showUpdateNotification();
                }
            };

            navigator.serviceWorker.addEventListener("message", messageHandler);

            if (registration.active) {
                console.log("[Main] Service Worker 已激活,发送检查版本消息");
                registration.active.postMessage("CHECK_VERSION");
            }
        } catch (error) {
            console.error("[Main] Service Worker 注册失败:", error);
        }
    } else {
        console.warn("[Main] 浏览器不支持 Service Worker");
        console.log("[Main] 使用定时器检查更新");
        setInterval(() => {
            fetch("/version.json", {
                cache: "no-cache",
                headers: {
                    "Cache-Control": "no-cache",
                    Pragma: "no-cache",
                },
            })
                .then((response) => response.json())
                .then((data) => {
                const currentVersion = localStorage.getItem("app-version");
                if (currentVersion && currentVersion !== data.version) {
                    console.log("[Main] 检测到新版本");
                    showUpdateNotification();
                }
                localStorage.setItem("app-version", data.version);
            })
                .catch((error) => {
                console.error("[Main] 版本检查失败:", error);
            });
        }, 5 * 60 * 1000);
    }
};

// 检查更新
export const checkForUpdates = () => {
    if (navigator.serviceWorker && navigator.serviceWorker.controller) {
        console.log("[Main] 手动触发版本检查");
        navigator.serviceWorker.controller.postMessage("CHECK_VERSION");
    } else {
        console.warn("[Main] Service Worker 未就绪,无法检查更新");
    }
};

3. 自动版本号递增(autoIncrementVersion.ts)

根目录下创建 autoIncrementVersion.ts

为了方便版本管理,实现了一个 Vite 插件在构建时自动递增版本号:

import { Plugin } from "vite";
import fs from "fs";
import path from "path";
//版本号自增
const incrementVersion = (version: string) => {
    const parts = version.split(".").map(Number);
    parts[2]++;
    if (parts[2] > 9) {
        parts[2] = 0;
        parts[1]++;
        if (parts[1] > 9) {
            parts[1] = 0;
            parts[0]++;
        }
    }
    return parts.join(".");
};

export default function autoIncrementVersion(): Plugin {
    return {
        name: "vite:autoIncrementVersion",
        apply: "build",
        //构建开始时的钩子
        buildStart(options) {
            if (options) {
                try {
                    const pkgPath = path.resolve("./public/version.json");
                    console.log("Reading version.json from:", pkgPath);
                    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
                    console.log("Current version:", pkg.version);
                    pkg.version = incrementVersion(pkg.version);
                    console.log("New version:", pkg.version);
                    fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
                } catch (error) {
                    console.error("Error updating version:", error);
                }
            }
        },
    };
}

4. 创建版本号json(public/version.json)

在 public 目录下创建 version.json 文件

{
  "version": "1.0.8",
  "buildTime": "",
  "environment": "production"
}

使用方法

  1. 在 main.ts 中注册 Service Worker:
// 注册 Service Worker
import { registerServiceWorker } from '/@/utils/serviceWorker';
registerServiceWorker();
  1. 在 vite.config.ts 中添加版本自增插件:
import autoIncrementVersion from './autoIncrementVersion'

export default defineConfig({
    plugins: [autoIncrementVersion()],
    build: {

    plugins: [
    {
    name: 'version-inject',
    buildStart() {
    // 在构建开始时就创建 version.json
    const versionData = {
        version: process.env.npm_package_version,
        buildTime: new Date().toISOString(),
        environment: process.env.NODE_ENV || 'production'
    };

    // 写入到 public 目录
    const fs = require('fs');
    const path = require('path');
    const publicDir = path.resolve(__dirname, 'public');

    // 确保 public 目录存在
    if (!fs.existsSync(publicDir)) {
        fs.mkdirSync(publicDir, { recursive: true });
    }

    // 写入 version.json
    fs.writeFileSync(
        path.join(publicDir, 'version.json'),
        JSON.stringify(versionData, null, 2)
    );

    console.log('[Build] 在 public 目录生成版本信息:', versionData);
},
    generateBundle() {
        // 在构建输出时创建 version.json
        const versionData = {
            version: process.env.npm_package_version,
            buildTime: new Date().toISOString(),
            environment: process.env.NODE_ENV || 'production'
        };

        console.log('[Build] 在构建目录生成版本信息:', versionData);
        this.emitFile({
            type: 'asset',
            fileName: 'version.json',
            source: JSON.stringify(versionData, null, 2)
        });
    }
}
]
},
})

注意事项

  1. Service Worker 必须在 HTTPS 环境下运行(本地开发环境除外)
  2. 本地开发环境(localhost)可以正常使用
  3. 为了保证版本检查的准确性,package.json 的请求始终绕过缓存
  4. 提供了降级方案,在不支持 Service Worker 的环境下使用定时器检查更新

总结

通过 Service Worker 结合版本号检查,我们实现了一个可靠的前端应用自动更新机制。该方案具有以下优点:

  1. 自动检测新版本
  2. 提供友好的更新提示
  3. 支持离线缓存
  4. 包含降级方案
  5. 自动化的版本管理

这个方案可以显著提升用户体验,确保用户始终使用最新版本的应用。