当我们使用各种技巧以充分爆发 Service Worker 的小宇宙时,往往因忽略其自身的更新问题,从而造成各种意想不到的故障。基于此,本章我们将目光重新回到 Service Worker 上,来聊一聊它的更新处理。

# Service Worker 脚本命名

现代前端构建体系中,输出的静态资源(脚本、图片、样式等)都以 [name].[hash].[ext] 的格式命名(比如:index.54a427d9cf.js),这是因为此类文件内容变更的频率非常低,我们可以对其使用强制缓存来避免不必要的网络请求,并且在文件变更之后,能够在页面刷新时得到更新。那么这种成为业界标准的命名是否适用于 Service Worker 脚本呢?我们通过一个例子进行说明:

  • 假设我们在 index.html 中注册了某版本的 Service Worker(文件名为:sw.v1.js),并通过预缓存将 index.html 添加到缓存中。
  • Service Worker 更新后,index.html需要注册新版本的 Service Worker(文件名为:sw.v2.js),
  • 如果我们使用缓存优先或仅缓存策略来响应 index.html请求,除非用户手动清除缓存,否则从缓存中得到的 index.html中注册的 Service Worker 依旧为 sw.v1.jssw.v2.js 将永久不会生效。

正是由于以上所述缘由,当我们处理 Service Worker 脚本时,一定要保证不同版本的文件名保持一致。

# Service Worker 脚本缓存

如果已存在一个版本的 Service Worker,那么再次触发其安装的条件是从服务器获取的 sw.js 与本地版本存在差异。如果我们对 sw.js 进行缓存,除非用户手动清除缓存或缓存失效,否则新版本的 Service Worker 将永远无法安装。因此,不要对 Service Worker 脚本设置缓存(服务端可通过 max-age: 0 响应头来避免缓存)

# 慎用 skipWaiting

Service Worker 安装成功后,如果已存在一个版本的 Service Worker 且有页面尚未关闭,新版 Service Worker 便会进入等待状态,直到运行旧版本的页面全部关闭或在 install 事件中调用 skipWaiting 方法,新的版本才会进入激活状态。虽然我们可以通过调用 skipWaiting 方法让新版本尽快接管页面控制权,但这种过早的权利交接很可能造成一些意想不到的问题,比如:

假设 Service Worker 对请求 /users/tom 的响应进行了转换,在旧版本中返回的格式为:

{
  name: 'Tom',
  city: 'NanJing'
}

新版本中返回的格式为:

{
  v2Name: 'Tom',
  v2City: 'NanJing'
}

这样的不一致可能在某些情况下导致应用崩溃且难以复现,这就等于在应用中埋藏了一个不稳定的定时炸弹,所以,除非能够保证同一个页面在两个版本相继处理的情况下依旧能够正常工作,否则尽量避免使用 skipWaiting 方法

# 处理更新的正确姿势

上文说到滥用 skipWaiting 可能会带来意想不到的问题,那如果我们想要新版本的 Service Worker 尽快接收控制权,又该如何处理呢?我的建议是将控制权交给用户,比如:

上图中,当新版本的 Service Worker安装成功后,给予用户提示,并让用户自行决定是否进行更新,如果用户确定更新,我们便激活新版本,并在激活成功后给予提示并刷新页面(如下图)。

更新提示显示时机代码实现:

export async function initSW() {
  if ('serviceWorker' in navigator) {
    const registration = await navigator.serviceWorker.register('/sw.js');
    //... 其他逻辑
    if (registration.waiting) {
      showSwUpdateTip(registration);
    }
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed') {
          setTimeout(() => {
            if (newWorker.state === 'installed') {
              showSwUpdateTip(registration);
            }
          }, 200);
        }
      });
    });
    return registration;
  }
}
  • Service Worker 注册成功后,如果当前 Service Worker 处于等待状态(通过 registration.waiting 是否为非空值判断),则显示更新提示。
  • 然后通过监听 registrationupdatefound 事件(将在 registration.installing 值变化时触发),并在其回调中监听 registration.installingstatechange 事件。
  • statechange 事件中,如果 registration.installing 的状态变为 installed,且在 200 毫秒(等待以确保 Service Workerinstall 事件中没有调用 skipWaiting 方法)后,registration.installing 的状态仍然为 installed 时,显示更新提示。

新版本激活代码实现:

function showSwUpdateTip(registration) {
  toast(
    'info',
    '页面已更新,请点击此处进行更新',
    {
      timeOut: 0,
      onHidden: () => {
        registration.waiting.postMessage('skipWaiting');
      }
    }
  );
}

//sw.js
self.addEventListener('message', event => {
  if (event.data === 'skipWaiting') {
    self.skipWaiting();
  }
});
  • showSwUpdateTip方法中,在提示信息被点击的回调中调用 registration.waiting.postMessage 方法发送消息给 Service Worker 线程,以通知其激活新版本。
  • sw.js 文件中,通过监听 message 事件,并在 event.dataskipWaiting 时,通过调用 skipWaiting 方法来激活新版本。

最后要做的便是通知主线程进行页面刷新:

export async function initSW() {
  if ('serviceWorker' in navigator) {
    //... 其他逻辑
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      toast('success', '页面更新完毕,即将刷新页面', {
        onHidden: () => {
          window.location.reload();
        }
      });
    });
    //... 其他逻辑
  }
}
  • 首先,监听 navigator.serviceWorkercontrollerchange 事件(将在新的 Service Worker 获得控制权后触发)。
  • 然后,在回调中给予更新成功的提示,并在提示消失后刷新当前页面。

# 总结

  • 本章我们首先对 Service Worker 脚本文件名须保持一致且不能对其进行缓存的原因进行了讨论,然后对 skipWaiting 方法滥用可能导致的问题进行了说明,最后通过实例讨论了如何有效处理 Service Worker 的更新。至此我们已完成了 PWA 实战中所涉及到的常见主题,相信我们已经掌握了:
  • 如何通过自定义 Webpack Plugin 实现动态预缓存列表的生成。
  • 如何利用应用 Shell、导航预加载来解决恶劣网络环境下可能出现的空白页问题。
  • 如何利用请求策略、缓存置换策略来实现缓存的高效利用。
  • 如何避免常见的 Service Worker 更新问题。

关于 应用安装、推送通知、后台同步,因已在第一部分中进行了详细说明,故本部分不再重述,具体应用可参见示例代码:

阅读全文