Service Workerの使い方・総まとめ!オフライン配信や通知の配信

Service Worker(サービスワーカー)は以前と比べてかなり浸透してきました。

ブラウザもほとんど対応しています。(IEはサポート終了したので無視。)

PWAも同時並行でiOSにも対応が進んできています。

PWA(プログレッシブウェブアプリ)は、HTML、CSS、JavaScript、WebAssemblyなどの一般的なウェブテクノロジーを使用して構築された、ウェブを通じて配信されるアプリケーションソフトウェアの一種です。

Wikipedia

要するにWebサイトを実際のアプリのように動かしてしまうのがPWAなのですが、PWAはService Workerを用いてオフライン対応や、通信帯域の節約、通知の配信を行っています。

筆者自身、今まで少しミーハー的によく知らないままService Workerを扱ってきてしまったので、この記事にまとめて整理していこうと思います。

まずService Workerって?

Service Workerは、今までのWebアプリケーションの概念を覆すような仕組みです。

なぜなら、Webサイトとは別に独立したプロセスなためです。

Service Workerを使うことで今までできなかったこんなことができてしまいます。

オフラインでのサイトの表示

オンライン時に取得したデータをキャッシュしておくことで、オフラインでもサイトを表示させることができます。

また、サイトのリクエストをキャッシュさせることができるので、ページのレスポンスを書き換えることも可能です。

これにより、常にサーバーからデータを取得する必要がなくなるので、サーバーの負荷軽減だけでなくユーザー体験の向上にもつながります。

通知の配信

今までインストールしたアプリでしかできないような通知の配信がService Workerを用いることで可能になります。

通知の配信については、この記事では触れないため以下の記事を参考にしてください。

データのバックグラウンド処理

サイトを開いていない時でも、保存されているデータを最新に保ったり、オフラインで使用されたデータをオンラインになったと同時に送信する。といったことが可能になります。

また、高負荷なプログラムをバックグラウンドで処理することによって表示を円滑にすることも可能です。
※プログラムを別プロセスにするにはWeb Worker APIを利用します。本記事では扱いません。

Service Workerが使えないケース

Service Workerは、次の場合無効化されます。

  • HTTPS以外での通信
  • ブラウザのプライベートモード( Firefox )

Service Workerのインストール

Service Workerを利用するには、Service WorkerのJSファイルを登録する必要があります。
(ServiceWorkerのファイルについては次章以降に説明します。)

  1. ダウンロード
  2. インストール
  3. 有効化

の3つの手順によって利用できるようになります。

if('serviceWorker' in navigator){
  window.addEventListener('load',()=>{
    navigator.serviceWorker.register('/sw.js',{scope:'/'})
    .then((registration)=>{
      console.log('ServiceWorker 登録成功:',registration.scope);
    }).catch((err)=>{console.log('ServiceWorker 登録失敗: ', err);});
  });
}

if('serviceWorker' in navigator)では、Service Workerが使用可能かをチェックしています。

navigator.serviceWorker.registerでService Workerの登録を行います。

第一のパラメーターではスクリプトのURL。

第二のパラメータではService Workerのスコープを指定できます。(省略可)

また、then内のregistrationでは以下のメソッド・イベントが使用できます。(よく利用するもの)

registration.unregister()

Service Workerの登録を解除します。

Service Workerに進行中の作業がある場合、それらが完了してから登録が解除されます。

registration.update()

登録されているService Workerの更新を試みます。

現在登録されているワーカーと配信されているワーカーがバイト単位で異なる場合は、新しいワーカーをインストールします。

通常は自動的に更新されますが、明示的に更新することが可能です。

updatefound (イベント)

Service Workerがアップデートされた際に発火します。

registration.onupdatefound=()=>{
  console.log("アップデートされたよ!");
}

また、registration.installingには、インストール中にのみ新しいワーカーを取得できます。通常はnullです。つまり、nullかどうかでインストール中かどうかを判断することもできます。

registration.addEventListener('updatefound', function() {
  var installingWorker = registration.installing;
  console.log('インストール中:',installingWorker);
});

まとめ

まとめるとこのようなコードになります。

if('serviceWorker' in navigator){
  window.addEventListener('load',()=>{
    navigator.serviceWorker.register('/sw.js',{scope:'/'})
    .then((registration)=>{
      console.log('ServiceWorker 登録成功:',registration.scope);
      registration.addEventListener('updatefound', function() {
        var installingWorker = registration.installing;
        console.log('インストール中:',installingWorker);
      });
    }).catch((err)=>{console.log('ServiceWorker 登録失敗: ', err);});
  });
}

Service Worker 本体

次は、オフラインキャッシュや通知の制御を行うプログラムについてです。

install (イベント)

新しいワーカーのインストール時に発火します。

主にキャッシュの追加等を行います。
(キャッシュについては後ほど説明します)

self.addEventListener('install', (event) => {
  console.log('install!');
  event.waitUntil(
    // キャッシュを開く
    caches.open('cache_name').then((cache) => {
      // 指定されたファイルをキャッシュに追加する
      return cache.add('/','image.png','test.html');
    })
  );
});

skipWaiting()

ワーカーのインストール時、通常は待機状態となり、Webサイトが次回開かれた時に有効化されます。

installイベント内で使用することでこの待機状態をスキップし、強制的にアクティブ状態に変更します。

//インストール時にすぐに待機からアクティブに変更
event.waitUntil(self.skipWaiting());

active (イベント)

インストールされたワーカーがアクティブ状態になった際に発火します。

主に前回のワーカーのキャッシュデータの削除を行います。

self.addEventListener('activate', (event) => {
  console.log('activate!');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return cacheNames.filter((cacheName) => {
        // このスコープに所属していて且つCACHE_NAMEではないキャッシュを探す
        return cacheName.startsWith(`${registration.scope}!`) &&
               cacheName !== CACHE_NAME;
      });
    }).then((cachesToDelete) => {
      return Promise.all(cachesToDelete.map((cacheName) => {
        console.log('delte cacheName: ', cacheName);
        // いらないキャッシュを削除する
        return caches.delete(cacheName);
      }));
    })
  );

clients.claim()

アクティブなワーカーにアクティブページの制御を設定します。

通常アクティブ時に開いているサイトは制御されず、次回読み込み時から制御されます。それらのページをすぐに制御することができます。

//既に読み込まれているサイトから制御対象にする
event.waitUntil(self.clients.claim());

Event.waitUntil()

先ほどからしれっと登場しています。

installイベント内に使用すると、waitUntil内に定義したタスクが完了するまでService Workerをインストール中の段階で保持します。
これによって、installイベントが完了する前にactiveイベントが発生し、不具合が起きないようにします。

activeイベント内で使用すると、waitUntil内のPromiseが終了するまでfetchやpushなどの機能イベントを一時的に停止します。
これによって、完全にactiveイベントが確立された適切な状態でイベントが実行されます。

self.addEventListener('install', (event) => {
  console.log('install');
  event.waitUntil(
    // Program
  );
});

self.addEventListener('activate', (event) => {
    console.log('activate');
    event.waitUntil(
      // Program
    );
    })
  );

Cache メソッド

Cache.match(request, {options})

キャッシュ内から最初に一致したリクエストを返す

cache.match(request, {options}).then(function(response) {
  // レスポンス
});

options(省略可)

  • ignoreSearch Boolean Default:false URLのクエリ文字を無視するかどうか。
  • ignoreMethod Boolean Default:false trueに設定すると、照合操作がRequest HTTPメソッドを検証しないようにする。
  • ignoreVary Boolean Default:false trueに設定すると、VARYヘッダの照合を実行しないように指示する。つまり、URLが一致するとVARYヘッダにかかわらず一致する。

複数の一致を取得するには、Catch.mathAll()を使用します。使い方はmatchとほとんど同じです。

Cache.add(request)

URLを受け取り、それを取得し、指定されたキャッシュに結果のレスポンスを追加します。

cache.add(request).then(function() {
  // リクエストはすでに cahce に追加された状態
});
  • request キャッシュに加えるリクエスト。Request オブジェクトかURLを指定します。

機能的には以下と同じです。省略して書ける便利な関数ってことです。

fetch(url).then(function(response) {
  if (!response.ok) {
    throw new TypeError('bad response status');
  }
  return cache.put(url, response);
})

複数のURLを同時にキャッシュする場合はCache.addAll()を使用します。

cache.addAll([request1,request2,request3,...]).then(function() {
  // リクエストはすでに cahce に追加された状態
});

Cache.put(request, response)

リクエストとレスポンスの両方を受け取り、指定されたキャッシュへ追加します。

  • request キャッシュに追加するRequest オブジェクトまたはURL
  • response リクエストと合うResponse。

※URLスキームがhttpまたはhttpsでない場合、エラーになります。

cache.put(request, response).then(function() {
  // リクエストとレスポンスのペアがキャッシュに追加された
});

Cache.delete(request, options)

リクエストを探し、見つかった場合はCache エントリを削除して、Promiseはtrueを返します。見つからない場合は、Promiseはfalseを返します。

cache.delete(request, {options}).then(function(found) {
  // キャッシュエントリが見つかった場合は削除されている
});
  • request 削除しようとしている RequestオブジェクトまたはURL。
  • options(省略可)
    • ignoreSearch 照合操作でクエリ文字を無視するかどうか。デフォルトはfalse。
    • ignoreMethod trueの場合、照合操作がRequest HTTPメゾットを検証しないようにする。デフォルトはfalse。
    • ignoreVary trueに設定すると、VARYヘッダを照合しないようにする。デフォルトはfalse。

Cache.keys(request, options)

Cacheキーの配列を返す。挿入されたのと同じ順番で返されます。

cache.keys(request, {options}).then(function(keys) {
  // リクエストの配列
});
  • request 省略可特定のキーが必要な場合、返してほしい Request。Request オブジェクトまたは URL です。
  • options (省略可)
    • ignoreSearch: 照合操作で URL のクエリ文字列を無視するかどうか。 デフォルトは false。
    • ignoreMethod: true に設定すると、照合操作が Request HTTP メソッドを検証しないようにする。 デフォルトは false。
    • ignoreVary: true に設定すると、VARY ヘッダーを照合しないように照合操作に指示する。デフォルトは false。

まとめ

実際にWebサイトに上記の内容を埋め込むと以下のようになります。

const CACHE_VERSION='v1.0.0';
const CACHE_NAME =`${registration.scope}!${CACHE_VERSION}`;
// インストール時に自動でキャッシュするファイルをセット
const urlsToCache = [
  '/',
  '/favicon.ico',
];

self.addEventListener('install', (event) => {
  console.log('install!');
  event.waitUntil(
    // キャッシュを開く
    caches.open(CACHE_NAME)
    .then((cache) => {
      // 指定されたファイルをキャッシュに追加する
      return cache.addAll(urlsToCache);
    })
  );
  //インストール時にすぐに待機からアクティブに変更
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event) => {
  console.log('activate!');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return cacheNames.filter((cacheName) => {
        // このスコープに所属していて且つCACHE_NAMEではないキャッシュを探す
        return cacheName.startsWith(`${registration.scope}!`) &&
               cacheName !== CACHE_NAME;
      });
    }).then((cachesToDelete) => {
      return Promise.all(cachesToDelete.map((cacheName) => {
        console.log('delte cacheName: ', cacheName);
        // いらないキャッシュを削除する
        return caches.delete(cacheName);
      }));
    })
  );
  //既に読み込まれているサイトも制御対象にする
  event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
  console.log('fetch!');
  event.respondWith(
    caches.match(event.request)
    .then((response) => {
      // キャッシュ内に該当レスポンスがあれば、それを返す
      if (response) {
        return response;
      }

      // 重要:キャッシュ用、fetch用と2回cloneする
      const fetchRequest = event.request.clone();

      return fetch(fetchRequest)
        .then((response) => {
          if (!response || response.status !== 200 || response.type !== 'basic') {
            // キャッシュしないレスポンス
            return response;
          }

          // 重要:ブラウザ用とキャッシュ用の2回cloneする
          const responseToCache = response.clone();

          caches.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache);
            });

          return response;
        });
    })
  );
});

上のコードは、最初に設定しておいたページを登録し、その後キャッシュにないファイルを次々にキャッシュしていくシステムです。

もし、指定したキャッシュ以外を登録したくない場合は次のようなコードにすることができます。

const CACHE_VERSION='v1.0.0';
const CACHE_NAME =`${registration.scope}!${CACHE_VERSION}`;
// インストール時に自動でキャッシュするファイルをセット
const urlsToCache = [
  '/',
  '/favicon.ico',
];

self.addEventListener('install', (event) => {
  console.log('install!');
  event.waitUntil(
    // キャッシュを開く
    caches.open(CACHE_NAME)
    .then((cache) => {
      // 指定されたファイルをキャッシュに追加する
      return cache.addAll(urlsToCache);
    })
  );
  //インストール時にすぐに待機からアクティブに変更
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event) => {
  console.log('activate!');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return cacheNames.filter((cacheName) => {
        // このスコープに所属していて且つCACHE_NAMEではないキャッシュを探す
        return cacheName.startsWith(`${registration.scope}!`) &&
               cacheName !== CACHE_NAME;
      });
    }).then((cachesToDelete) => {
      return Promise.all(cachesToDelete.map((cacheName) => {
        console.log('delte cacheName: ', cacheName);
        // いらないキャッシュを削除する
        return caches.delete(cacheName);
      }));
    })
  );
  //既に読み込まれているサイトも制御対象にする
  event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
  console.log('fetch!');
  event.respondWith(
    caches.match(event.request)
    .then((response) => {
      // キャッシュ内に該当レスポンスがあれば、それを返す
      if (response) {
     return response;
      }
      //なければそのままリクエストして返す
      return fetch(event.request);
    })
  );
});

このように、カスタマイズ次第で様々な用途に応用できるService Worker。

是非サイトに取り入れてみてください。

参考