ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

uni-app H5 搭配 PWA 后更新不生效?三招根治缓存与 Service Worker 更新链路

uni-app H5 搭配 PWA 后更新不生效?三招根治缓存与 Service Worker 更新链路 先说个现象uni-app 打出来的 H5 包配合 Service Worker 做了 PWA上线之后你推了新版结果用户那边怎么刷新都是旧页面。有些用户关掉浏览器重进才好有些干脆一直停在旧版本上。你本地看是好的隐身窗口看是好的但只要真实流量上来问题就冒出来了。这个系列第一篇讲了 uniapp 怎么把 H5 包改造成 PWA第二篇我们专门治这个“更新不生效”的毛病。按我做过的项目来说这个坑不是偶然踩中而是没有把 PWA 的更新链路想清楚之前几乎必然踩中。这篇会把机制拆开再给一套能直接用的工程方案覆盖从 sw.js 生成到页面提示、再到上线后排查的完整流程。1. 先把锅分清楚更新不生效往往不是 Service Worker 一个人的问题1.1 三层缓存同时作用你以为是 SW 的锅其实是 HTTP 缓存很多人一听到“PWA 更新不生效”第一反应就是 Service Worker 缓存捣乱然后去研究 cache 版本、强刷、清缓存。但在我实际排查的几个项目里真正卡住用户的往往不是 Service Worker而是更外层、更基础的那一层HTTP 缓存。一个 PWA 页面加载时资源要经过的缓存链路大致是这样浏览器 HTTP 缓存服务器通过Cache-Control、Expires等响应头决定页面和静态资源能不能被本地缓存、缓存多久。Service Worker 缓存SW 拦截请求后通过 Cache Storage API 自己维护的一套缓存。页面容器缓存如果外面套了 WebView或者用户用某些浏览器内置的预加载机制还会有更隐蔽的缓存。很多项目只盯着 SW 那一层把caches.delete写得很溜却忽略了服务器给index.html设置的长缓存。这样就会出现一个很典型的局面SW 已经把新版本资源都准备好了但页面加载时根本没有请求到新index.html浏览器直接拿着旧的 HTML 跑了旧 HTML 里引用的还是旧 JS 文件名于是你推的新版本跟没推一样。如果你用了 Nginx 或者 CDN先检查一下index.html的响应头。对于 PWA 来说index.html应该用no-cache或非常短的缓存时间让它每次都能回到服务器确认版本。静态资源则应该走带 hash 的文件名加长缓存这样既能享受缓存红利又不会影响版本更新。1.2 uniapp H5 的构建产物和 SW 缓存是什么关系把 uniapp 项目编译成 H5 后产出一般长这样index.html整个应用唯一的 HTML 入口。static/js、static/css目录Vite 或 Webpack 编译出来的 JS、CSS 文件文件名里通常带内容 hash。manifest.jsonPWA 的 Web App Manifest描述应用名、图标、主题色等。sw.js如果项目里配置了会从public目录复制到构建产物根目录。带 hash 的静态资源是天然适合缓存的内容变了文件名就变了老缓存不会命中新请求。但index.html是恒定的 URL它必须承担“指引浏览器去加载哪个版本”的角色。换句话说只要index.html是旧的后面所有资源再新也白搭。这也是我特别强调“先检查 HTTP 缓存”的原因。SW 的 fetch 拦截虽然能覆盖页面请求但如果你的 SW 逻辑写的是 cache-first而且缓存里已经有旧的index.html那么即使网络上有新版本用户拿到的仍是旧的。也就是说SW 这一层同样可能导致 HTML 陈旧。1.3 先用一张对照表框定问题方向遇到“更新不生效”我会先根据现象快速定位而不是一上来就改代码现象最可能的原因优先排查入口所有用户都停在旧版本服务器index.html缓存时间过长检查响应头Cache-Control部分用户新、部分用户旧SW 更新存在延迟或用户长时间不关闭页面检查 SW 生命周期与 waiting 状态强制刷新后能更新缓存策略 cache-first 太激进调整 fetch 策略为网络优先手机上有问题电脑上没问题手机浏览器省电模式或 WebView 容器缓存检查手机端浏览器版本与容器行为改了 sw.js 但没反应sw.js 内容没变化字节对比不通过确认每次构建是否真的改了 sw.js 内容这个表不是最终答案但它能帮你把排查范围从“一头雾水”缩小到具体某一层。接下来我们深入 SW 本身。2. Service Worker 更新链路里真正卡住你的其实就两个细节2.1 字节对比机制sw.js 内容不变浏览器就当无事发生Service Worker 的更新检查有个基础机制很多教程一笔带过但它恰恰是“更新不生效”最容易踩的点浏览器不是通过版本号判断 SW 有没有新版本而是通过字节对比。每次浏览器要检查更新时会重新拉取 sw.js 脚本然后和当前已安装的 sw.js 做逐字节对比。只要内容有任何不同哪怕只改了一个空格、一行注释浏览器就会认为这是一个新 SW进入安装流程。如果内容完全一致浏览器就认为“没新版本”什么都不做。这就是为什么很多人改了自己的业务代码后发现 SW 没有更新你在构建产物里更新的 JS、CSS 都变了但 sw.js 本身一个字都没变那浏览器自然认为整个 PWA 没有新版本。它不会替你“聪明地”去检查缓存里的其他资源。所以工程上必须保证一件事每次发布sw.js 的内容一定要有变化。最简单的方式就是把构建时间或版本号写进 sw.js 里比如生成一个带时间戳的注释或者把版本号放到缓存名前缀里。这样字节对比必然通过SW 更新才会被触发。提示字节对比的对象是 SW 脚本本身。如果你只是改了manifest.json或者别的静态资源sw.js 没变浏览器不会因为这个主动更新 SW。2.2 waiting 与激活接管新版 SW 装好了但不代表它立刻干活字节对比通过之后新 SW 进入 installing 状态。如果安装顺利它会进入 waiting 状态也就是“等待中”。这个等待状态是个非常经典的卡点。等待的原因是浏览器默认不希望新 SW 打断当前正在被旧 SW 控制的页面。如果用户当前还开着你的站点旧 SW 正在托管这些页面新 SW 装好了也只能等着直到所有被旧 SW 控制的页面都关闭后才会自动 activate。对用户来说这意味着就算新版本已经下载到了浏览器里只要他还有一个标签页没关新 SW 就一直不接管下次再打开站点要先加载一遍旧逻辑等 SW 切换完成后才可能走到新逻辑。表现上就是“更新不生效”。解决办法就是那两个 APIself.skipWaiting()和self.clients.claim()。skipWaiting()让处于 waiting 状态的新 SW 跳过等待直接进入 active。clients.claim()让新 SW activate 之后立刻接管所有已被控制的客户端页面不用等页面刷新。这两个 API 在研发期尤其有用能让你改了 SW 代码后马上生效。但在生产环境要用得有策略不能无脑全开。我后面的方案里会区分“静默更新”和“提示后更新”两种模式。2.3 fetch 策略决定一切SW 上岗了资源也不一定更新很多项目好不容易解决了 SW 的激活问题发现资源还是旧的那一份。问题出在 fetch 监听里的缓存策略。如果你在 SW 的 fetch 事件里写的是先去caches.match找缓存找到就直接返回完全不碰网络这就是 cache-first。离线支持确实好但线上更新也受它限制。就算新 SW 激活了只要缓存里还有旧的index.html、旧的 JS它就能一直满足请求永远不给网络机会。用户看到的就永远是旧版本。正确的思路要按资源类型区分策略。以 uniapp H5 为例HTML 导航请求页面跳转、刷新用 network-first也就是先请求网络网络成功就返回网络结果并更新缓存网络失败再回退到缓存。这能保证入口文件总是最新的。带 hash 的静态资源JS、CSS、图片文件名本身带版本信息可以用 cache-first即使走缓存也没问题因为文件名变了一定会触发新请求。需要新鲜度的接口别让 SW 参与或者只做网络优先不写缓存。如果你的静态资源文件名没有 hash那更要注意。没有 hash 的固定文件名一旦 SW 把旧内容写进缓存后面所有用户拿到的都是旧文件。此时应该用 stale-while-revalidate先返回缓存内容让页面快速显示同时在后台请求网络用新响应更新缓存这样下一次加载就是新的了。3. Uniapp 项目里可直接用的自动更新工程方案3.1 设计思路让每次发布都有“新东西”进入 SW 更新链路光讲原理不够工程上需要一套能直接塞进 uniapp 项目的方案。我的设计思路是这样每次构建时生成一个新的 sw.js内容里包含本次构建的时间戳或版本号。只要 sw.js 内容变了浏览器的字节对比就会触发 SW 更新。SW 更新后执行缓存清理删除老版本的 Cache Storage写入新缓存。页面端监听 SW 更新状态选择“自动刷新”或“给用户弹提示”的方式完成切换。一句话把构建时间变成版本锚点让每次发版都在 SW 层面产生一个“可感知的变化”。3.2 构建脚本自动生成带版本号的 sw.js我推荐在 uniapp 项目里用 Vite 的构建流程同时加一个小脚本。每次执行npm run build:h5时先跑这个脚本生成 sw.js再走正常编译流程。先看脚本本身我用 Node 写放在scripts/generate-sw.jsconst fs require(fs) const path require(path) const BUILD_VERSION Date.now().toString(36).toUpperCase() const swCode const BUILD_VERSION ${BUILD_VERSION} const CACHE_NAME uniapp-pwa- BUILD_VERSION const PRECACHE_LIST [ ./, ./index.html, ./manifest.json, ./static/js/, ./static/css/ ] self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME) .then((cache) cache.addAll(PRECACHE_LIST)) .catch((err) console.warn(precache failed:, err)) ) self.skipWaiting() }) self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) { return Promise.all( keys .filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) ) }) ) self.clients.claim() }) self.addEventListener(fetch, (event) { const request event.request if (request.method ! GET) return if (!request.url.startsWith(self.location.origin)) return // 页面导航走网络优先保证入口文件最新 if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const clone response.clone() caches.open(CACHE_NAME).then((cache) cache.put(./index.html, clone)) return response }) .catch(() caches.match(./index.html)) ) return } // 静态资源走 stale-while-revalidate兼顾速度与更新 event.respondWith( caches.match(request).then((cached) { const networkFetch fetch(request) .then((response) { if (response response.ok) { const clone response.clone() caches.open(CACHE_NAME).then((cache) cache.put(request, clone)) } return response }) .catch(() cached) return cached || networkFetch }) ) }) self.addEventListener(message, (event) { if (event.data event.data.type SKIP_WAITING) { self.skipWaiting() } }) const outputPath path.join(__dirname, ../public/sw.js) fs.writeFileSync(outputPath, swCode, utf8) console.log(sw.js generated with version:, BUILD_VERSION)注意几个关键点BUILD_VERSION用的是时间戳的 base36 字符串即使一天构建十次内容也不会重复。skipWaiting()我保留在 install 里同时也在 message 监听里提供手动跳过等待的入口。这样既可以走“自动更新”也可以走“用户确认后更新”。activate 里删除旧缓存这一步必须做否则旧缓存会一直留在 Cache Storage 里白占空间不说还可能干扰后面的 fetch 匹配。fetch 事件里用self.location.origin做一次过滤只处理同源请求。跨域的 CDN 资源是否拦截要看具体场景我不建议在基础方案里把所有跨域资源都纳入 SW 管理容易碰见 CORS 响应异常。然后在package.json里调整构建命令{ scripts: { build:h5: node scripts/generate-sw.js vue-cli-service build --mode production } }如果你的 uniapp 是用 HBuilderX 里的“发行”按钮打包没法直接改 npm scripts那就改成手动执行脚本把生成的public/sw.js提交到项目里也能用。核心逻辑没变每次发布的 sw.js 必须不同。3.3 页面端监听 SW 更新按需刷新或提示用户SW 那边准备就绪后页面端要做的是感知 SW 的状态变化然后决定“直接刷新”还是“告诉用户”。我一般在 uniapp 的 App.vue 里H5 端挂一段逻辑export default { onLaunch() { // #ifdef H5 if (serviceWorker in navigator) { this.setupSWUpdate() } // #endif }, methods: { setupSWUpdate() { let refreshing false navigator.serviceWorker.addEventListener(controllerchange, () { if (refreshing) return refreshing true // 新 SW 接管后强制刷新拿到新页面 window.location.reload() }) navigator.serviceWorker.register(/sw.js).then((registration) { // 如果当前已经存在 waiting 的新 SW直接提示或刷新 if (registration.waiting) { this.noticeOrReload(registration.waiting) } registration.addEventListener(updatefound, () { const newWorker registration.installing if (!newWorker) return newWorker.addEventListener(statechange, () { if (newWorker.state installed navigator.serviceWorker.controller) { this.noticeOrReload(newWorker) } }) }) }) }, noticeOrReload(worker) { // 简单方案直接跳过等待controllerchange 会自动刷新 worker.postMessage({ type: SKIP_WAITING }) // 如果想给用户提示这里可以改成弹窗等用户点击后再 postMessage } } }上面这个写法是“自动刷新”路线发现新 SW 后直接给它发SKIP_WAITING新 SW 激活后触发controllerchange页面自动刷新。好处是无感用户最多感觉闪了一下坏处是如果用户正在填表单、正在看视频强制刷新会打断体验。如果是内容型站点我建议用自动刷新。如果是工具型、带操作状态的站点我更建议改成提示模式noticeOrReload(worker) { // 用 uni.showModal 提示用户 uni.showModal({ title: 发现新版本, content: 是否立即更新到最新版, success(res) { if (res.confirm) { worker.postMessage({ type: SKIP_WAITING }) } } }) }用户点“确定”之后SW 跳过等待激活页面刷新新版本生效。这里的体验控制比“强制刷新”温和得多我觉得生产环境更应该用这种。3.4 发布流程从改代码到线上生效的完整检查单配合上面的方案我每次发版走这样的流程确认scripts/generate-sw.js能正常执行生成的 sw.js 内容里包含新的 BUILD_VERSION。执行npm run build:h5产物生成到dist/build/h5。把产物部署到服务器注意替换index.html时别覆盖保留旧缓存目录让 Nginx 给index.html配Cache-Control: no-cache。静态资源目录正常发布即可带 hash 的文件不会和旧的冲突。自己用无痕窗口打开页面看 Network 面板里 sw.js 是不是新版本Cache Storage 里是不是新缓存名。第二次打开页面确认没有报错确认旧缓存被清理掉。这套流程跑熟了之后你会发现“更新不生效”的概率大幅下降。剩下偶尔冒出来的问题基本都能靠下一节的排查手段快速定位。4. 实战排查我遇到过的几个“更新不生效”现场4.1 现场一服务器 Cache-Control 直接把 SW 甩开了有个项目部署在 CDN 后面CDN 给index.html默认加了Cache-Control: max-age3600。第一次访问时 SW 正常安装用户也看到了页面。之后我们发布新版本SW 的 fetch 逻辑是 network-first按说应该能拿到新 HTML。但问题是CDN 节点直接返回了缓存里旧 HTMLSW 拿到的就是旧 HTML 并把它写进了缓存。结果用户永远是旧版本而且看起来 SW “正常工作”。排查过程先看 Network 面板发现请求/返回的是from disk cache根本没走到 SW。再查响应头确认Cache-Control是长缓存。这时候就算把 sw.js 改上天也没用因为入口请求在到达 SW 之前就被 HTTP 缓存吃了。解决把 CDN 针对 HTML 类型的缓存改成no-store或者极短的s-maxage让请求每次都能回源或至少回到应用服务器。静态资源保持长缓存因为文件名有 hash。这也提醒我一个原则PWA 的 SW 不是万能网关它只是浏览器进程里的一个拦截器。HTTP 层一旦命中缓存可能根本轮不到 SW 执行。4.2 现场二sw.js 内容没变化浏览器根本不更新另一个项目我们发版后等了半小时线上用户还是旧版。自己用 Chrome 打开Application 面板里看到 SW 状态永远停在“activated but no new version available”。查到最后发现前端同事改了页面代码但 sw.js 是手工维护的这次发版压根没动它。浏览器每次对比 sw.js 的字节都和已安装的一模一样自然认为“没有新 SW”也就不会去执行skipWaiting、activate这些流程。缓存里的旧资源被反复命中新版本永远上不去。解决引入了构建时自动生成 sw.js 的脚本也就是上面那段代码。从制度上保证“每次发版 sw.js 必然变”。这是最治本的办法比靠人记着“这版记得改版本号”靠谱得多。4.3 现场三register 作用域不对SW 管不到页面还有一个项目部署在子路径下比如https://example.com/h5/。当时图省事把 sw.js 放在了站点根目录的静态服务里页面里注册时写的是/sw.js。理论上 SW 作用域是整个/能管到/h5/下的页面。但实际上因为部署配置问题根目录的 sw.js 没有正确发布实际访问路径返回的是 404注册自然失败SW 一直没装上。后来把 sw.js 直接放到/h5/sw.js注册路径改成./sw.js作用域默认就是/h5/问题才解决。这里的关键在于SW 的作用域取决于 sw.js 文件的 URL 路径而不是你注册时想管多大就管多大。跨作用域需要Service-Worker-Allowed响应头配合否则会被浏览器拒绝。如果你用了子路径部署务必确认 sw.js 所在目录和页面 URL 的覆盖关系。否则你排查半天最后发现是 SW 压根没接管。4.4 一份我实战中用的排查顺序清单遇到更新不生效我的排查顺序是固定的按从外到内无痕窗口打开站点看 Network 面板确认index.html有没有走网络响应头对不对。看sw.js是否被正常加载Network 里有没有 404 或 200。Application 面板 - Service Workers看当前状态是 running 还是 stopped有没有新的 installing/waiting worker。Application 面板 - Cache Storage看缓存名是不是新版本旧缓存有没有被清理。手动点一次 “Update on reload”在 DevTools 里勾选后刷新看 SW 是否能正确更新。如果手动更新成功、用户环境更新失败再检查是不是浏览器兼容或服务器缓存问题。这套顺序基本能覆盖 90% 的情况。剩下的就是浏览器版本差异、企业私网缓存这种环境问题通常做线上兜底提示比硬修更高效。5. 别让“自动更新”绑架用户体验一点工程上的取舍建议5.1 静默更新和用户提示模式怎么选才合理自动更新是一把双刃剑。用户永远停留在旧版本是体验问题但每次发版都强制刷新也是体验问题。我的取舍是这样内容展示型站点新闻、博客、活动页静默更新用户无感知刷新。工具型页面表单、编辑器、复杂状态提示更新让用户自己选择时机。业务核心页面支付前后、长流程填单不要自动刷新只给“有新版本”的提示让用户处理完当前流程再决定。如果把这两种模式分开看核心区别只在noticeOrReload这一步放的是worker.postMessage({type:SKIP_WAITING})还是uni.showModal。SW 本身的逻辑不用动这点上设计好了解耦后续改起来成本很低。5.2 顺带说下小程序端和 App 端的更新逻辑别在同一套代码里踩坑很多 uniapp 项目是同时做 H5、小程序、App 三端的。这里的“更新不生效”问题在三端有完全不同的表现不能把 PWA 的经验直接搬过去。小程序端微信有自己的包更新机制。用户打开小程序时微信不一定马上拉取最新代码包新版可能需要用户退出后重新进入才生效。很多开发者在代码里用uni.getUpdateManager()监听更新事件在收到“新版本下载完成”后提示用户重启小程序。这个和 PWA 的 SW 接管本质上是两套东西靠的是微信的更新管理接口不是 Service Worker。小程序还有主包 2MB 的体积限制超过就要拆分包这是另一个工程话题。App 端uniapp 的 App 有整包更新和 wgt 资源热更新两种。整包更新要过应用商店审核更新周期长wgt 热更新走的是 uni 原生提供的资源包机制可以绕过商店但 iOS 的审核政策对热更新有限制不能用来绕开审核上架。这个更要小心因为 App 端一旦出现“旧版不兼容新版接口”的情况用户没升级的话问题比 PWA 更新失败严重得多。我的建议是多端项目按“一版配置文件 多端更新代码”的思路拆开H5 走 PWA 的 SW 逻辑小程序走微信的 UpdateManagerApp 走 uni 的资源更新接口。别在某一个端把另一端的逻辑硬塞进去。5.3 上线前回归清单把“更新不生效”挡在发布之前最后分享一份我每次上线前会过的回归清单算是这些年踩坑换来的习惯检查index.html响应头no-cache或短缓存不能被 CDN 长缓存。检查静态资源文件名必须带 hash或保证每次发布内容能覆盖旧文件。检查 sw.js本次构建生成的版本号和上次不同。检查 SW 的 activate 逻辑旧缓存能正常删除。检查 fetch 策略HTML 走网络优先静态资源走缓存优先或 stale-while-revalidate。检查页面端监听controllerchange 和 updatefound 都接上了。真机测试至少用 Chrome 手机模拟、iOS Safari 实测一次因为移动浏览器的行为差异更大。确认用户提示按钮如果用了提示模式按钮事件能触发 SKIP_WAITING。这套流程跑下来你在发布窗口几乎不会再收到“更新不生效”的反馈。PWA 的自动更新难的不是某个 API而是整条链路里多个环节的配合。只要把版本号、字节对比、SW 生命周期、缓存策略这四件事理顺这个痛点就能基本根除。
返回列表