Service Worker のライフサイクル

Service Worker のライフサイクルは、最も複雑な部分です。その目的やメリットがわからない場合は、戦いを挑まれているかのようでしょう。しかしいったんその仕組みがわかれば、ウェブとネイティブ パターンのよいところを組み合わせて、ユーザーにシームレスかつ目立たないようにアップデートを提供できます。

ここでは詳細を説明しますが、各セクションの先頭に必要な知識を箇条書きで示します。

インテント

ライフサイクルの目的は次のとおりです。

  • オフライン ファーストを可能にする。
  • 現行の Service Worker を妨げることなく新しい Service Worker を使用可能にする。
  • スコープ内のページが同じ Service Worker で制御されるようにする(または Service Worker なし)。
  • 一度に 1 つのバージョンのサイトのみが実行されるようにする。

最後の 1 つは非常に重要です。Service Worker がない場合、ユーザーは 1 つのタブをサイトに読み込み、後で別のタブを開くことができます。これにより、同時に 2 つのバージョンのサイトが動作することになります。これでも正常に動作することがありますが、ストレージを処理する場合は、最終的に共有ストレージの管理方法が大きく異なる 2 つのタブが存在することになります。これにより、エラーが発生するか、もっと悪い場合はデータが失われる可能性があります。

最初の Service Worker

以下に簡単に説明します。

  • install イベントは Service Worker が最初に取得するイベントであり、これは一度だけ発生します。
  • installEvent.waitUntil() に渡された Promise によって、インストールにかかった時間と成功または失敗が通知されます。
  • Service Worker は、インストールが正常に終了して「アクティブ」になるまで fetchpush などのイベントを受信しません。
  • デフォルトでは、ページ リクエスト自体が Service Worker を通過する場合を除き、ページのフェッチが Service Worker を通過することはありません。そのため、Service Worker の効果を確認するには、ページを更新する必要があります。
  • clients.claim() はこのデフォルトをオーバーライドし、制御対象外のページを制御下に置くことができます。

次の HTML をご覧ください。

<!DOCTYPE html>
An image will appear here in 3 seconds:
<script>
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered!', reg))
    .catch(err => console.log('Boo!', err));

  setTimeout(() => {
    const img = new Image();
    img.src = '/dog.svg';
    document.body.appendChild(img);
  }, 3000);
</script>

これは Service Worker を登録し、3 秒後に犬の画像を追加します。

その Service Worker sw.js は次のとおりです。

self.addEventListener('install', event => {
  console.log('V1 installing…');

  // cache a cat SVG
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/cat.svg'))
  );
});

self.addEventListener('activate', event => {
  console.log('V1 now ready to handle fetches!');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the cat SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/cat.svg'));
  }
});

これは、猫の画像をキャッシュし、/dog.svg のリクエストがあるたびに表示します。ただし、上記の例を実行すると、ページを最初に読み込んだときは犬が表示されます。更新を押すと、猫が表示されます。

スコープと制御

Service Worker 登録のデフォルトのスコープは、スクリプトの URL をルートにした相対的な ./ です。つまり、//example.com/foo/bar.js に Service Worker を登録すると、デフォルトのスコープは //example.com/foo/ になります。

ページ、ワーカー、共有ワーカーは clients と呼ばれます。Service Worker で制御できるのは、スコープ内のクライアントのみです。クライアントが「制御」されるようになると、そのフェッチはスコープ内の Service Worker を通過するようになります。クライアントを制御している navigator.serviceWorker.controller が null と Service Worker インスタンスのどちらであるかを判別できます。

ダウンロード、解析、実行

最初の Service Worker は、.register() を呼び出すとダウンロードされます。スクリプトがダウンロードや解析に失敗したか、初期実行時にエラーをスローした場合、登録 Promise は棄却され、Service Worker は破棄されます。

Chrome の DevTools によって、エラーがコンソールと [Application] タブの Service Worker セクションに表示されます。

Service Worker の DevTools タブに表示されたエラー

インストール

Service Worker が最初に取得するイベントは install です。このイベントは Service Worker が実行されるとすぐにトリガーされ、Service Worker ごとに一度だけ呼び出されます。Service Worker スクリプトを変更すると、ブラウザでは別の Service Worker と見なされ、その install イベントが取得されます。アップデートの詳細については、後述します

install イベントが発生すると、クライアントを制御する前に必要なものをすべてキャッシュできます。event.waitUntil() に渡す Promise により、ブラウザはインストールの完了のタイミングと成功したかどうかを把握できます。

Promise が棄却されると、インストールは失敗したことになり、ブラウザは Service Worker を破棄します。クライアントは制御されません。つまり、fetch イベントではキャッシュに存在する cat.svg にのみ依存することになります。これは依存関係です。

有効化

Service Worker がクライアントを制御したり、pushsync などの機能イベントを処理したりできるようになると、activate イベントを取得します。ただし、.register() を呼び出したページが制御されるようになるという意味ではありません。

デモを最初に読み込んだ場合、Service Worker のアクティベート後しばらくしてから dog.svg をリクエストしても、Service Worker はリクエストの処理を行わず、犬の画像が表示されます。デフォルトは一貫性です。Service Worker なしでページが読み込まれ、サブリソースも読み込まれない場合、デモを 2 回目に読み込んだ場合(つまり、ページを更新した場合)は、Service Worker が制御するようになります。ページも画像も fetch イベントを通過し、猫が表示されます。

clients.claim

アクティベート後に Service Worker 内で clients.claim() を呼び出すことによって、制御されていないクライアントを制御できます。

activate イベントで clients.claim() を呼び出す上記のデモのバリエーションを次に示します。最初に猫が表示されるはずです。「はずです」というのは、タイミングによって異なるからです。猫が表示されるのは、画像が読み込まれる前に Service Worker がアクティベートされ、clients.claim() が有効になった場合のみです。

ページをネットワーク経由で読み込む場合とは異なる Service Worker を使用した方法で読み込んだ場合、clients.claim() は問題になることがあります。それは、Service Worker は最終的にはそれがない状態で読み込まれた一部のクライアントを制御するためです。

サービス ワーカーの更新

以下に簡単に説明します。

  • 更新は、次のいずれかが発生するとトリガーされます。
    • スコープ内ページへのナビゲーション。
    • pushsync などの機能イベントの発生時(24 時間以内にアップデート チェックが実行された場合を除く)
    • Service Worker URL が変更された場合のみ .register() を呼び出す。ただし、ワーカーの URL は変更しないでください
  • ほとんどのブラウザ(Chrome 68 以降を含む)は、登録済みの Service Worker スクリプトが更新されているかどうかチェックするとき、デフォルトではキャッシュ ヘッダーを無視します。importScripts() で Service Worker 内に読み込まれたリソースを フェッチするときには、引き続きキャッシュ ヘッダーが優先されます。このデフォルトの動作をオーバーライドするには、Service Worker の登録時に updateViaCache オプションを設定します。
  • Service Worker がアップデートされていると見なされるのは、そのバイト数がブラウザに既にあるものと異なる場合です。(これは、インポートされたスクリプトやモジュールも含むように拡張されています)。
  • アップデートされた Service Worker は、既存のものとともに起動され、独自の install イベントを取得します。
  • 新しい Worker は、ステータス コードが正常でないか(たとえば 404)、解析に失敗するか、実行中にエラーをスローするか、インストール時に棄却される場合は破棄されますが、現行の Worker はアクティブなままです。
  • インストールに成功すると、アップデートされた Worker は、既存の Worker の制御しているクライアントがゼロになるまで wait 状態になります。(クライアントは、更新中は重複します)。
  • self.skipWaiting() は待機を回避します。つまり、Service Worker は、インストールが完了するとすぐにアクティベートされます。

猫ではなく馬の画像をレスポンスとして返すように Service Worker スクリプトを変更したとします。

const expectedCaches = ['static-v2'];

self.addEventListener('install', event => {
  console.log('V2 installing…');

  // cache a horse SVG into a new cache, static-v2
  event.waitUntil(
    caches.open('static-v2').then(cache => cache.add('/horse.svg'))
  );
});

self.addEventListener('activate', event => {
  // delete any caches that aren't in expectedCaches
  // which will get rid of static-v1
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log('V2 now ready to handle fetches!');
    })
  );
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the horse SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/horse.svg'));
  }
});

上記のデモをチェックしてください。それでも猫の画像が表示されます。その理由は次のとおりです。

インストール

キャッシュ名を static-v1 から static-v2 に変更したことに注意してください。つまり、古い Service Worker でまだ使用されている現行のキャッシュ内のものを上書きせずに、新しいキャッシュをセットアップできるということです。

このパターンでは、バージョン固有のキャッシュが作成されます。これは、ネイティブ アプリがその実行可能ファイルにバンドルするアセットに似ています。avatars などのバージョン固有でないキャッシュを使用することもできます。

待機中

インストールに成功すると、アップデートされた Service Worker は、既存の Service Worker がクライアントを制御しなくなるまでアクティベートを遅らせます。この状態を「待機」といい、これによってブラウザでは同時に 1 つのバージョンの Service Worker のみが実行されることになります。

更新されたデモを実行しても、V2 Worker がまだアクティベートされていないため、猫の画像が表示されるはずです。DevTools の [Application] タブで、新しい Service Worker が待機中であることを確認できます。

新しい Service Worker が待機中であることを示す DevTools

デモで 1 つのタブのみを開いている場合でも、ページを更新しただけでは新しいバージョンに引き継がれません。これは、ブラウザ ナビゲーションの動作によるものです。ナビゲートすると、現在のページはレスポンス ヘッダーを受信するまで消えません。さらに、レスポンスに Content-Disposition ヘッダーが含まれている場合、現在のページは消えないことがあります。このような重複があると、更新中は現行の Service Worker が常にクライアントを制御することになります。

アップデートを取得するには、現行の Service Worker を使用しているすべてのタブを閉じるか、それらのタブから移動します。次に、再びデモに移動すると、馬が表示されるはずです。

このパターンは、Chrome のアップデート方法に似ています。Chrome のアップデートはバックグラウンドでダウンロードされますが、Chrome が再起動するまで適用されません。再起動までの間、中断することなく引き続き現行バージョンを使用できます。とはいっても、このことは開発時には問題となります。そこで、DevTools にはこれを軽減する方法があります。これについては後で説明します。

有効化

アクティベートにより、古い Service Worker はなくなり、新しい Service Worker がクライアントを制御できるようになります。これは、データベースの移行やキャッシュの消去など、古い Worker を使用中に実行できなかったことを実行するのに最適なタイミングです。

前述のデモでは、必要なキャッシュのリストを保持し、activate イベントでそれ以外のすべてのキャッシュを消去して、古い static-v1 キャッシュを削除しています。

Promise を event.waitUntil() に渡すと、Promise が解決されるまで機能イベント(fetchpushsync など)がバッファされます。そのため、fetch イベントが発生すると、アクティベーションは完了します。

待機段階をスキップする

待機段階とは、一度に 1 つのバージョンのサイトのみを実行していることを意味しますが、その機能が不要になった場合には、self.skipWaiting() を呼び出して新しい Service Worker をすぐにアクティベートできます。

これにより、Service Worker は現在アクティブな Worker を追い出し、待機段階に入るとすぐに(または、既に待機段階に入っている場合は即座に)自身をアクティベートします。この場合、Worker はインストールをスキップしません。待機だけをスキップします。

待機中または待機前であれば、skipWaiting() をいつ呼び出しても実際には問題にはなりません。通常は install イベントで呼び出します。

self.addEventListener('install', event => {
  self.skipWaiting();

  event.waitUntil(
    // caching etc
  );
});

ただし、Service Worker への postMessage() の結果として呼び出すこともできます。ユーザー インタラクションの後で skipWaiting() が必要になる場合などです。

skipWaiting() を使用するデモをご覧ください。移動しなくても牛の画像が表示されるはずです。clients.claim() の場合と同じように、ここでも競争となるため、新しい Service Woker がフェッチ、インストール、アクティベートを行ってからページが画像を読み込むと、牛が表示されるということです。

手動アップデート

前述したとおり、ブラウザはナビゲーションや機能イベントの後、自動的にアップデートを確認しますが、手動でトリガーすることもできます。

navigator.serviceWorker.register('/sw.js').then(reg => {
  // sometime later…
  reg.update();
});

ユーザーがサイトを再読み込みすることなく長時間使用できるようにするには、update() を定期的に呼び出す必要があります(1 時間ごとなど)。

Service Worker スクリプトの URL の変更の回避

キャッシュのベスト プラクティスに関する私の投稿をご覧になったことがある場合は、Service Worker の各バージョンに一意の URL を指定することを検討するかもしれません。この操作は行わないでください。通常、これは Service Worker に対してはベスト プラクティスどころかまったくお勧めできないので、現在の場所にあるスクリプトのみアップデートしてください。

次のような問題が発生する可能性があるからです。

  1. index.htmlsw-v1.js を Service Worker として登録します。
  2. sw-v1.js は、オフライン ファーストで動作するように index.html をキャッシュし提供します。
  3. index.html を更新して、新しい sw-v2.js を登録します。

このようにすると、ユーザーは sw-v2.js を取得しません。sw-v1.js はキャッシュから古いバージョンの index.html を提供するからです。Service Worker をアップデートするために、Service Worker をアップデートすることが必要になるという状況になってしまいました。うわぁ。

ただし、上記のデモでは、Service Worker の URL を変更しています。デモ目的で、バージョンを切り替えることができるようにしています。本番環境でこのようにすることはありません。

開発を容易にする

Service Worker のライフサイクルは、ユーザーを考慮して構築されていますが、開発時は少し問題があります。幸いなことに、この問題に役立つツールがあります。

再読み込み時に更新

これは私のお気に入りです。

DevTools の [Update on reload]

これにより、ライフサイクルはデベロッパーにとって使いやすくなります。各ナビゲーションにより次のことが行われます。

  1. Service Worker を再取得します。
  2. バイト数が同じでもそれを新しいバージョンとしてインストールします。つまり、install イベントが実行され、キャッシュがアップデートされます。
  3. 新しい Service Worker がアクティベートされるように、待機段階をスキップします。
  4. ページをナビゲートします。

つまり、2 回再読み込みしたりタブを閉じたりすることなく、ナビゲーション(更新など)ごとにアップデートを取得します。

待機をスキップする

DevTools の [skipWaiting]

待機中の Worker がある場合は、DevTools で [skipWaiting] を選択すると、すぐに「アクティブ」になります。

Shift キーを押しながら再読み込み

ページを強制的に再読み込み(シフト再読み込み)すると、Service Worker 全体がスキップされます。これは制御されません。この機能は仕様どおりであり、他の Service Worker 対応ブラウザで動作します。

アップデートの処理

Service Worker は、拡張可能ウェブの一部としてデザインされました。我々ブラウザ デベロッパーは、ウェブ デベロッパーよりもウェブ開発が得意ではないと認識しています。したがって、Google の好きなパターンを使用して特定の問題を解決する、限られた高レベルの API を提供すべきではありません。代わりに、ブラウザの中心部へのアクセス権を付与し、ユーザーに最適な方法で好きなように実行してもらいます。

できるだけ多くのパターンを有効にするために、アップデート サイクル全体は監視可能になっています。

navigator.serviceWorker.register('/sw.js').then(reg => {
  reg.installing; // the installing worker, or undefined
  reg.waiting; // the waiting worker, or undefined
  reg.active; // the active worker, or undefined

  reg.addEventListener('updatefound', () => {
    // A wild service worker has appeared in reg.installing!
    const newWorker = reg.installing;

    newWorker.state;
    // "installing" - the install event has fired, but not yet complete
    // "installed"  - install complete
    // "activating" - the activate event has fired, but not yet complete
    // "activated"  - fully active
    // "redundant"  - discarded. Either failed install, or it's been
    //                replaced by a newer version

    newWorker.addEventListener('statechange', () => {
      // newWorker.state has changed
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // This fires when the service worker controlling this page
  // changes, eg a new worker has skipped waiting and become
  // the new active worker.
});

ライフサイクルは永遠に続く

ご覧のとおり、Service Worker のライフサイクルを理解することは有益です。理解することで、Service Worker の動作がより論理的になり、不思議に思えなくなります。これらの知識があれば、Service Worker のデプロイと更新をより確実に行うことができます。