外观
PWA 离线落地实录:Service Worker 缓存策略与发版不生效的治理方案
两个真实场景促成了这次改造:一是工厂车间的平板在弱网环境下加载文档站要等半分钟,切个页面转圈一次;二是运营反复反馈「明明发了新版,页面还是旧的」,最后发现是用户没关过标签页,旧 Service Worker 一直在站岗。PWA 的离线能力能解决第一个问题,但它的更新机制如果不治理,反而会加重第二个问题。
本文按「缓存策略选型 → vite-plugin-pwa 接入 → 更新治理」的顺序记录完整落地过程,所有代码可直接运行。
缓存策略选型:没有万能策略,只有按资源类型分工
Service Worker 的本质是一个常驻的代理层:页面发出的每个请求都会先经过它,你可以决定「从缓存给、从网络给、还是网络优先缓存兜底」。三种基础策略各有明确的适用资源:
| 策略 | 行为 | 适用资源 |
|---|---|---|
| Cache First | 缓存命中直接返回,不回源 | 带内容哈希的 JS/CSS、图片字体 |
| Network First | 先请求网络,失败回退缓存 | HTML 文档、实时性 API |
| Stale-While-Revalidate | 先给缓存,后台更新缓存 | 不常变但仍需更新的静态资源 |
核心判断依据是资源的「版本敏感度」:构建产物文件名带哈希,内容变即文件名变,Cache First 永远安全;HTML 是一切入口的引用源,缓存旧 HTML 会连带着加载旧资源,必须 Network First;字体这类「变了也无所谓晚一点变」的资源,Stale-While-Revalidate 体验最好。策略配错资源类型,就是「发版不生效」类事故的根源。
实操:vite-plugin-pwa 一站式接入
Vite 生态用 vite-plugin-pwa,它把 Workbox 的配置翻译成构建流程的一部分,SW 与 precache 清单在构建时自动生成:
ts
// vite.config.ts:PWA 接入核心配置
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
registerType: "prompt", // 手动提示更新,避免用户填表时被强刷打断
workbox: {
// SPA 回退只对页面路由生效,接口路径排除在外
navigateFallbackDenylist: [/^\/api/],
// 预缓存:构建产物(带哈希)自动进入 precache 清单
runtimeCaching: [
{
// 静态资源:缓存优先 + 数量与时间双重过期,防膨胀
urlPattern: /\.(?:png|jpg|svg|woff2)$/,
handler: "CacheFirst",
options: {
cacheName: "static-assets",
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }
}
},
{
// 业务接口:网络优先,弱网 3 秒超时回退最近一次成功响应
urlPattern: /^\/api\//,
handler: "NetworkFirst",
options: { cacheName: "api-cache", networkTimeoutSeconds: 3 }
}
]
}
})
]
});这份配置的关键取舍:registerType: "prompt" 而不是 "autoUpdate"——自动更新模式会在检测到新 SW 接管时直接刷新页面,用户正填一半的表单会被清空;预缓存清单由构建自动生成,构建产物天然带哈希,Cache First 交给 Workbox 默认处理;运行时缓存只配「构建清单之外」的资源(图片、接口),每一条都带过期策略。
更新治理:让新版本真正到达用户
PWA 最反直觉的机制在这里:新 Service Worker 安装成功后并不会立即接管页面——只要旧 SW 控制着任何一个打开的标签页,新 SW 就停留在 waiting 状态。长开的后台标签页、从不关闭的平板应用,就是这个机制的最佳养料。
prompt 模式下的完整治理流程分三步。第一步,主线程注册 SW 并监听「有待接管的新版本」事件:
ts
// main.ts:注册 SW,更新时机由用户确认
import { registerSW } from "virtual:pwa-register";
const updateSW = registerSW({
// 检测到新 SW 进入 waiting 状态时触发
onNeedRefresh() {
showToast("发现新版本,点击刷新体验", () => updateSW(true)); // 用户确认后刷新
},
// 首次离线缓存就绪,可以告知用户「离线也能用了」
onOfflineReady() {
console.log("[PWA] 离线缓存就绪");
}
});第二步,提示时机讲究「低风险」:轮询检测到新版本后不要立刻弹窗打断操作,挂在页面角落等用户空闲时点按。第三步,留一条兜底通道:对 Web 端管理后台这类强时效系统,可以在用户下次进入路由时再确认一次,仍未更新的给出行内提示条,避免「点了忽略就永远不更新」的死角。
治理效果要用数据验证:在应用里埋一个版本号上报(构建时注入),统计新版本发布后 24 小时内的到达率。改造前该文档站的新版到达率长期在四成上下徘徊,治理后一周内到达率接近全量——数字是最有说服力的验收标准。
最后要说清离线能力的边界:离线不是全站照搬。登录、支付、实时行情这类页面应明确排除在离线体验之外——接口层 NetworkFirst 的超时回退只该兜住「读起来无所谓新旧」的数据,写操作在断网时应当直接提示失败而不是默默排队,离线写同步意味着一整套冲突解决体系,复杂度远超常规离线展示。文档站的落地经验是:导航与文章页做离线可读,表单与检索页在网络不可用时展示明确的离线提示页。给用户一个「看似能用、提交必失败」的界面,比诚实的离线提示页伤害大得多。
避坑总结
- HTML 绝不能 Cache First。发版不生效的头号事故源:旧 HTML 引用旧资源,SW 还在欢快地提供全量缓存;HTML 走 Network First 或干脆直出网络,页面级缓存只做离线兜底。
- autoUpdate 的强刷会丢用户状态。表单填写中、富文本编辑中的页面被静默刷新,换来的是客诉;prompt 模式 + 用户确认是所有有输入场景的默认选择。
- 缓存不设过期就是慢性 DoS。Cache Storage 没有自动清理,长期运行后设备存储被吃满,浏览器在存储压力下可能清掉全部缓存;maxEntries 与 maxAgeSeconds 必配。
- scope 与部署路径要一致。部署在子路径(如 /docs/)时,manifest 的 start_url 和 scope、SW 的注册路径都要带上前缀,否则 SW 注册失败且报错很隐蔽;本地调试先看 Application 面板的 SW 状态。
- 调试期先学会「绕过」。开发时被自己的缓存坑到怀疑人生是常态:DevTools 的 Application 面板用「Bypass for network」+ Update on reload,每次改完代码硬刷新,别在缓存迷宫里耗时间。
写在最后
PWA 离线方案的落地,一半是缓存策略的选型,另一半是更新机制的治理。前者决定了「离线时能不能用」,后者决定了「在线时用户用的是不是新版」。策略按资源版本敏感度分工、更新交给用户在低风险时机确认、到达率用版本埋点验证——三件事做齐,离线能力才真正从演示特性变成生产特性。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。