背景
根据w3c draft对Service Worker(以下简称SW)的定位。SW是属于PWA全家桶的其中一项技术。传统Web加载document以及子资源是浏览器内部策略,开发者无法直接控制网络资源的加载,而Web Application标准是建立在网络资源可控的前提下,所以SW被提出用于平衡Web和Web Application之间的gap,SW负责提供在线或离线的资源给document,替换掉过时的Application Cache。
随着SW生命周期以及独立Script Context的完善,使得SW有能力承担Web后台Service的角色,例如Push通知、后台数据同步、网络资源管理、计算密集型任务等。某大厂小程序也使用过SW作为Service端载体。
IOS系统在2018年部分支持SW,但是在卸载策略、Push/Background Sync API标准与W3C标准不一样,跨平台开发需谨慎。另外因为SW有能力拦截网络请求,所以为了安全性,网站或Web Application需要支持HTTPS协议。
应用
Service Worker基本用法:
先上个简单Demo,代码https://github.com/mdn/sw-test/。Http Server可以用github内的python工具,也可以直接在Chrome中安装Web Server扩展,链接如下。
https://chrome.google.com/webstore/detail/web-server-for-chrome/ofhbbkphhbklhfoeikjpcbhemlocgigb?hl=en
service worker基本步骤:
- 页面index.html通过
serviceWorkerContainer.register(scriptUrl, scope)
注册service worker 。scriptUrl是SW js的url,scope是SW脚本执行的作用域,例如对于域名https://serviceworke.rs/ 创建两个iframe分别是controlled.html以及non-controlled.html,register(sw.js, {scope: './controlled'})使sw.js只能控制controlled.html的网络资源,而无法控制non-controlled.html。 - 如果注册成功,sw.js 就在
ServiceWorkerGlobalScope
环境中运行; 这是一个特殊类型的 woker 上下文运行环境,与主运行线程(register执行环境,也就是index.html)相独立,同时也没有访问 DOM 的能力。 - 注册成功后sw.js会触发service worker的install事件,此时可以使用IndexDB和Cache API预先缓存资源。
- 当
oninstall
事件的处理程序执行完毕后,可以认为 service worker 安装完成了。 - 安装完成后,会接收到一个激活事件(activate event)。
onactivate
主要用途是清理先前版本的service worker 脚本中使用的过期资源,防止本地存储爆炸。 - Service Worker 现在可以控制页面了,但仅是在
register()
成功后打开的页面。也就是说,如果页面在service worker activate之前加载网络资源,则这些网络资源不会被service worker控制。所以,页面需要重新加载让 service worker 获得完全的控制。 - Service worker脚本通过监听fetch, push, sync API事件实现对页面的控制。
sw.js脚本更新方法:
Service Worker规范提供了skipWaiting以及update两种方式可以让开发者更新SW。
使用skipWaiting立即更新。注意:旧的sw.js业务逻辑会被立即杀掉,需要处理交接时的问题。具体见https://zhuanlan.zhihu.com/p/51118741
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
// caching etc
);
});
使用update通知内核更新,具体时机由内核决定,例如navigations and functional events之后触发更新。
navigator.serviceWorker.register('/sw.js').then(reg => {
// sometime later…
reg.update();
});
Service worker生命周期的各个阶段以及可监听的事件见参考资料。
参考资料:https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API/Using_Service_Workers
网络资源控制:
Demo代码以及说明:
google offline cookbook
基本步骤:
- sw.js监听网络请求fetch事件。
- 在fetch回调中使用caches.open("sw_cache")打开一个命名的cache。
- 使用fetch API(区别于fetch事件)向网络请求资源,或者cache.get从缓存中取资源,用return将获取到的资源返回给内核。
控制模式:
通过fetch、cache以及Promiss搭配,可以对网络资源实现以下几种控制模式。
- 网络或缓存加载。
self.addEventListener('fetch', function(event) {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
self.addEventListener('fetch', function(event) {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behaviour
});
- 先网络后取缓存,vice versa。
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(function() {
return caches.match(event.request);
})
);
});
- 同时网络以及取缓存,快者优先。
// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all promises
promises = promises.map(p => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach(p => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b))
.catch(() => reject(Error("All failed")));
});
};
self.addEventListener('fetch', function(event) {
event.respondWith(
promiseAny([
caches.match(event.request),
fetch(event.request)
])
);
});
- 先取缓存再网络更新。
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.open('mysite-dynamic').then(function(cache) {
return cache.match(event.request).then(function(response) {
var fetchPromise = fetch(event.request).then(function(networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
return response || fetchPromise;
})
})
);
});
- 在Android 7.0及以上可以通过终端Java ServiceWorkerController
设置所有资源的网络控制方式,包括LOAD_DEFAULT、LOAD_CACHE_ELSE_NETWORK、LOAD_CACHE_ELSE_NETWORK、LOAD_NO_CACHE以及LOAD_CACHE_ONLY。
参考资料:
google offline cookbook 以上例子出处
MDN Service Worker cookbook 不仅包括网络资源的demo,还包括Push通知、offline应用等。
MDN Service Worker Guide SW基本使用
跨Scope Context通信:
如同前后端通信。当用户在网页或Web App操作DOM时,想触发SW执行网络资源拉取或计算任务,就要通过postMessage接口将消息跨Context传递。
Demo代码:https://github.com/googlechrome/samples/tree/gh-pages/service-worker/post-message
通信方式:
单向通信。
index.html -> sw.js
- sw.js监听message事件
self.addEventListener("message", function(event) {
console.log(event.data.command);
});
- 在MainScope中获取SW对象。
#方式一: SW install成功后取navigator.serviceWorker.controller
# 实际对应ServiceWorkerRegistration.active
var serviceWorker = navigator.serviceWorker.controller
#方式二: register成功后在返回的ServiceWorkerRegistration中取SW对象
#注:ServiceWorkerRegistration中不同状态对应不同的SW对象
navigator.serviceWorker.register('sw.js').then(function(registration) {
var serviceWorker = registration.installing;
if (!serviceWorker)
serviceWorker = registration.waiting;
else if (!serviceWorker)
serviceWorker = registration.active;
});
- 调用postMessage接口,即可在index.html中发送消息到sw.js。
serviceWorker.postMessage({command:'hello'})
API资料:
https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/controller
sw.js -> index.html
- 通过SW控制的页面的代理对象,例如index.html,调用postMessage发送消息。
clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({command:'Ack'});
})
})
API资料:
https://developer.mozilla.org/en-US/docs/Web/API/Client
双向通信。
建立MessageChannel,同样通过postMessage传递Channel连接。
index.html
if (navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = function(event) {
console.log(`Response from the SW : ${event.data.command}`);
}
navigator.serviceWorker.controller.postMessage({ command: "connect"}, [messageChannel.port2]);
}
API资料:https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel
sw.js
self.addEventListener("message", function(event) {
const data = event.data;
if (data.command === "connect") {
event.ports[0].postMessage({ command: "accept" });
}
});
广播通信
建立同一命名的BroadcastChannel,sw.js与client在BroadcastChannel添加message监听以及postMessage。
// From sw.js:
const channel = new BroadcastChannel('sw-messages');
channel.postMessage({title: 'Hello from SW'});
// From index.html:
const channel = new BroadcastChannel('sw-messages');
channel.addEventListener('message', event => {
console.log('Received', event.data);
});
API资料:https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
存在的问题
问题:StopWorker 会触发MessageChannel 关闭,而且 service worker 再次重启之后也无法重建原来的 Messagechannel。这就意味着,在 service worker stop之后,整个双向通信的通道就不能使用了。按照 service worker 规范的说明,浏览器可以在任意需要的时候关闭和重启 service worker,这也等同于 service worker 与其控制页面建立的 MessageChannel 随时会断掉,而且无法重建。
解决方法:
- 每次发送消息都新建 MessageChannel。Google 官方的 DEMO 就是使用了这种方式。它将postMessage包装成 sendMessage 方法,该方法每次调用都会创建新的 MessageChannel。缺点是每次消息通信都需要新建 MessageChannel 实例,这样它与单向通信相比,优势就不明显了。
- 页面监听SW生命周期,在事件回调中维护MessageChannel。每当ServiceWorkerRegistration.installing安装了新的SW之后,会触发ServiceWorkerRegistration.onupdatefound回调。在回调中建立与新的SW的连接,并且监听新的ServiceWorker.onstatechange事件,当SW状态转为redundant时标记连接不可用。优点是Channel管理逻辑与postMessage逻辑隔离,缺点是需要额外实现Channel的管理逻辑。
API资料:https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/state
http://craig-russell.co.uk/2016/01/29/service-worker-messaging.html#.XPSPJfkzaUl
https://github.com/GoogleChrome/samples/tree/gh-pages/service-worker/post-message
Worker可能随时罢工
Service worker 规范中提到:“Service workers may be started by user agents without an attached document and may be killed by the user agent at nearly any time”,即 Service Worker 线程可能在任意时间被浏览器停止,即使关联的文档还未关闭 service worker 线程也有可能已被停止。这种设计主要是为了降低 Service Worker 对资源(比如浏览器内存、手机电量等)的消耗。所以,Service Worker 线程一般在什么情况下会被停止?
- Service worker JS 有任何异常,都会导致 service worker 线程退出。
包括但不限于 JS 文件存在语法错误、service worker 安装失败或激活失败、service worker JS 执行时出现未被捕获的异常。 - Service worker 功能事件处理完成,处于空闲状态,service worker 线程会自动退出。
- Service worker JS 执行时间过长,service worker 线程会自动退出。比如 service worker JS 执行时间超过 30 秒,或 Fetch 请求在 5 分钟内还未完成。
- 浏览器会周期性检查各个 service worker 线程是否可以退出, 一般在启动 service worker 线程时会检查一次。
- 为了方便开发者调试, Chromium 进行了特殊处理, 在连上 devtools 之后,service worker 线程不会退出。Keep a serviceworker alive when devtools is attached - chromium - Monorail