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() を呼び出すとダウンロードされます。スクリプトがダウンロードや解析に失敗した場合や、初回実行時にスクリプトがエラーをスローした場合、register Promise は拒否され、Service Worker は破棄されます。

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

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

インストール

Service Worker が最初に取得するイベントは install です。Worker が実行されるとすぐにトリガーされ、Service Worker ごとに 1 回だけ呼び出されます。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 を有効にした後で、Service Worker 内で clients.claim() を呼び出します。

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

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

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 を追い出し、待機段階に入るとすぐに(または、既に待機段階に入っている場合は即座に)自身をアクティベートします。ワーカーがインストールをスキップすることはなく、単に待機するだけです。

待機中または待機前であれば、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 のデプロイと更新を行う際の自信を高めることができます。