Service Workerとは
ブラウザが Web ページとは別にバックグラウンドで実行するスクリプト
オフラインのアプリを実現・サポートするために作られたものです
ちなみに、ブラウザの対応状況はこんな感じ
http://caniuse.com/#search=service%20workers
特徴
- DOM にアクセスできない
- DOM を操作したい場合は、Service Worker がコントロールしているページ(js)と postMessage でメッセージのやり取りをして行う
- リクエストをプロキシすることが可能
- Service Worker はブラウザが必要に応じて起動・終了するので、変数の値を保持しておけない
- Cache、IndexedDB 等で値を保存して、必要になった時に取り出すようにする
- Promise を多用する
- https か localhost 上でしか動作しない
ライフサイクル
Web ページとは全く異なるライフサイクルで動作する
赤文字はその時に発火するイベント
登録
Service Worker を使うにはまず register()
関数を呼びだして登録する
すでに登録されているかどうかはブラウザがチェックしてくれるので気にせず呼べばいい
navigator.serviceWorker.register('/service-worker.js');
登録時に重要なのがスコープ
スコープとは Service Worker がコントロールするページのこと
スコープは、service-worker.js が存在する階層が自動的に設定される
また、下記のように明示的に設定することも可能
navigator.serviceWorker.register('/service-worker.js', {scope: '/example'});
上記の場合、/example 配下のページが Service Worker にコントロールされる
つまり、「 http://www.example.com/example/page.html 」はコントロールされるが
「 http://www.example.com/index.html 」はコントロールされないということ
また、register()
で登録し service-worker.js が更新されている場合はonupdatefound
イベントが発火する(初回登録時も)
インストール
Service Worker を新規インストール、もしくは、Service Worker が更新されていると installing 状態になる
この時、oninstall イベントも発火する
インストール時に何か処理させたい場合は、下記のように Service Worker 内で oninstall イベントを監視する
self.addEventListener('install', (event) => {
// 何かの処理
});
更新
ブラウザが保持している Service Worker と、これからダウンロードしようとしている Service Worker に
1byteでも違いがあれば更新されたと判断される
ブラウザをコントロールしている Service Worker が存在しなければ installing 状態のあと
すぐに、active 状態になるが、コントロールしている Service Worker が存在する場合は
waiting 状態に移行する
なぜすぐに active 状態にしないかというと、古い Service Worker が保持しているデータを
そのまま新しい Service Worker が使おうとした場合、不整合が起きる可能性があるからです
その後、安全な状態(ページが閉じられるなど)になると waiting 状態から active 状態に移行します
Tips
更新時に installing 状態からすぐに active 状態に移行させたい
Service Worker 更新時はデータの不整合等を防ぐために waiting 状態に移行し
安全な状態になるのを待つようになっている
すぐに active 状態にしても問題ない場合は、skipWaiting()
を呼ぶことで active 状態にすることができる
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
waitUntil()
はこの関数が呼ばれたイベント終了のライフタイムをその処理が終わるまで待つ
(この関数はよく使うので覚えておくといいです)
active 状態になったらすぐにコントロールさせたい
Service Worker は active 状態になってもすぐにブラウザをコントロールしない
コントロールするのは次にページが表示された時
claim()
を呼ぶことすぐにコントロールさせることができる
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
ここまで、Service Worker の基本を説明してきました
ここからは Service Worker を使ってできることを説明していきます
サンプルコードはここにあるので見てください
動作は、Google Chrome 52.0.2743.116 m (64-bit)で確認済みです
Cache編
Cache API を使用することでオフライン状態でもリソースを取得できるようにします
動作サンプルはこちら
一度オンライン状態でページを取得し、その後オフライン状態で再読み込みしてみてください
オフライン状態でもリソースが取得できることが確認できるはずです
ページ
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Cache Test</title>
<link rel="stylesheet" href="./styles/main.css">
</head>
<body>
<h1>Service Worker Cache Test</h1>
<img src="./images/image.jpg">
<script src="./script/jquery-3.1.0.js"></script>
<script src="./script/main.js"></script>
</body>
</html>
CSS、イメージ、スクリプトのリクエストをするだけの簡単なページです
main.js は Service Worker を登録するスクリプトです
Service Worker 登録スクリプト
navigator.serviceWorker.register('./service-worker.js')
.catch(console.error.bind(console));
Service Worker の登録を行うスクリプトです
register で登録をしてエラーがあった時はコンソール表示するだけの簡単なものです
Service Worker
ちょっと長いです
いったん、全ソースを紹介して、その後細かく説明していきます
'use strict';
const CACHE_NAME = 'cache-v1';
const urlsToCache = [
'./',
'./styles/main.css',
'./images/image.jpg',
'./script/main.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
// 指定されたリソースをキャッシュに追加する
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('activate', (event) => {
var cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// ホワイトリストにないキャッシュ(古いキャッシュ)は削除する
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
// 重要:リクエストを clone する。リクエストは Stream なので
// 一度しか処理できない。ここではキャッシュ用、fetch 用と2回
// 必要なので、リクエストは clone しないといけない
let fetchRequest = event.request.clone();
return fetch(fetchRequest)
.then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// 重要:レスポンスを clone する。レスポンスは Stream で
// ブラウザ用とキャッシュ用の2回必要。なので clone して
// 2つの Stream があるようにする
let responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
install時
const CACHE_NAME = 'cache-v1';
const urlsToCache = [
'./',
'./styles/main.css',
'./images/image.jpg',
'./script/main.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
// 指定されたリソースをキャッシュに追加する
return cache.addAll(urlsToCache);
})
);
});
CACHE_NAME はキャッシュに保存する時の名前です
DevTools の Application タブを開くと左側に Cache という項目があり
↑で指定した名前で保存されていることがわかります
CacheStorage.open('キャッシュ名')でキャッシュを開き
urlsToCache で指定されているリソースを Cache.addAll でキャッシュに保存します
activate時
self.addEventListener('activate', (event) => {
var cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// ホワイトリストにないキャッシュ(古いキャッシュ)は削除する
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
この activate 時の処理ですが、新規インストール時は何も処理されません
ここでは、キャッシュ名がcache-v2
になった時の処理を説明します
- キャッシュ名が変わったので Service Worker が更新されたと判断され、install イベントが発火します
そして、キャッシュ名cache-v2
で保存されます
fetch時
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
// 重要:リクエストを clone する。リクエストは Stream なので
// 一度しか処理できない。ここではキャッシュ用、fetch 用と2回
// 必要なので、リクエストは clone しないといけない
let fetchRequest = event.request.clone();
return fetch(fetchRequest)
.then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// 重要:レスポンスを clone する。レスポンスは Stream で
// ブラウザ用とキャッシュ用の2回必要。なので clone して
// 2つの Stream があるようにする
let responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
);
Service Worker がブラウザをコントロールしている時にリソースのリクエストが発生すると
fetch イベントが発火します
fetch イベントで何もしなければ、普通にネットワーク経由でリクエストが処理されます
ここで行っていることは単純で、リクエストが来たリソースがキャッシュに保存されていればそれを返し、なければサーバーにリクエストを投げ、返って来たリソースをキャッシュに保存しつつ、クライアントに返しているだけです
install 時にキャッシュに保存していなくても、こうすることでキャッシュすることが可能です
重要なことは、リクエストとレスポンスはStreamなので使い回すことができないようです
フェッチしたり、キャッシュしたりする場合は clone する必要があるようです
Cacheについての説明はこんな感じです
実際に、オフライン状態でページを読み込んでみると
Service Worker を使ってリソースを取得できてるのがわかります
Push Notification編
ネイティブアプリのようにプッシュ通知をブラウザに送る方法を説明します
プッシュ通知を試したい場合は、ここにあるリポジトリをクローンし
下記を参考に適宜修正してください
修正が必要なのは、manifest.json の gcm_sender_id と push.js の APIキー、エンドポイントなどです
ページ
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Push Test</title>
<link rel="manifest" href="./manifest.json">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/primer/2.0.2/primer.css">
</head>
<body>
<h1>Service Worker Push Test</h1>
<dl class="form">
<dt><label>Endpoint URL</label></dt>
<dd><textarea id="subscription-endpoint" class="input-block"></textarea></dd>
<dt><label>Auth</label></dt>
<dd><textarea id="subscription-auth" class="input-block"></textarea></dd>
<dt><label>Public Key</label></dt>
<dd><textarea id="subscription-public-key" class="input-block"></textarea></dd>
</dl>
<script src="./script/main.js"></script>
</body>
</html>
ページは manifest.json の読み込み以外、大したことはしていません
プッシュ通知に必要な情報を表示するための要素があるぐらいです
manifest.json
{
"name": "SW Push Notification",
"short_name": "SW Push Notification",
"icons": [{
"src": "https://kanatapple.github.io/service-worker/push/images/image.jpg",
"sizes": "192x192",
"type": "image/jpg"
}],
"start_url": "./index.html",
"display": "standalone",
"gcm_sender_id": "621388437768"
}
プッシュ通知する際は必要なので用意します(詳細はここを参考に)
重要なのはgcm_sender_id
です
gcm_sender_id
には後述する GCM(Google Cloud Messaging)、FCM(Firebase Cloud Messaging)を使用するプロジェクトID(送信者ID)を指定します
Service Worker 登録スクリプト
今回のサンプルではメッセージをペイロードするために必要な情報を表示するようにしています
メッセージをペイロードする必要がない時は、エンドポイントだけでOKです
document.addEventListener('DOMContentLoaded', () => {
let endpoint = document.querySelector('#subscription-endpoint');
let key = document.querySelector('#subscription-public-key');
let auth = document.querySelector('#subscription-auth');
navigator.serviceWorker.register('./service-worker.js');
navigator.serviceWorker.ready
.then((registration) => {
return registration.pushManager.subscribe({userVisibleOnly: true});
})
.then((subscription) => {
var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
key.value = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
auth.value = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
endpoint.value = subscription.endpoint;
console.log(`GCM EndPoint is: ${subscription.endpoint}`);
})
.catch(console.error.bind(console));
}, false);
重要なのは registration.pushManager.subscribe({userVisibleOnly: true})
subscribe することでプッシュ通知を購読するようになります
subscribe には {userVisibleOnly: true}
を渡す必要があると記載があったんですが
false で渡しても、引数を渡さなくても通知されるので、ちょっと謎です・・
(何かわかったら追記します)
subscribe が終わったら、公開鍵、鍵生成に使用する乱数、エンドポイントを画面上に表示してます
これらの値をプッシュ通知の際に使用します
Service Worker
/*
fetch以外は省略。リポジトリのコードを見てください
*/
self.addEventListener('push', (event) => {
console.info('push', event);
const message = event.data ? event.data.text() : '(・∀・)';
event.waitUntil(
self.registration.showNotification('Push Notification Title', {
body: message,
icon: 'https://kanatapple.github.io/service-worker/push/images/image.jpg',
tag: 'push-notification-tag'
})
);
});
メッセージをペイロードしている場合は、event.data にデータが入っているので
text()
を呼ぶとメッセージを取得できます
あとは、showNotification
に必要なデータを渡すと通知が表示されます
showNotification
のシグネチャはこんな感じ
Promise<void> showNotification(DOMString title, optional NotificationOptions options);
dictionary NotificationOptions {
NotificationDirection dir = "auto";
DOMString lang = "";
DOMString body = "";
DOMString tag = "";
USVString icon;
USVString badge;
USVString sound;
VibratePattern vibrate;
DOMTimeStamp timestamp;
boolean renotify = false;
boolean silent = false;
boolean noscreen = false;
boolean requireInteraction = false;
boolean sticky = false;
any data = null;
sequence<NotificationAction> actions = [];
};
この辺を参考に
https://notifications.spec.whatwg.org/#dom-serviceworkerregistration-getnotificationsfilter
https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification
プロジェクト登録
プッシュ通知を送信する場合は、Google Developers Console、Firebase Console に
プロジェクトを作成する必要があります
現在は Firebase の使用を推奨してるらしいですが、両方説明しときます
Google Developers Console
3.APIの有効化
ライブラリから「Google Cloud Messaging」を探して
4.認証情報の作成
プロジェクトを使用するには認証情報が必要らしいので、「認証情報に進む」をクリック
APIを呼び出す場所で「ウェブブラウザ(Javascript)」を選択して
APIキーに適当な名前を付けて「APIキーを作成する」をクリック
完了
Firebase
Firebase の場合、新規作成するか Google Developers Console に作成したプロジェクトをインポートすることができます
今回は新規作成を説明します
完了
Firebaseで作成すると、プロジェクトを作成すると
自動的にクラウドメッセージング用のAPIと送信者IDを作成してくれるので簡単です
通知を送信
それでは実際に通知を送信してみます
通知には送信者IDとAPIキーが必要なので、Google Developers Console、Firebase で調べます
Google Developers Console
送信者ID
APIキー
Firebase
送信者IDとAPIキー
manifest.jsonの修正
gcm_sender_id
に送信者IDを設定します
{
"name": "SW Push Notification",
"short_name": "SW Push Notification",
"icons": [{
"src": "https://kanatapple.github.io/service-worker/push/images/image.jpg",
"sizes": "192x192",
"type": "image/jpg"
}],
"start_url": "./index.html",
"display": "standalone",
"gcm_sender_id": "118577160855"
}
送信
今回、送信用のライブラリにweb-pushを使います
push.js を用意します
'use strict';
const push = require('web-push');
const GCM_API_KEY = '********';
push.setGCMAPIKey(GCM_API_KEY);
const data = {
'endpoint': '********',
'userAuth': '********',
'userPublicKey': '********'
};
push.sendNotification(data.endpoint, {
payload: 'push test for service worker',
userAuth: data.userAuth,
userPublicKey: data.userPublicKey,
})
.then((result) => {
console.log(result);
})
.catch((err) => {
console.error('fail', err);
});
GCM_API_KEY
に上記で調べたAPIキーを記述します
endpoint
、userAuth
、userPublicKey
にページを表示した時に表示される情報を設定します
設定が終了したら下記コマンドを実行します
node push.js
こんな感じで通知が来ます
Background Sync編
オフライン時にデータを保持しておいて、オンラインになった時にバックグラウンドでデータを送信する方法を説明します
今回はどういう仕組みで動くのかだけ説明します
ページ
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sync Test</title>
</head>
<body>
<h1>Service Worker Sync Test</h1>
<button id="button">sync</button>
<script src="./script/main.js"></script>
</body>
</html>
syncボタンがあるだけで、他は特殊なものはありません
Service Worker 登録スクリプト
navigator.serviceWorker.register('./service-worker.js');
navigator.serviceWorker.ready
.then((registration) => {
document.getElementById('button').addEventListener('click', () => {
// ここでIndexedDBなどにデータを保存しておく
// 保存が終わったら、↓を呼ぶ
registration.sync.register('sync-test')
.then(() => {
console.log('sync registerd');
})
.catch(console.error.bind(console));
}, false);
})
.catch(console.error.bind(console));
登録については他と一緒です
ボタンをクリックした時にサーバに送信したいデータを IndexedDB などに保存します
Service Worker はブラウザが必要に応じて終了させてしまうので、変数の値を保持しておけないためです
(グローバル変数に入れておいてもダメということ)
データの保存が終わったらregistration.sync.register
を呼びます
引数にはタグ名を設定します(このタグ名を IndexedDB に保存するキーとかにしておくといい)
Service Worker
/*
sync以外は省略。リポジトリのコードを見てください
*/
self.addEventListener('sync', (event) => {
console.info('sync', event);
// ここでIndexedDBからデータを取得して、サーバに送信する
});
sync が登録されて、Service Worker がサーバと Sync できる状態にあると判断した場合
この sync イベントが発火します
オンライン状態で sync が登録されたら、すぐに sync イベントが発火します
オフライン状態で sync が登録されたら、オンライン状態になった時に sync イベントが発火します
event.tag に sync 登録時に設定したタグ名が設定されています
このタグ名を使って IndexedDB に保存しておいたデータを取得しサーバにデータを送信します
こんな感じで、確実にデータを送信することができます
仕組みはこんな感じです
ここにサンプルがあるので、Developer Toolsを開きながら
オフラインの時にsyncボタン、オンラインの時にsyncボタンを押して挙動を確認してみてください
参考
http://www.html5rocks.com/ja/tutorials/service-worker/introduction/
https://blog.jxck.io/entries/2016-04-24/service-worker-tutorial.html