Service Workerの基本とそれを使ってできること

Service Workerとは

ブラウザが Web ページとは別にバックグラウンドで実行するスクリプト
オフラインのアプリを実現・サポートするために作られたものです

ちなみに、ブラウザの対応状況はこんな感じ
http://caniuse.com/#search=service%20workers

特徴

  • DOM にアクセスできない
    • DOM を操作したい場合は、Service Worker がコントロールしているページ(js)と postMessage でメッセージのやり取りをして行う
  • リクエストをプロキシすることが可能
  • Service Worker はブラウザが必要に応じて起動・終了するので、変数の値を保持しておけない
    • Cache、IndexedDB 等で値を保存して、必要になった時に取り出すようにする
  • Promise を多用する
  • httpslocalhost 上でしか動作しない

ライフサイクル

Web ページとは全く異なるライフサイクルで動作する

ServiceWorkerLifeCycle.png

赤文字はその時に発火するイベント

登録

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 イベントを監視する

// service-worker.js
self.addEventListener('install', (event) => {
  // 何かの処理
});

更新

ブラウザが保持している Service Worker と、これからダウンロードしようとしている Service Worker に
1byteでも違いがあれば更新されたと判断される

ブラウザをコントロールしている Service Worker が存在しなければ installing 状態のあと
すぐに、active 状態になるが、コントロールしている Service Worker が存在する場合は
waiting 状態に移行する

ServiceWorker更新時.png

なぜすぐに active 状態にしないかというと、古い Service Worker が保持しているデータを
そのまま新しい Service Worker が使おうとした場合、不整合が起きる可能性があるからです
その後、安全な状態(ページが閉じられるなど)になると waiting 状態から active 状態に移行します

Tips

更新時に installing 状態からすぐに active 状態に移行させたい

Service Worker 更新時はデータの不整合等を防ぐために waiting 状態に移行し
安全な状態になるのを待つようになっている

すぐに active 状態にしても問題ない場合は、skipWaiting()を呼ぶことで active 状態にすることができる

// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(self.skipWaiting());
});

waitUntil()はこの関数が呼ばれたイベント終了のライフタイムをその処理が終わるまで待つ
(この関数はよく使うので覚えておくといいです)

active 状態になったらすぐにコントロールさせたい

Service Worker は active 状態になってもすぐにブラウザをコントロールしない
コントロールするのは次にページが表示された時

claim()を呼ぶことすぐにコントロールさせることができる

// service-worker.js
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 登録スクリプト

// main.js
navigator.serviceWorker.register('./service-worker.js')
                       .catch(console.error.bind(console));

Service Worker の登録を行うスクリプトです
register で登録をしてエラーがあった時はコンソール表示するだけの簡単なものです

Service Worker

ちょっと長いです
いったん、全ソースを紹介して、その後細かく説明していきます

// service-worker.js
'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 はキャッシュに保存する時の名前です
Cacheキャッシュ名1.png
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になった時の処理を説明します

  1. キャッシュ名が変わったので Service Worker が更新されたと判断され、install イベントが発火します
    そして、キャッシュ名cache-v2で保存されます
    Cacheキャッシュ名2.png
    このようにキャッシュが2つある状態になります
  2. 安全な状態になり、再度ページを開いた時に activate イベントが発火します
    CACHE_NAMEcache-v2になっているのでcacheWhitelistにはcache-v2が設定されます
    cache.keysによって保存されているキャッシュの名前が列挙され、ホワイトリストにないcache-v1(古いキャッシュ)が削除されます
    Cacheキャッシュ名3.png
    cache-v1が削除されていることがわかる

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についての説明はこんな感じです
実際に、オフライン状態でページを読み込んでみると CacheDevTools.png
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に必要なデータを渡すと通知が表示されます

プッシュ通知.png

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

https://console.developers.google.com/

1.左上のプロジェクト作成を選択 Googleプロジェクト作成1.png

2.プロジェクト名を設定して、作成 Googleプロジェクト作成2.png

3.APIの有効化 GoogleAPI1.png
ライブラリから「Google Cloud Messaging」を探して

GoogleAPI2.png
有効化します

4.認証情報の作成 Google認証情報作成1.png
プロジェクトを使用するには認証情報が必要らしいので、「認証情報に進む」をクリック

Google認証情報作成2.png
APIを呼び出す場所で「ウェブブラウザ(Javascript)」を選択して

Google認証情報作成3.png
APIキーに適当な名前を付けて「APIキーを作成する」をクリック

完了

Firebase

https://console.firebase.google.com/

Firebase の場合、新規作成するか Google Developers Console に作成したプロジェクトをインポートすることができます
今回は新規作成を説明します

1.プロジェクト作成をクリック
Firebaseプロジェクト作成1.png

2.プロジェクト名を設定して、作成
Firebaseプロジェクト作成2.png

完了

Firebaseで作成すると、プロジェクトを作成すると
自動的にクラウドメッセージング用のAPIと送信者IDを作成してくれるので簡単です

通知を送信

それでは実際に通知を送信してみます
通知には送信者IDとAPIキーが必要なので、Google Developers Console、Firebase で調べます

Google Developers Console

送信者ID

Google送信者ID.png

APIキー

GoogleAPIキー.png

Firebase

送信者IDとAPIキー

Firebase情報.png

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 を用意します

// 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キーを記述します
endpointuserAuthuserPublicKeyにページを表示した時に表示される情報を設定します

設定が終了したら下記コマンドを実行します

node push.js

プッシュ通知.png

こんな感じで通知が来ます

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 登録時に設定したタグ名が設定されています BackgroundSyncTag.png

このタグ名を使って 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