Service Worker 入門

Service Worker は「リッチなオフライン体験」を提供することができる比較的新しい JavaScript APIです。

これを使えば、たとえばネットワークに繋がらない時、通常現れる恐竜の代わりにカスタマイズしたページを表示させることができるようになります。

必要な知識

Service Worker は難易度が高い分野です。少なくとも次のスキルが求められます。

  • HTML、CSS、JavaScript や HTTP 通信などウェブ技術の基本的な理解があること
  • JavaScript の Promise の使い方を知っていること
  • ブラウザの開発者ツールを扱うことができること

Service Worker 概要

Service Worker は、WebブラウザがWeb ページとは別にバックグラウンドで実行する、アプリケーションのキャッシュを管理するスクリプトです。 HTMLで参照されるリソースや、最初のindex.htmlへのリクエストでさえもキャッシュ可能で、サーバー指定のキャッシュヘッダーに依存しません。

Service Worker を活用すればユーザー体験を大幅に向上させることができます。

特徴

  • Service worker は、JavaScript の Worker の一種です。そのため、メインスレッドから独立したスレッドで実行されます。
  • HTTPS 通信を前提としています。HTTP通信では使用できません。
  • Promise を多用します。予め Promise の扱いに慣れておきましょう。
  • 1つの Service Worker で複数のページを制御することができます。逆に、それぞれのページは独自の Service Worker を持ちません。
  • Service Worker は使用されていない間は終了していて、必要なときになったら起動します。異なるライフサイクル間でデータを共有したい場合は、IndexedDB を使用します。

できること

  • ブラウザのオフライン画面(Chromeなら恐竜)を表示させないようにする(カスタムオフラインページ)
  • 位置情報やジャイロスコープのような計算コストの高いデータの更新を集中的に受信して、複数のページがデータの一部を利用できるようにすること
  • 特定のURLパターンに基づくテンプレートのカスタマイズ
  • ユーザーが近く必要になるであろうデータの先読み
  • バックグランドのデータ同期

できないこと

  • DOMへのアクセス
  • HTTPでの使用
  • 同期型の XHR やlocalStorage のようなAPIの使用

ブラウザーのサポート状況

Internet Explorer でのサポートはありません。Safari や Edge のサポートが乏しかったのですが、Safari はバージョン11.1から、Edge はバージョン17から十分なサポートにあるようです。

詳しくは、Jake Archibaldの Is Serviceworker ready? や Can I Use で確認できます。

一連の流れ

  1. navigator.serviceWorker.register() で Service Worker の登録を行う
  2. 成功すると、メインスレッドから独立している ServiceWorkerGlobalScopeで実行される
  3. インストールが試みられ、完了すると、ページ読み込みが起こった後に Service Worker が有効化される
  4. この時点で Service Worker はページを制御しているため、リクエストを受け取るたびにキャッシュを返すなどの処理を行うことができる

Service Worker の登録

app.js
  1. if ('serviceWorker' in navigator) {
  2. window.addEventListener('load', function() {
  3. navigator.serviceWorker.register('/sw.js')
  4. .then(function(registration) {
  5. // 登録成功
  6. console.log('ServiceWorker 登録成功: ', registration.scope);
  7. })
  8. .catch(function(err) {
  9. // 登録失敗 :(
  10. console.log('ServiceWorker 登録失敗: ', err);
  11. });
  12. });
  13. }

まず、Service Worker API が利用可能かチェックします。

  1. if ('serviceWorker' in navigator) {

利用可能であれば、ページを読み込む時に JavaScript ファイルである Service Worker /sw.js を、ServiceWorkerContainer.register() で登録します。これは、ページが読み込まれる度に行いますが、実際に登録が行われるかはブラウザが自動でうまいことやってくれるので心配はいりません。

  1. window.addEventListener('load', function() {
  2. navigator.serviceWorker.register('/sw.js')

ここで注意点があって、この例ではドメインのルートに sw.js があるので、この Service Worker はこのドメインのすべての fetch イベントを受け取ります。つまり、Service Worker ファイルのある場所がそのまま Service Worker のスコープになります。もし /sub/sw.js としたら、Service Worker は、ページのURLが /sub/ から始まるもののみの fetch イベントを受け取ります。これは、デフォルトの動作であり、Service Worker ファイルとは異なる階層をスコープにしたいのなら、次のように scope オプションで指定します。

scope オプションを指定する場合:

  1. navigator.serviceWorker.register('/sw.js', {scope: '/sub/'})

register() メソッドは Promise を返すので、成功の場合と失敗の場合の処理をメソッドチェーンで記述します。

  1. .then(function(registration) {
  2. // 登録成功
  3. console.log('ServiceWorker 登録成功: ', registration.scope);
  4. })
  5. .catch(function(err) {
  6. // 登録失敗 :(
  7. console.log('ServiceWorker 登録失敗: ', err);
  8. });

Service Worker のインストール

ここからは Service Worker スクリプトに移ります。

Service Worker の登録が終わると、ブラウザーは Service Worker をインストールしようします。この時に発火するのが install イベントです。このイベントのコールバックにキャッシュしたいアセットを指定します。まず、コールバックの書き方です。

  1. self.addEventListener('install', function(event) {
  2. event.waitUntil(
  3. // インストールステップ
  4. );
  5. });

ここで、見慣れないものが2つ出てきましたね。

まず self ですが、これはService Worker のグローバル実行コンテキストです。Service Worker 版 window といったところです。そしてwaitUntil() メソッドは、内部の処理が成功(resolve)するまでは Service Worker がインストールされないことを保証する記述です。

続いて、インストールステップの内容を付け加えます

インストールイベントのコールバック——sw.js
  1. self.addEventListener('install', function(event) {
  2. event.waitUntil(
  3. caches.open('cache-v1')
  4. .then(function(cache) {
  5. return cache.addAll([
  6. '/',
  7. '/styles.css',
  8. 'app.js',
  9. '/images/default.png'
  10. ]);
  11. })
  12. );
  13. });

caches.open() メソッドは、任意のキャッシュ名を引数にとってコールします。解決(resolve)されると、パラメータとしてオープンしたキャッシを返すので、addAll() メソッドにキャッシュしたいすべてのリソースのオリジン相対URLを配列で指定してコールします。これもまた成功または失敗の Promise を返します。

レスポンスを制御する

サイトのアセットはキャッシュされたので、リクエストに対してキャッシュのレスポンスを行えます。ここが一番やりたいと思える部分ではないでしょうか。

そのために新しいイベントである fetch の登場です。Service Worker がインストールされた状態で、他のページヘ移動したりページを更新したりすると、Service Worker は fetch イベントを受け取ります。

リクエストに対する応答の基本——sw.js
  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.match(event.request)
  4. .then(function(response) {
  5. // キャッシュがあったのでレスポンスを返す
  6. if (response) {
  7. return response;
  8. }
  9. // キャッシュがなかったので通常の fetch を行う
  10. return fetch(event.request);
  11. }
  12. )
  13. );
  14. });

event.respondWith() では、ブラウザー既定の fetch ハンドリングを抑止するはたらきがあり、引数にレスポンスの Promise を受け取ります。caches.match() でキャッシュの確認を行って、 キャッシュがあればキャッシュを返し、なければ規程の fetch を行うように場合分けして使います。

ここまでの例では、キャッシュの作成はインストールの時に行うのみでした。しかし、後の要求がオフラインであるかもしれないことに備えてリクエストがある度にアセットをキャッシュに追加していきたいと思うでしょう。その場合は次のように、キャッシュがなかったときの fetch リクエストのレスポンスを取得してにキャッシュに追加します。

リクエストに応答する際、キャッシュになければ追加するように変更した例——sw.js
  1. // キャッシュがなかったので通常の fetch を行う
  2. // 重要:リクエストはストリームであり、1度しか読み取れないため複製します。
  3. // ブラウザーのフェッチに加え、キャッシュで使用するため2つ必要になります。
  4. var fetchRequest = event.request.clone();
  5. return fetch(fetchRequest)
  6. .then(
  7. function(response) {
  8. // 有効な応答を受信したかどうかを確認します
  9. if(!response || response.status !== 200 || response.type !== 'basic') {
  10. return response;
  11. }
  12. // 有効な応答を受信したようなので、キャッシュに追加していきます。
  13. // 重要:リクエストと同様の理由により、レスポンスも複製します。
  14. var responseToCache = response.clone();
  15. caches.open('cache-v1')
  16. .then(function(cache) {
  17. cache.put(event.request, responseToCache);
  18. });
  19. return response;
  20. }
  21. );
  22. }
  23. )
  24. );
  25. });

コメントに記載してありますが、リクエストとレスポンスはストリームであるので、読み取れるのは1度きりです。それぞれキャッシュ用に追加で1回使用するので .clone() しています。リクエストは14行目と27行目、レスポンスは27行目と30行目で使用されています。

ここまでの例でかなり対応が柔軟になりましが、リクエストの際にアセットがキャッシュになく、それでいてネットワークがオフラインの場合は、リクエスト失敗の恐竜が現れてしまいます。

オフライン恐竜
  1. self.addEventListener('fetch', function(event) {
  2. event.respondWith(
  3. caches.match(event.request)
  4. .then(function(response) {...})
  5. .catch(function() {
  6. return caches.match('/images/default.png');
  7. })
  8. );
  9. });

この '/images/default.png' というのは、先の install イベントで登録しておいたものです。

Service worker の更新

Service Worker が既にインストールされていて、ページの更新や読み込みが行われた時に新しいバージョンの Service worker が利用できる場合はバックグラウンドでインストールされます(起動はまだされません)。バージョンの異なるかどうかはバイトの差異で判断されます。インストールが完了した後でページの読み込みが発生すると新しいバージョンが起動されます。

新しい Service Worker がページを制御するようになると activate イベントが起こります。このタイミングで不要になった古いキャッシュを削除することができます。

次の例では、’cache-v2′ 以外のキャッシュを削除します。削除には caches.delete() を使います。

古いキャッシュの削除——sw.js
  1. self.addEventListener('activate', function(event) {
  2. var cacheKeeplist = ['cache-v2'];
  3. event.waitUntil(
  4. caches.keys().then(function(keyList) {
  5. return Promise.all(keyList.map(function(key) {
  6. if (cacheKeeplist.indexOf(key) === -1) {
  7. return caches.delete(key);
  8. }
  9. }));
  10. })
  11. );
  12. });

全体像

ここまでのコードの全体像を確認します。

まず、app.js は、メインスレッドで実行される通常の JavaScript です。ページが読み込まれた際に実行され、ServiceWorker の登録を指示します。

app.js
  1. if ('serviceWorker' in navigator) {
  2. window.addEventListener('load', function() {
  3. navigator.serviceWorker.register('/sw.js')
  4. .then(function(registration) {
  5. // 登録成功
  6. console.log('ServiceWorker 登録成功: ', registration.scope);
  7. })
  8. .catch(function(err) {
  9. // 登録失敗 :(
  10. console.log('ServiceWorker 登録失敗: ', err);
  11. });
  12. });
  13. }

もう一方は ServiceWorker のコードです。メインスレッドからは独立して動作し、様々な処理を記述します。

sw.js
  1. self.addEventListener('install', function(event) {
  2. event.waitUntil(
  3. caches.open('cache-v1')
  4. .then(function(cache) {
  5. return cache.addAll([
  6. '/',
  7. '/styles.css',
  8. 'app.js',
  9. '/images/default.png'
  10. ]);
  11. })
  12. );
  13. });
  14.  
  15. self.addEventListener('fetch', function(event) {
  16. event.respondWith(
  17. caches.match(event.request)
  18. .then(function(response) {
  19. // キャッシュがあったのでレスポンスを返す
  20. if (response) {
  21. return response;
  22. }
  23. // キャッシュがなかったので通常の fetch を行う
  24. // 重要:リクエストはストリームであり、1度しか読み取れないため複製します。
  25. // ブラウザーのフェッチに加え、キャッシュで使用するため2つ必要になります。
  26. var fetchRequest = event.request.clone();
  27. return fetch(fetchRequest)
  28. .then(
  29. function(response) {
  30. // 有効な応答を受信したかどうかを確認します
  31. if(!response || response.status !== 200 || response.type !== 'basic') {
  32. return response;
  33. }
  34. // 有効な応答を受信したようなので、キャッシュに追加していきます。
  35. // 重要:リクエストと同様の理由により、レスポンスも複製します。
  36. var responseToCache = response.clone();
  37. caches.open('cache-v1')
  38. .then(function(cache) {
  39. cache.put(event.request, responseToCache);
  40. });
  41. return response;
  42. }
  43. );
  44. }
  45. )
  46. );
  47. });
  48.  
  49. self.addEventListener('activate', function(event) {
  50. var cacheKeeplist = ['cache-v2'];
  51. event.waitUntil(
  52. caches.keys().then(function(keyList) {
  53. return Promise.all(keyList.map(function(key) {
  54. if (cacheKeeplist.indexOf(key) === -1) {
  55. return caches.delete(key);
  56. }
  57. }));
  58. })
  59. );
  60. });

開発で役に立つこと

  • 有効になっている Service Worker を知るには、chrome://inspect/#service-workers にアクセスするとわかる(Chrome や Opera の場合)
  • テストはシークレットウインドウで行うといい。なぜなら、ウィンドウを閉じてから開けばそれまでの Service Worker の影響を受けないから。

ブラウザーの開発者ツールの [Application] タブには Sevice Worker の項目があります。

www.youtube.com の Service Worker(Chrome での表示)

参考文献