service-worker 更新策略

支持 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 了,流程需要改成下面这样:

  1. updated 通知页面。
  2. 页面弹窗。
  3. 用户不同意更新,不需要操作。
  4. 用户同意更新。
    1. 页面通知新的 SW 切换控制权(因为直接刷新页面,浏览器是不会主动切换到新版 SW 的)。
    2. 新的 SW 接管页面。
    3. 监听到新 SW 变为激活状态,通知页面刷新。
    4. 页面直接刷新。

看看,一下子就复杂很多了。之前只需要一次通信,一个弹窗(甚至可以没有弹窗),现在不仅需要四次通信,还需要监听状态变更,以及延迟 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()
  }
})

代码一下子变多了,但……是值得的。

现在用户就可以选择延迟更新了,体验一下子好了很多。