Table of Contents
支持 PWA 的网站体验比不支持的网站要好很多,不仅支持添加到桌面,以类似 APP 的方式启动,还因为缓存了网站资源,后续切换页面速度明显更快,甚至还可以支持离线访问。
但使用这个技术也存在一些问题,比如很久之前我开发了一个扫雷应用,因为把 sw 文件也缓存了,nginx 那里也没处理,导致应用检测不到更新。
在后来的网站(飞雪实验室)开发中,无法更新的问题我避免掉了,但又有了新的问题——service-worker 何时更新。
默认处理
浏览器端在检测到新的 SW 之后会安装新版本(install),但不会替换旧版本,而是进入 waiting 阶段,直到下一次打开网页才会替换旧版本(新版本进入 activated 阶段)。
一般情况下,这也没什么问题,无非就是你发布新版本之后,用户打开还是旧版本,直到再下一次打开才是新版本。但如果遇到需要立即生效新版本的情况该怎么办呢?比如线上有严重 bug。所以我采用的是立即生效方式。
立即生效
SW 有 skipWaiting 和 claim 方法,这会让新版本 SW 准备好之后立即替换旧版本,直接接管网页。
// 使用 workbox 的调用方式 import { clientsClaim } from 'workbox-core' self.skipWaiting() clientsClaim()
这种方式……体验不好。因为这让旧的缓存资源失效(可能我用了 cleanupOutdatedCaches()),而页面还是旧版,这样页面跳转可能就一直 loading。如果网页上加了 loading bar(加载进度条)之类的,用户就只能看着上面的进度条进入龟速、停滞。
当然,这个也不是很严重的问题,用户刷新网页就可以了,反正当网页打不开、报错的时候,一般用户都会刷新网页。不过为了体验有好一些,我还是决定添加一个更新提示弹窗。
更新提醒
// register-service-worker.ts import { register } from 'register-service-worker' register(process.env.SERVICE_WORKER_FILE, { updated(registration) { console.log('New content is available; please refresh.') // https://www.fedrianto.com/quasar-pwa-install-custom-caching-push-notification-background-sync/ // 不知道主线程要怎么监听 workbox 创建的 sw 线程,所以用 broadcast 代替 const channel = new BroadcastChannel('sw-messages') channel.postMessage({ type: 'sw-updated' }) }, })
SW 更新完成后发送一个更新通知,然后页面里监听通知并弹窗。
// App.vue if (process.env.CLIENT) { // 不知道主线程要怎么监听 workbox 创建的 sw 线程,所以用 broadcast 代替 const channel = new BroadcastChannel('sw-messages') channel.addEventListener('message', (data) => { console.log(data) const type = data.data?.type if (type === 'sw-updated') { $q.dialog({ message: t('global.sw.updated'), persistent: true, }).onOk(() => { window.location.reload() }) } }) }
这样体验就好很多了,不需要用户去猜到底是加载慢还是页面出错,也不需要用户主动尝试刷新页面。
但这种方式还是有点不好——如果用户正在操作怎么办?这种只能同意的弹窗和不弹窗自动刷新效果差不多——用户都没得选。所以还需要改改,我们需要允许用户继续使用旧版本。
可选更新弹窗
为了能够取消(延迟)更新,SW 里就不能直接 skipWaiting 了,流程需要改成下面这样:
- updated 通知页面。
- 页面弹窗。
- 用户不同意更新,不需要操作。
- 用户同意更新。
- 页面通知新的 SW 切换控制权(因为直接刷新页面,浏览器是不会主动切换到新版 SW 的)。
- 新的 SW 接管页面。
- 监听到新 SW 变为激活状态,通知页面刷新。
- 页面直接刷新。
看看,一下子就复杂很多了。之前只需要一次通信,一个弹窗(甚至可以没有弹窗),现在不仅需要四次通信,还需要监听状态变更,以及延迟 skipWaiting。
// register-service-worker.ts updated(registration) { console.log('New content is available; please refresh.') // 不知道主线程要怎么监听 workbox 创建的 sw 线程,所以用 broadcast 代替 const sw = registration.waiting if (sw) { const channel = new BroadcastChannel('sw-messages') channel.postMessage({ type: 'sw-updated' }) channel.addEventListener('message', (data) => { console.log(data) if (data.data.type === 'skip-waiting') { sw.postMessage({ type: 'skip-waiting' }) sw.addEventListener('statechange', () => { if (sw.state === 'activated') { channel.postMessage({ type: 'activated' }) } }) } }) } },
// App.vue if (process.env.CLIENT) { // 不知道主线程要怎么监听 workbox 创建的 sw 线程,所以用 broadcast 代替 const channel = new BroadcastChannel('sw-messages') channel.addEventListener('message', (data) => { const type = data.data?.type if (type === 'sw-updated') { $q.dialog({ message: t('global.sw.updated'), persistent: true, cancel: true, }).onOk(() => { channel.postMessage({ type: 'skip-waiting' }) }) } else if (type === 'activated') { window.location.reload() } }) }
// custom-service-worker.ts(我项目里的文件名,用的 quasar 框架) self.addEventListener('message', (data) => { const type = data.data?.type if (type === 'skip-waiting') { self.skipWaiting() clientsClaim() } })
代码一下子变多了,但……是值得的。
现在用户就可以选择延迟更新了,体验一下子好了很多。