在页面线程中,虽然可以直接使用底层 API 来处理 Service Worker 的注册、更新与通信,但在较为复杂的应用场景下(比如,页面中不同窗口注册不同的 Service Worker),我们往往会因为要处理各种情况而逐步陷入复杂、混乱的深渊,并且,在出现运行结果与预期结果不一致时,我们往往不知所措、不知如何进行排查。

正是因为这些原因,Workbox 提供了运行在页面线程中的 workbox-window 模块,通过该模块,我们可以:

  • 更便捷、高效地处理 Service Worker 地注册、更新及通信。
  • 通过运行时完善的日志输出(比如 Service Worker 生命周期状态改变),可帮助我们快速定位运行错误;亦可通过日志的提示(比如注册 Service Worker 时,指定了错误的 scope),帮助我们避免犯一些常见错误。
  • 接下来,我们将一起学习 workbox-window 模块的使用。

# 基本使用

要使用 workbox-window,我们需要通过 npm 来安装相关依赖:

$ npm install --save workbox-window

或使用 yarn:

$ yarn add workbox-window

然后在代码文件中引入相关模块:

import { Workbox } from 'workbox-window/Workbox.mjs';

为了在开发环境中 workbox-window 能够输出日志,我们必须从 workbox-window/Workbox.mjs 中引入 Workbox 模块,并按照以下方式修改 webpack.config.js 文件:

const Terser = require('terser-webpack-plugin');
const { EnvironmentPlugin } = require('webpack');

module.exports = {
  //... 其他配置
  optimization: {
    minimizer: [
      new Terser({
        test: /\.m?js$/
      })
    ]
  },
  plugins: [
    //... 其他插件
    new EnvironmentPlugin({
      NODE_ENV: 'development'
    })
  ]
};

接下来,我们便可通过以下方式进行 Service Worker 的注册:

if ('serviceWorker' in navigator) {
  const workbox = new Workbox('/sw.js', { scope: '/' });
  workbox.register({ immediate: false });
}

示例中,我们首先声明了 Workbox 的实例对象 workbox,然后调用其实例方法 register 进行 Service Worker 的注册,其中:

Workbox 构造函数的参数与方法 navigator.serviceWorker.register 的参数一样,此处不再重述。

workbox.register 方法的参数为含有 immediate 属性的对象,该属性表示是否立即注册 Service Worker,而无需等待页面元素加载完成,默认值为 false。当该属性的值为 false 时,我们无需显式监听页面的 load 事件,因此以下代码:

window.addEventListener('load', () => {
  workbox.register();
});

可简化为:

workbox.register();

# 更新管理

我们可通过监听 ServiceWorker 的 statechange 事件、ServiceWorkerRegistrationupdatefound 事件以及 ServiceWorkerContainercontrollerchange事件来处理 Service Worker 的更新:

navigator.serviceWorker.register('/sw.js').then(registration => {
  if (registration.waiting) {
    //通知用户有更新,执行更新操作...
  }

  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed') {
        setTimeout(() => {
          if (newWorker.state === 'installed') {
            //通知用户有更新,执行更新操作...
          }
        }, 200);
      }
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  //通知用户更新已完成,执行页面刷新操作...
});

上例中,为了能够准确无误地处理更新,除了要求我们对 Service Worker 生命周期有深刻清晰的认识,也需要我们自行处理可能出现的任何状况,此过程繁琐且易于出错;基于此,workbox-window 在内部封装了这些细节,并通过一些简单明了的事件来帮助开发者便捷、高效地处理 Service Worker 更新问题。

在介绍 workbox-window 的生命周期事件之前,我们先对已注册 Service Worker 及外部 Service Worker 进行简单说明:

Service Worker 注册成功后,如果后续触发了 updatefound 事件(workbox-window 内部会主动监听该事件),新安装的 Service Worker 只有满足以下任何一个条件后,才会被当作外部 Service Worker,否则为已注册 Service Worker:

  • updatefound 事件被触发的次数大于一次。
  • updatefound 事件被触发与 workbox.register 被调用的时间差大于 1 分钟。
  • 新安装 Service Worker 脚本地址与注册的地址不一致。

了解了已注册 Service Worker 及外部 Service Worker,下面我们来一起看下 workbox-window 所提供的生命周期事件:

  • installed:新的 Service Worker 已安装,且新安装的 Service Worker 为已注册 Service Worker 时触发。
  • waiting:
    • 执行 workbox.register 方法时,如果 registration.waiting 的值不为空,触发该事件,且事件参数 event 的 wasWaitingBeforeRegister 属性值为 true
    • 新的 Service Worker 已安装,且 200 毫秒后(等待以确保 Service Worker 在 install 事件中没有调用 skipWaiting 方法)新安装的 Service Worker 状态依旧为 installed,并且新安装的 Service Worker 为已注册 Service Worker 时触发。
  • controlling:事件 controllerchange 被触发,且新激活的 Service Worker 为已注册 Service Worker 时触发。
  • activatedService Worker 已激活,且已激活的 Service Worker 为已注册 Service Worker 时触发。
  • externalinstalled:新的 Service Worker 已安装,且新安装的 Service Worker 为外部 Service Worker 时触发。
  • externalwaiting:新的 Service Worker 已安装,且 200 毫秒后(等待以确保 Service Worker 在 install 事件中没有调用 skipWaiting 方法)新安装的 Service Worker 状态依旧为 installed,并且新安装的 Service Worker 为外部 Service Worker 时触发。
  • externalactivated:新的 Service Worker 已激活,且新激活的 Service Worker 为外部 Service Worker 时触发。

了解了 workbox-window 的生命周期事件,我们便可以按照以下方式修改前文所述的 Service Worker 更新示例:

const workbox = new Workbox('/sw.js');
workbox.addEventListener('waiting', event => {
  //通知用户有更新,执行更新操作...
});
workbox.addEventListener('externalwaiting', event => {
  //通知用户有更新,执行更新操作...
});

workbox.addEventListener('activated', event => {
  if (event.isUpdate) {
    //通知用户更新已完成,执行页面刷新操作...
  }
});
workbox.addEventListener('externalactivated', event => {
  //通知用户更新已完成,执行页面刷新操作...
});
workbox.register();

示例中,我们需要注意以下两点:

由于在执行 workbox.register 方法时,如果 registration.waiting的值不为空,便会在当前调用栈为空时立即触发 waiting 事件,如要捕获此刻的 waiting 事件,应在 workbox.register 执行之前注册事件监听。

由于在首次注册 Service Worker 时亦会触发 activated 事件,因此需要通过 event.isUpdate 判断来避免首次注册时执行不必要的逻辑。

# 通信管理

我们可以调用 workbox.messageSW 方法向 Service Worker 发送消息,并以 Promise 返回值的形式得到 Service Worker 的响应,比如:

//sw.js
const SW_VERSION = '1.0.0';
self.addEventListener('message', event => {
  if (event.data.type === 'GET_VERSION') {
    event.ports[0].postMessage(SW_VERSION);
  }
});

//index.html
const swVersion = await workbox.messageSW({ type: 'GET_VERSION' });
console.log('Service Worker version:', swVersion);

示例中:

  • 首先,在 Service Worker 中监听 message 事件,并且当 event.data.type的值为 GET_VERSION 时,通过 event.ports[0] 发送响应给页面;
  • 然后,在页面中,我们通过调用 workbox.messageSW方法来发送类型为 GET_VERSION 的消息,并通过方法的返回值(类型为 Promise)获得 Service Worker 的响应。

messageSW 方法的参数可以是任意类型,但还是建议使用含有以下属性的对象:

  • type:Service Worker 需要根据该属性的值执行不同的业务逻辑,因此该属性的值需要全局唯一(类型为字符串,单词全部大写,且单词之间用下划线分割)。
  • meta:主要用于放置一些额外信息,且该信息不属于 payload 的一部分;在 Workbox 中,该属性的值为消息发送方所在模块的名称(比如:workbox-broadcast-cache-update),自定义消息时可不指定,或自行指定(类型为字符串)。
  • payload:需要发送的实际数据(任意类型)。

由于 messageSW 的实现基于 MessageChannel(详情参见 Workbox 详解篇:缓存更新广播中的相关讨论),因此在 Service Worker 端必须使用event.ports[0].postMessage` 来给页面发送响应,如用其他方式或不发送响应,那么 messageSW 的返回值将永远不会 resolve。

除了向 Service Worker 发送消息外,我们还可以通过监听 workbox 的 message 事件来接收 Service Worker 主动发送的消息:

workbox.addEventListener('message', event => {
  //doSomething...
});

由于 workbox 的 message 事件内部同时监听了通过 postMessage 和 BroadcastChannel(详情参见 Workbox 详解篇:缓存更新广播中的相关讨论)发送的消息,因此在 Service Worker 中如果使用 BroadcastChannel 发送消息,那么 BroadcastChannel 构造参数的值必须为 workbox。

# 总结

本章我们首先介绍了 workbox-window 模块的使用;然后介绍了如何通过所提供的生命周期事件来高效地处理 Service Worker 更新问题;最后我们讨论了如何使用 messageSW 给 Service Worker 发送消息、如何接收来自 Service Worker 的消息、页面与 Service Worker 相互通信时的注意事项。

通过本章的学习,相信大家已能轻松应对 Service Worker 注册、更新中所遇到的问题,下一章,我们将讨论 Workbox 最后一个主题:构建。

阅读全文