发了一个关键修复,CDN 缓存清完,浏览器硬刷看到新版本,Slack 上用户还在反馈看到老的坏页面,有人几小时都是这样,少数人几天都是。元凶几乎都是某个 PWA、Workbox、Next.js / Astro PWA 插件装上的 service worker:它在网络之前拦住请求,发回缓存里的 index.html,而那份 HTML 引用的旧 JS 哈希块在服务器上可能根本不存在了(直接白屏)。这是”部署完用户仍看到旧版”最常见的一种 bug,需要部署期修复 + 一条给已经被困住用户的逃生通道。
常见原因
按用户反馈出现频率排序。
1. service worker 用 CacheFirst 缓存 index.html
Workbox / vite-plugin-pwa / next-pwa 的默认策略有时会用 CacheFirst 缓存 HTML。一旦进缓存,SW 永远从缓存发 —— 用户永远拿不到引用新 JS 哈希块的新 HTML。
如何识别:DevTools → Application → Service Workers,看 SW 源码。navigationRoute 或 HTML 路由上的 CacheFirst 就是元凶。
2. 新 SW 装上了但没调 skipWaiting()
浏览器静默装了新 SW,它停在 waiting 状态,要等该站点所有 tab 关掉才会激活(生产力工具用户可能挂着几天)。在此之前老 SW 一直控制页面。
如何识别:DevTools → Application → Service Workers 显示 “waiting to activate” 或同时列了两个 SW 版本。“reload to update” 横幅一直不触发。
3. 缓存的 chunk 引用了已不存在的哈希
Workbox 第 N 次构建的 precache manifest 指向 /assets/index.abc123.js,第 N+1 次哈希成 /assets/index.def456.js。SW 发缓存 index.html 引用 abc123,但服务器返回 404,页面直接空白。
如何识别:用户看到空白页;DevTools Network 显示某个 JS chunk 404;SW Application 标签里的 precached 条目指向缺失的哈希。
4. 缓存范围设到根、长期不清
navigator.serviceWorker.register('/sw.js', { scope: '/' }) 控制根下所有 URL。几个月前装的 SW 至今没更新,期间你改过路径、删过路由,SW 还在声称这些路由归它管。
如何识别:本该 404 的路径返回了缓存响应。用户身上的 SW 是几个版本前装的,没有更新过。
5. activate 里没调 clients.claim()
即使新 SW 激活,已经打开的 tab 不会被它接管(要么 reload)。
如何识别:DevTools 里新 SW 显示 “activated” 但页面控制权还在老 SW。刷新一次后新 SW 才接管。
6. CDN 给 sw.js 自己加了长 max-age
sw.js 响应头 Cache-Control: max-age=31536000,浏览器 HTTP 缓存直接命中 SW,根本不去看新版。SW 更新生命周期永远不启动。
如何识别:curl -I https://your-site/sw.js 显示长 max-age。浏览器 DevTools Network 显示 sw.js 走 disk cache 而非 network。
7. SW 条件注册,生产上静默失败
if (window.location.hostname !== 'localhost') registerSW() —— 如果只在生产注册,生产 SW install 出 bug(比如 precache 文件缺失)会静默失败,页面看起来正常,但离线支持 / 快速加载没了。
如何识别:DevTools Console 出现 Failed to register a ServiceWorker 或 Service worker installation failed。
开始排查前
- 确认确实装了 SW:DevTools → Application → Service Workers 看注册情况。
- 确认是哪个库装的:Workbox、next-pwa、vite-plugin-pwa、还是自定义。
- 抓一份受影响用户的浏览器 console / network 截图(如果能)。
- 弄清楚当前各路由类型(HTML、JS、图片)的缓存策略。
- 确认你能立刻部署新版 SW 作为修复手段。
需要收集的信息
- 生成 SW 用的库名和版本(
@vite-pwa/astro、workbox-webpack-plugin、next-pwa)。 - 配置里按路由的缓存策略(
runtimeCaching数组)。 sw.js自己的Cache-Control头。- 是否配置了
skipWaiting()和clients.claim()。 - 受影响的 UA / 浏览器列表(PWA 场景多为桌面 Chrome、iOS Safari)。
- 最新部署的 precache manifest 内容。
分步修复
顺序:先止新血、再救已被困用户。
步骤 1:HTML 立刻切到 NetworkFirst
SW 配置里:
// workbox / vite-plugin-pwa
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
options: {
cacheName: "html-cache",
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
},
},
],
新部署后 HTML 总是先走网络,离线才用缓存。带哈希文件名的静态 JS / CSS 可以继续 CacheFirst。
步骤 2:加上 skipWaiting 和 clientsClaim
源码(或配置)里:
// vite-plugin-pwa
VitePWA({
registerType: "autoUpdate",
workbox: {
clientsClaim: true,
skipWaiting: true,
},
});
或自定义 sw.js:
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
注意:忙碌会话上 skipWaiting 可能造成开着的 tab 与新 SW 版本瞬时不一致。内容站可以接受;有重要进行中状态的应用建议改为弹窗提示用户手动刷新。
步骤 3:不让 sw.js 自己被长缓存
Vercel/Netlify 头配置:
// vercel.json
{
"headers": [
{
"source": "/sw.js",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" },
{ "key": "Service-Worker-Allowed", "value": "/" }
]
}
]
}
SW 文件本身每次访问都必须重新拉,否则更新永不发生。浏览器对 sw.js 有一些特别照顾,但显式 header 能消除所有模糊地带。
步骤 4:给已经被困的用户发一个紧急关停 SW
部署一个会自我卸载的 SW:
// public/sw.js —— 紧急自卸
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
const clients = await self.clients.matchAll();
clients.forEach((c) => c.navigate(c.url));
})());
});
临时部署它。任何还有 SW 的用户下次打开页面会触发自卸 + 刷新 + 拿到真 HTML。1-2 周后再换回你正常(已修复)的 SW。
步骤 5:每次部署让 sw.js 字节不同,强制重新注册
浏览器逐字节对比 sw.js,任何一字节差异就触发 install。强制做到这点:
// sw.js 顶部
const BUILD_ID = "__BUILD_ID__"; // 构建时替换为 commit SHA
构建步骤:
sed -i "s/__BUILD_ID__/$(git rev-parse --short HEAD)/g" dist/sw.js
每次部署 sw.js 都字节不同,更新流程总能启动。
步骤 6:页面内加更新提示横幅
如果不开 skipWaiting,可在 waiting SW 出现时弹提示:
// register-sw.ts
import { registerSW } from "virtual:pwa-register";
const updateSW = registerSW({
onNeedRefresh() {
if (confirm("New version available. Reload?")) updateSW(true);
},
});
把”被困用户”变成一键自救,不必动用紧急关停 SW。
步骤 7:审 Service-Worker-Allowed 和 scope
SW 放在 /static/sw.js,但希望它控制 /:
Service-Worker-Allowed: /
不加这个头 SW 只能控制 /static/...,多半不是你想要的。相关响应头排查见 robots txt not working。
验证
- 新部署:清浏览器 SW + 缓存,打开站点,Application → Service Workers 立即显示新 SW 已激活。
curl -I https://your-site/sw.js返回cache-control: max-age=0。- DevTools Network 每次导航都看到新 HTML,不再 “(from ServiceWorker)” 拉到旧内容。
- 找人测一次紧急关停 SW,确认一次刷新就能让被困用户恢复。
- 一周后 Slack 上没人再反馈”还在看老页面”。
长期预防
- HTML 默认
NetworkFirst,加短networkTimeoutSeconds;绝不CacheFirstHTML。 skipWaiting必须搭配clientsClaim,单独用任一项都会留下尾巴。- 托管配置里把
sw.js钉在max-age=0,不给自己留忘记的机会。 - SW 源码里嵌 build ID / commit SHA,每次部署字节都变。
- 始终给用户加更新提示横幅,给一条”软”逃生路径。
- 准备好一条紧急自卸 SW 分支随时可发,迟早会用到。
常见坑
- “为修 bug” 直接禁用 SW —— 已装 SW 的用户在你发自卸 SW 之前一直坏。
- “为了快” 给 HTML 用
CacheFirst—— 首次加载省 200ms,每次部署给一周的混乱。 - 忘了 iOS Safari 的 SW 极顽强,iPhone 用户可能几周都不触发更新。相关浏览器状态调试见 vercel 500 errors。
- 有进行中状态的应用上调
skipWaiting()—— 新 SW 半途接管,用户未保存的编辑会丢。 - 用文件名版本
sw-v2.js却不卸载老的,两个同时跑,行为更奇怪。
常见问答
Q: 怎么从服务端日志识别哪些用户卡在旧 SW 上?
看 UA + 请求的静态资源版本。如果某用户在请求 build #88 的 chunk 哈希而你现在是 build #112,他就被困了。可以记一个 x-build-id 合成 header 与当前对比。
Q: 干脆不用 service worker 行不行?
不需要离线、不需要快速重访、不需要推送的话,可以直接不要。多数内容站不需要 SW。它只在能抵得过自身复杂度时才值得留。
Q: 为什么 iOS Safari 的用户更容易遇到?
iOS Safari 跨会话保留 SW 更激进,对 skipWaiting() 反应更慢。紧急关停 SW 对 iOS 用户尤其重要。
Q: 紧急关停 SW 会把已经在用的 PWA 装机搞坏吗?
它只卸载 SW。下次用户访问、你已经部署了新好 SW 时,install 流程会从头跑。“Add to Home Screen” 快捷方式仍可用,PWA 功能会恢复。相关白屏症状排查见 static site blank page。
标签: #排查 #service-worker #pwa #cache #部署