WebView 里永远更新不了的 H5,最后发现是旧 PWA 插件在作祟

这个事故从头到尾都很像微信强制缓存:只影响部分用户、怎么发版都不更新、Nginx 怎么配都没用。

直到抓到那句 from service worker,才发现真正的“强制缓存”来自我们早期上线过的 Service Worker

天线宝宝_teletubbies-24

前情提要:从 WebView 开始,一路误判到“再观察观察”

第一阶段:小程序 WebView 出现“永远是旧页面”

最早是在小程序 WebView 里出现的。表现是:

  • 只有部分设备中招

  • 重进小程序、下拉刷新、甚至重启微信都没用

  • 重新发版了,用户还是旧页面

  • 加了 hash、随机数、时间戳都没有效果

我们当时的默认假设很自然:微信强制缓存策略太凶。于是一路骂微信(私密马赛

天线宝宝_teletubbies-15

第二阶段:各种 Nginx 去缓存都没效果

中途我们尝试了很多“传统的缓存治理手段”:

  • Cache-Control: no-cache / no-store

  • Expires -1

  • Pragma: no-cache

  • index.html、静态资源分别配置

结果:问题依然存在。。。两个月没彻底解决,就搁置“观察观察”。

第三阶段:公众号 H5 端正式服上线后,我复现到最极端

H5 正式服上线后,我用电脑从公众号菜单进去:

  • 直接看到三个月前的页面

  • 怎么刷新都不变

  • 更要命的是:服务器日志看不到请求,Network 也显示根本没请求服务器

这就导致“网络侧”方案注定无效:因为请求压根没出客户端

第四阶段:“二战”转折点——把 request header 发给 GPT

我把那条“缓存命中”的完整请求信息贴给 GPT,它提醒:像是 PWA/SW 问题,让我去检查项目里是否启用了 PWA。

然后我检查了一下:

  • 项目早期确实启过 vite-plugin-pwa

  • 后来注释掉了

  • 只要用户访问过那段时间的版本,就可能被旧 SW(service worker) 永久控制

为什么疯狂修改 Nginx,用户侧却一点反应没有?

正常请求链路(没有 Service Worker 时)

flowchart LR U[User/WebView] -->|HTTP GET| CDN[CDN/WAF] CDN --> N[Nginx/OpenResty] N --> APP[Static files / index.html] APP --> U

在这个模式下,调 Nginx、加标签,都有效,因为请求一定会经过它们。

被旧 Service Worker 控制时:

flowchart LR 用户[用户 / WebView] --> 旧SW[旧服务工作线程] 旧SW -->|缓存命中| 缓存[缓存存储] 缓存 --> 旧SW 旧SW --> 用户 旧SW -->|缓存未命中| CDN[CDN / WAF] CDN --> Nginx[Nginx / OpenResty] Nginx --> 源服务器[应用服务器] 源服务器 --> Nginx Nginx --> CDN CDN --> 旧SW 旧SW --> 用户

命中缓存时的结果:

请求从 Cache Storage 直接返回,完全绕过 CDN/Nginx/Origin

所以改 Nginx 头、改缓存策略、甚至服务器发版,用户侧都毫无变化——因为用户根本没和服务器说过话。

为什么“刷新页面”也没用?

sequenceDiagram participant 页面 as Page (受控) participant 旧SW as Old Service Worker participant 缓存 as Cache Storage participant 网络 as Network 页面->>旧SW: 请求 /assets/index-*.js 旧SW->>缓存: 匹配(request) alt 命中 缓存-->>旧SW: 返回旧响应 旧SW-->>页面: 200(来自 SW) else 未命中 旧SW->>网络: 发起请求(request) 网络-->>旧SW: 返回响应 旧SW-->>页面: 响应 end

页面刷新只会重新触发 fetch,而 fetch 仍然先被 SW 拿到。只要 SW 策略偏“缓存优先”,就会一直命中旧资源。

为什么“注释掉 PWA”会让问题更严重?

“那我把 PWA 插件关掉了,不就没有 SW 了吗?”

但事实是:它注册在用户浏览器里,不在你的代码里。

SW 的“持久在线” vs “发版周期”

timeline title 服务工作线程 (SW) 与版本发布 (Release) 对比 2025-xx : 版本 A 发布(启用 PWA) -> 部分客户端注册了 SW 2025-xx : 版本 B 发布(PWA 注释掉) -> 没有生成/更新新的 SW 脚本 2025-xx : 版本 C/D/E 发布... -> 部分客户端仍然被旧的 SW 控制

一旦你停止发布 SW 脚本(或让它 404 / 被 CDN 长缓存),那些已注册旧 SW 的用户就可能长期停留在“旧 SW + 旧 Cache Storage”的世界里。

解决方案:插入“自毁型 sw.js”,实现无感修复?

其作用如下:

  • 让旧 SW 退出舞台

  • 把此前留下的 Cache Storage 完全清理掉

  • 让页面回到正常更新路径

stateDiagram-v2 [*] --> 安装 安装 --> 激活: skipWaiting() 激活 --> 清理缓存: caches.keys() + delete() 清理缓存 --> 取得客户端控制权: clients.claim() 取得客户端控制权 --> 重新加载客户端: client.navigate(url) 重新加载客户端 --> 注销ServiceWorker: registration.unregister() 注销ServiceWorker --> [*]

我们发布的 sw.js 大致就是按这个状态机跑完后消失。

非常关键的一点:新 SW 不实现 fetch handler。

这意味着它不会继续当“缓存层”,清完场就退场。

解决方案:用同名路径把 SW 注销掉

我最终的解决方案是覆盖 sw 的常见路径:

  • /sw.js

  • /service-worker.js

新脚本的核心动作是:

  • skipWaiting():让新 SW 立刻激活,不要等旧页面关闭

  • Cache Storage:删掉 Workbox precache + 历史遗留缓存

  • clients.claim() + navigate():尽量自动刷新现有页面(更“无感”)

  • unregister():注销自己

还有一个细节:sw.js 必须永远“可更新”

如果 /sw.js 被 CDN/浏览器长缓存住,就会出现:

  • 虽然发了“自毁 SW”

  • 但用户一直拿到旧的 sw.js(或拿不到更新)

  • 于是旧 SW 继续控制,修复失败

那才是真滴完啦哈哈哈

天线宝宝_teletubbies-13

所以如果涉及 SW 文件,建议单独设置强制 revalidate(至少 no-cache),并在发布时对该路径做 purge。