症状
Application 面板会告诉你更新究竟卡在哪:旧 worker “activated and is running”、新 worker “waiting to activate”——安装成功而激活从未发生,旧者仍控制着打开的客户端,也没人重载页面。同时 Cache Storage 里是上月日期的条目挂在无版本号的缓存名下,fetch 处理器 cache-first 应答——回访用户拿到的永远是冻结的旧外壳,部署了什么都没用,直到硬刷新或清站点数据。
诊断思路
先查 worker 状态区分 running 与 waiting,waiting 即更新停在激活阶段;再看缓存名与条目日期,无版本号加旧日期加 cache-first 就是冻结供给;最后追更新触发路径——浏览器在导航与 push 时检查 sw.js 字节差异,字节不变则更新流程根本不启动。
根因
Service Worker 控制页面直到被显式更新:新 worker 并行安装,但只要有客户端被旧者持有就处于 waiting。叠加 cache-first 策略与无版本缓存,回访者拿到冻结旧外壳,更新握手停在 waiting 阶段。
修复与验证
把更新舞步跳完整:缓存名随部署打版本(app-v42);activate 时删除所有旧版本缓存;HTML 走 network-first(离线回退缓存),仅不可变的带哈希 assets 走 cache-first;新 worker 里 skipWaiting 加 clients.claim 实现即时接管;页面侧在更新就绪时提示重载,而不是在会话中途热交换状态。验证时连续两次部署:新 worker 激活、旧缓存从 Cache Storage 消失、回访客户端一次导航内加载到新版本。skipWalking 配在途页面会把旧代码与新响应混进同一会话——提示重载比热切换安全。
代码示例
Service Workers
▸ activated and is running sw.js (old)
▸ waiting to activate sw.js (new)
// sw.js — versioned cache, activation cleanup
const VERSION = 'v42';
const CACHE = `app-${VERSION}`;
self.addEventListener('install', () => {
self.skipWaiting(); // take over when ready
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
);
await self.clients.claim(); // control existing pages
})());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.mode === 'navigate') {
// HTML: network-first, fall back to cache offline
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (url.pathname.startsWith('/assets/')) {
// hashed immutable assets: cache-first is safe
event.respondWith(
caches.match(event.request).then((hit) =>
hit ?? fetch(event.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(event.request, copy));
return res;
})
)
);
}
});