随着时代的发展,用户对应用体验的要求愈加苛刻,我们虽然可以通过各种优化手段来减少页面加载时间,但当用户处于移动状态,潜在的网络切换很可能导致短暂的离线,如果用户在此时进行事务处理,那么此刻应用的不可用很可能导致用户流失。也许正是由于 Web 应用在离线处理上的弱势,才最终导致其地位在移动时代不如原生应用这一局面。

那又该如何破局,将本机原生应用所具有的离线处理能力植入到 Web 应用中去呢?接下来我们要讨论的 Service Worker 便可解决这一难题。

# Service Worker 与 Web Worker

  • 首次看到 Service Worker,我想大家可能会跟我一样都有这东西跟 Web Worker 有什么联系之类的疑问,带着这个疑问让我们来梳理下两者的差异。
  • Web Worker 是现代浏览器提供的一个 JavaScript 多线程解决方案,我们可以将一些复杂、耗时的运算交给 Web Worker 执行以达到释放主线程的目的;Service Worker 则是建立在 Web Worker 之上,旨在通过请求代理、本地缓存、后台同步等机制来提供离线处理能力。两者的主要异同点如下:

相同点

  • 都独立于主线程,以单独线程的形式运行。
  • 都不能直接访问并操作 DOMwindow 对象。
  • 都是通过 postMessage 接口与主线程进行交互。

不同点

  • Service Worker 内部大部分为基于 Promise 的异步操作。
  • Service Worker 必须运行在 HTTPS 环境下以避免中间人攻击。
  • Service Worker 的生命周期完全独立于网页,且可在不用时被中止、在下次有需要时重启。

# 生命周期

上一节我们对 Service WorkerWeb Worker 进行了简单对比,下面我们将深入了解 Service Worker 的生命周期。

# 注册

要使用 Service Worker,需通过以下方式在页面中对其进行注册:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('./sw.js', { scope: './' }).then(function(registration) {
      // do domething...
    }).catch(function(err) {
      // do domething...
    });
  });
}

示例首先检测 Service Worker 是否可用,如果可用,则在页面加载完后注册位于 ./sw.jsService Worker。代码非常简单,但需注意以下两点:

  • 注册成功仅仅表明指定脚本已成功解析,并不意味着 Service Worker 已经安装或处于激活状态。
  • register 方法中的 scope 参数指定了 Service Worker 可接收 fetch 事件的作用域,比如 scope 的值为 /mobile,那么 Service Worker 便只能接收 path 以 /mobile 开头的 fetch 事件,默认值为 sw.js 所在路径。

# 安装

注册完成后,浏览器便会立即尝试安装并进入安装状态,此时将触发 Service Workerinstall 事件,在该事件中我们经常对静态资源进行缓存处理,比如:

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('sw-cache').then(function(cache) {
      return cache.addAll([
        '/',
        '/index.html',
        '/main.css',
        '/main.js',
        '/image.jpg'
      ]);
    })
  );
});

示例中通过 Cache API 对静态资源进行了缓存处理,其中方法 event.waitUntil 的参数是一个 Promise 对象,并且:

  • 等待直到参数为 resolve 状态时,Service Worker 才会进入下一个生命周期。
  • 如果最终参数为 reject 状态,Service Worker 安装失败,我们无需为此做特殊的处理,因为在下次进行注册时,会重新进行安装尝试。

需要注意的是,并不是每次注册成功后都会进入安装状态并触发 Service Worker 的 install 事件,其需要满足以下两个条件中的任意一个:

  • 页面中尚未安装 Service Worker
  • Service Worker 已安装,并且从服务器获取的 sw.js 文件与本地版本存在差异

# 等待

安装成功后,如果已经存在一个版本的 Service Worker 且有页面正在使用该版本,新版 Service Worker 便会进入等待状态,当 Service Worker 处于该阶段时,由于它必须等正在运行旧版本 Service Worker 的页面全部关闭后才会获得控制权,因此如果我们需要所有页面能够及时得到更新,可在 install 中通过 self.skipWaiting 来强制跳过该阶段:

self.addEventListener('install', function(event) {
  self.skipWaiting();
  //……
});

# 激活

当满足以下任一条件,Service Worker 便可进入该阶段:

  • self.skipWaiting 方法被调用。
  • 安装完成后,不存在旧版本的 Service Worker 或无页面使用此版本。
  • 等待状态下正在运行旧版本 Service Worker 的页面被全部关闭(页面刷新或切换无法使 Service Worker 从等待进入激活状态,这是由于当页面刷新或切换时,浏览器需要等到新页面渲染完成之后才会销毁旧页面,即新旧两个页面存在共同的交叉时间)。
  • 进入该状态后,activate 事件将会被触发,我们常通过订阅该事件对缓存进行更新或删除,比如
self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return cacheName != 'sw-cache';
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

当 Service Worker 被首次注册时,已打开的页面只有在刷新后才会接受 Service Worker 的控制,如果想要 Service Worker 在激活后尽快掌握这些页面的控制权,可在 activate 中调用 self.clients.claim 方法来实现:

self.addEventListener('activate', function(event) {
  self.clients.claim()
  //……
});

# 已激活

到了这一阶段,便可通过监听 fetchpushsyncmessage 等事件来为应用提供丰富的离线处理能力。

# 注销

用户可通过点击调试面板中的 unregister(如下图)来注销 Service Worker,但有些时候我们可能需要通过编程的方式来进行注销,其实现代码如下:

const serviceWorker = navigator.serviceWorker;
if (typeof serviceWorker.getRegistrations === 'function') {
  serviceWorker.getRegistrations().then(function(registrations) {
    registrations.forEach(function(registration) {
      registration.unregister();
    });
  });
} else if (typeof serviceWorker.getRegistration === 'function') {
  serviceWorker.getRegistration().then(function(registration) {
    registration.unregister();
  })
}

需要注意的是,无论通过何种方式注销,本地缓存都不会自动清除,需手动调用 Cache API、IndexedDB API 等其他离线存储 API 进行清理操作。

# 废弃

该阶段表示一个 Service Worker 的生命周期已结束;进入该阶段的条件可为以下任意一个:

  • 安装失败。
  • 激活失败。
  • 用户执行了注销操作。
  • 新版本的 Service Worker 替换了它并成为激活状态。

# 状态监听

注册成功后,我们可通过回调中的 registration 参数来获取以下状态的 ServiceWorker 实例:

  • 安装:通过 registration.installing 获取,如属性值为非空,则表示 Service Worker 正处于安装状态。
  • 等待:通过 registration.waiting 获取,如果属性值为非空,则表示 Service Worker 正处于等待状态。
  • 激活:通过 registration.active获取,如果属性值为非空,则表示 Service Worker 已被激活。

需要注意的是,在 Service Worker 新旧版本切换的时候,会同时存在安装(等待)及激活状态实例,这是因为新的 Service Worker 还没有完全取得所有页面的控制权。

通过以上方式得到 ServiceWorker 实例后,我们可通过监听该实例的 statechange 事件来获得其最新状态,比如:

navigator.serviceWorker.register('./sw.js').then(function(registration) {
  const newWorker = registration.installing;
  newWorker.addEventListener('statechange', function() {
    console.log(newWorker.state);
  });
});

同时也可通过 registrationupdatefound事件来监听 Service Worker的更新,该事件将在 registration.installing 的值发生变化时触发:

navigator.serviceWorker.register('./sw.js').then(function(registration) {
  registration.addEventListener('updatefound', function() {
  });
});

如果我们想要在新的 Service Worker 取得页面控制权后执行一些逻辑(比如给予用户提示),可通过订阅 navigator.serviceWorkercontrollerchange 事件来实现:

navigator.serviceWorker.addEventListener('controllerchange', function() {
});

# 事件

上一节我们对 Service Worker 的生命周期进行了详细说明,下面我们了解下 Service Worker 所支持的常用事件:

  • install:安装事件,一般对静态资源文件进行缓存处理。
  • activate:激活事件,一般用于更新或删除旧版本的缓存。
  • fetch:接收 Service Worker 作用域下的 fetch 事件,在该事件中可以做各种缓存代理的事情。
  • sync:后台同步事件,由 BackgroundSync API 发出。
  • message:由于 Service Worker 以独立线程运行,通过该事件可以实现与主进程的交互。
  • push:响应来自系统的推送消息。
  • notificationclick:推送通知点击事件,一般用来处理通知与用户的交互。

# 总结

本章我们通过与 Web Worker 的对比、生命周期以及常用事件三个方面对 Service Worker 进行了系统学习,这些机制为 Web 应用的离线处理、系统交互提供了可能,然而仅仅拥有机制还远远不够,它还需要其他技术的配合才能真正发挥其威力,因此在接下来的几个章节中,我们将对为其提供底层服务的技术一一进行讲解。

阅读全文