Service Worker 使用指南

5,081 阅读10分钟

service worker一般用的可能不多,但在很多时候却有着不可替代的作用,现在很多文章都只是对其简要介绍,很多细节部分都没有说明,所以就打算写这篇文章较为全面地聊一聊service worker能做什么,怎么做。

兼容性

就目前来说,service worker有着不错的兼容性,除了IE以外的大部分新浏览器都可以较好支持。

image.png

如何使用service worker

一个基本的service worker脚本

service worker脚本与普通js脚本的区别主要是因为他们的运行容器不同,在普通页面脚本中,有许多宿主对象可以使用,如与dom相关的window, document等,这些是service worker无法使用的,由于没有window对象,worker中的全局对象变成了self。其次,service worker被设计成完全异步的,所以需要尽量避免在其中使用需要长时间计算的同步逻辑。

service worker具有自己的生命周期状态,在不同的生命周期中可以做不同事情,主要有以下几个:

  1. install:准备好缓存等内容
  2. waiting:如果有一个旧版本的service worker在运行,那么会进入waiting状态,等待页面关闭后再打开才会更新service worker版本
  3. activate:表示service worker取得了当前页面的控制权,可以监听fetch和返回缓存等了

(在官方的表述中,有installing,installed,activating,activated,redundant几种,但由于它们没有相应的监听事件,所以不使用这种表述)

注:service worker 在不用时会被中止,并在下次有需要时重启

下面来写一个基本的service worker:

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    new Promise((resolve, reject) => {
      setTimeout(resolve, 1000);
    })
  );
});

self.addEventListener('activate', function(event) {
  console.log('activate');
});

install和activate是service worker中的两个生命周期钩子,这两个生命周期里面要做些什么主要取决于需要通过service worker实现的功能,这些会在下面的【service worker能做什么】部分介绍。

在两个事件中都可以使用event.waitUntil接受一个promise,来告诉浏览器目前还在install或者activate过程中,不要关闭worker,否则worker是有可能在任何时候被关闭的,如上面所示,就表示install过程会持续至少1s。

注册service worker

service worker一般通过navigator.serviceWorker.register()来注册,它的用例如下:

注:如果你不是在localhost上开发,那么需要https才能使用service worker

(async function regist() {
  try {
    let registration = await navigator.serviceWorker.register('service-worker.js', {scope: './'});
  } catch (e) {
    console.error(e);
  }
})()

register的第一个参数是脚本文件的路径,第二个参数中的scope(暂时只有这个属性)是这个脚本影响的作用域范围,如果像上面这样写,那么在当前路径/子路径下的页面都会受到影响。如果需要判断一个页面是否受service worker控制,可以检测navigator.serviceWorker.controller这个属性是否为null或者一个service worker实例。

用过web worker的话可能知道对web worker来说,脚本文件不一定是一个真实的文件路径地址,也可以是通过blob url来创建,就像下面这样:

let content = "console.log('worker is runningggg!')";
let url = URL.createObjectURL(new Blob([content], {type: "text/javascript"}));
let worker = new Worker(url);

但是service worker不能用这种方式来创建,否则会报错:

TypeError: Failed to register a ServiceWorker: The URL protocol of the script ('blob:xxxx') is not supported.

更新service worker

是否更新service worker是首先是由浏览器来决定的,只有当前后两个service worker文件在内容上不同的时候浏览器才会启动新的service worker的install事件。

有几种情况下,页面不会立即通过注册的service worker进行请求:

  1. 第一次注册service worker时,即使service worker已经activate,页面的请求也不会通过worker,只有在刷新页面后才会通过service worker代理请求
  2. 当页面存在旧版本的service worker,那么install过后会进入waiting状态,新的service worker会在重新打开页面后(注意不是刷新页面)生效
  3. 同一个scope中已打开的其它页面,在刷新之前不会受控于service worker

在第二种情况下,可以通过self.skipWaiting()来跳过waiting,这样的话每次更新service worker文件浏览器都会第一时间激活新的worker,无论有没有重新打开页面,self.skipWaiting()可以放在任何地方,不过一般会把它放在install事件里面:

self.addEventListener('install', function(event) {
  self.skipWaiting();
  event.waitUntil(
    // do something
  );
});

需要注意的是,以上只是激活了service worker,激活不等于能够使用,如果不刷新页面还是不能使用的,如果不想刷新页面(第一、三种情况),可以在service worker中使用clients.claim()替换这种默认行为,如果像下面这样写,那么service worker在activate之后立即生效:

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

在这些页面中没有显式调用register,如果需要在这些页面中知道新的service worker被激活,那么可以监听controllerchange事件

navigator.serviceWorker.addEventListener('controllerchange', e => console.log(e))

service worker工作流程

将注册和更新过程整合就可以得到完整的service worker工作流程,因为一直没有找到比较详细的service worker整体流程图,所以自己画了一个,有错误还请指出。

有一点需要注意,对于service worker脚本文件,它的http缓存规则与普通文件有所不同,如果设置了强缓存,并且max-age设置小于24小时,那么与普通http缓存无异,但是如果max-age大于24小时,那么service worker文件会在24小时之后强制更新。

service worker流程图.png

与页面间的通信

由于service worker挂载在navigator.serviceWorker.controller上,所以可以通过它与service worker进行通讯,方法是与web worker相同的postMessage,而在service worker中则是使用self.clients获取受控的页面:

navigator.serviceWorker.controller.postMessage('hello');
navigator.serviceWorker.addEventListener('message', function(e) {
  console.log(e.data);
})

// sw.js
self.addEventListener('message', e => {
  console.log(e);
  // 向特定窗口返回消息
  e.source.postMessage('response from service worker')
});

// 向全部窗口发送消息
(async function() {
  let cls = await self.clients.matchAll();
  cls.forEach(cl => cl.postMessage('message from service worker'));
})();

与其它地方的postMessage类似,也可以通过MessageChannel进行通讯:

const channel = new MessageChannel();
navigator.serviceWorker.controller.postMessage('hello', [channel.port1]);
channel.port2.onmessge = function(e) { console.log(e.data); }

// sw.js
self.addEventListener('message', e => {
  e.ports[0].postMessage('message from service worker');
});

service worker能做什么:缓存

service worker强大的缓存功能主要来自于它能够拦截全局的fetch事件,以及能在后台运行的能力等。

既然能够拦截fetch事件,那很容易想到的就是可以把关于浏览器请求的处理都放在一处,也就很大程度上方便了缓存管理,并且由于service worker可以离线使用,使得它成为了PWA的基础。

下面介绍一下如何使用service worker管理缓存

管理缓存

service worker的缓存能力主要与self.caches对象有关,这是一个CacheStorage对象,在普通页面中也可以使用,但是一般用在service worker中,同样的,CacheStorage只能用在https环境中。

如果预先知道需要缓存什么内容,可以使用caches.addAll()来预先缓存内容,如果像下面这样写,那么会在service worker安装的时候缓存test.jpg这个文件:

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('v1').then(function(cache) {
      return cache.addAll([
        '/test.jpg',
      ]);
    })
  );
});

然后便可以通过拦截fetch事件来使用缓存了:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

在上面这种情况下,test.jpg会从缓存中返回,而其它文件则会从网络下载,但在一些情况下我们不能预知需要缓存的内容,那么就可以在第一次请求之后进行缓存,与caches.addAll()不同,caches.put()可以手动将内容放入缓存中,需要注意的是,由于fetch所获得的response内容的读取是一次性的,而实际上我们把response返回给了页面,还把它放入了缓存中,所以要使用response.clone()创建两个相同的response:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(resp) {
      return resp || fetch(event.request).then(function(response) {
        return caches.open('v1').then(function(cache) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

缓存的更新

由于caches是可以分版本的,所以在更新了新的缓存列表后可以将旧的删除,这一是为了避免缓存混乱,二是为了减少缓存空间占用,因为每个浏览器都有自己的磁盘空间限制,在容量超出的时候,为了内容的一致性,浏览器一般会删除域下面的所有数据。

const currentCacheVersion = 'v2';

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (keyList) {
      return Promise.all(keyList.map(function (key) {
        if (key !== currentCacheVersion) {
          return caches.delete(key);
        }
      }));
    })
  );
});

手动构建Response

上面所示的response都是通过fetch获得的,实际上也可以手动构建Response,这给开发者带来了很大的灵活性,例如在返回缓存时修改header信息等,这里不详细介绍,可以参考MDN

service worker能做什么:后台同步

这个功能移动端可能应用场景会更多,并且对大部分应用场景来说这或许是一个锦上添花的功能,兼容性也不是很好:

image.png

可以看到只有chromium系的浏览器支持后台同步

后台同步(Background Sync)能做什么

后台同步主要为了将网络请求与页面分离,在用户关闭页面后也可以继续请求,比方说一个请求了很长列表的页面,当列表还未加载完的时候用户就关闭了页面,那么下次打开的时候又需要重新加载,这时就可以使用后台同步。

后台同步分为几个部分

  1. service worker中监听sync事件
  2. 页面向service worker发送sync请求
  3. 在service worker中触发sync事件回调函数,然后进行请求

下面是一个例子:

navigator.serviceWorker.register('/sw.js');

navigator.serviceWorker.ready.then(function(swRegistration) {
  return swRegistration.sync.register('sync-user-list');
});

// sw.js
self.addEventListener('sync', function(event) {
  if (event.tag == 'sync-user-list') {
    event.waitUntil(fetch('./sync?type=userlist'));
  }
});

注意要将返回Promise放入event.waitUntil中,好让浏览器知道什么时候请求完成,从而保证在请求完成前不会关闭service worker。

同步的数据如何使用

同步后的数据可以通过多种途径来使用,例如:

  1. 通过postMessage发送回页面
  2. 通过Notification给用户发送通知
  3. 通过一些变量/indexDB存储同步回来的结果(service worker无法使用localStorage等同步存储方式)

以第一种为例:

navigator.serviceWorker.register('/sw.js');

navigator.serviceWorker.ready.then(function(swRegistration) {
  return swRegistration.sync.register('sync-user-list');
});

navigator.serviceWorker.addEventListener('message', e => {
  const content = e.data;
})

// sw.js
self.addEventListener('sync', function(event) {
  if (event.tag == 'sync-user-list') {
    event.waitUntil(fetch('./sync?type=userlist').then(async res => {
      const content = await res.text();
      return clients.matchAll().then(cls => cls[0].postMessage(content));
    }));
  }
});

以第二种为例:

navigator.serviceWorker.register('/sw.js');

navigator.serviceWorker.ready.then(function(swRegistration) {
  return swRegistration.sync.register('sync-user-list');
});

// sw.js
self.addEventListener('sync', function(event) {
  if (event.tag == 'sync-user-list') {
    event.waitUntil(fetch('./sync?type=userlist').then(async res => {
      const content = await res.text();
      return self.registration.showNotification(content);
    }));
  }
});

service worker能做什么:Web Push

web push是一种浏览器后台推送通知的方法,它可以实现在关闭页面后继续推送通知的功能原理是通过浏览器的推送服务器向用户推送消息,看看它的兼容性怎么样:

image.png

这里特意把一些国内的手机浏览器也放了进来,可以看到,safari是不支持的,但是几个国内的移动端浏览器都支持,需要注意的是,如果使用的是Chrome,那么推送服务使用的是Google的FCM,这个服务国内应该是无法使用的。

这一部分不详细介绍了,在Google Developers上有一篇比较详细的介绍,如果有空的话我可能也会写一篇相关的文章。

DevTools

由于service worker的特性,相比于页面脚本它不是那么好开发调试,下面介绍两个开发的小方法。

调试

如果使用chrome,可以在chrome://inspect/#service-workers 中点击相应service worker的inspect,即可调试

image.png

service worker自动更新

上面说到,在service worker文件更新了之后要关闭页面重新打开才能生效,为了避免这个麻烦,可以在开发代码中加入skipWaitinig()clients.claim(),或者在DevTools的Applcation标签页选上Update on reload,这样每次刷新页面就会自动激活新的service worker。

image.png

工程化

由于目前的前端工程几乎都用打包工具进行构建,而service worker一般都要作为一个单独的文件来引入,所以可以在构建工具中做一些配置:

例如使用webpack构建:

...
entry: {
    'app': "./src/index.js",
    'service-worker': "./src/service-worker.ts",
}
...

另外也有一些工具与service worker相关:

offline-plugin

serviceworker-webpack-plugin 已Archived

由于这些工具我没怎么用过,所以就不介绍了。