Service Worker のライフサイクル

サービス ワーカーのライフサイクルは、最も複雑な部分です。何をしようとしているのか、どのようなメリットがあるのかわからないと、AI が自分と戦っているように感じられることがあります。仕組みを理解すれば、ウェブとネイティブのパターンの長所を組み合わせて、シームレスで邪魔にならないアップデートをユーザーに提供できます。

詳細な説明ですが、各セクションの冒頭にある箇条書きに、知っておくべきことがほとんど記載されています。

インテント

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

  • オフラインファーストを可能にする。
  • 現在のサービス ワーカーを中断することなく、新しいサービス ワーカーが準備できるようにします。
  • スコープ内のページが、全体で同じ Service Worker によって制御されている(または Service Worker が使用されていない)ことを確認します。
  • 一度に実行されるサイトのバージョンは 1 つだけにしてください。

最後の項目は非常に重要です。サービス ワーカーがないと、ユーザーはサイトに 1 つのタブを読み込み、後で別のタブを開くことができます。これにより、2 つのバージョンのサイトが同時に実行される可能性があります。場合によっては問題ありませんが、ストレージを扱う場合は、共有ストレージの管理方法について 2 つのタブで意見が大きく異なる可能性があります。これにより、エラーが発生したり、最悪の場合はデータが失われたりする可能性があります。

最初の Service Worker

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

  • install イベントは、サービス ワーカーが最初に受け取るイベントであり、1 回だけ発生します。
  • installEvent.waitUntil() に渡される Promise は、インストールの所要時間と成功または失敗を通知します。
  • サービス ワーカーは、インストールが正常に完了して「アクティブ」になるまで、fetchpush などのイベントを受信しません。
  • デフォルトでは、ページのリクエスト自体が 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>

サービス ワーカーを登録し、3 秒後に犬の画像を追加します。

サービス ワーカー 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 のリクエストがあるたびに配信します。ただし、上記の例を実行すると、ページを初めて読み込んだときに犬が表示されます。更新すると、猫が表示されます。

スコープと制御

サービス ワーカー登録のデフォルトのスコープは、スクリプト URL を基準とした ./ です。つまり、//example.com/foo/bar.js にサービス ワーカーを登録すると、デフォルトのスコープは //example.com/foo/ になります。

ページ、ワーカー、共有ワーカーは clients と呼ばれます。サービス ワーカーは、スコープ内のクライアントのみを制御できます。クライアントが「制御」されると、そのフェッチはスコープ内のサービス ワーカーを経由します。クライアントが navigator.serviceWorker.controller によって制御されているかどうかを検出できます。navigator.serviceWorker.controller は null またはサービス ワーカー インスタンスになります。

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

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

Chrome の DevTools のコンソールと、[アプリケーション] タブの [サービス ワーカー] セクションにエラーが表示されます。

サービス ワーカーの DevTools タブにエラーが表示される

インストール

サービス ワーカーが最初に受け取るイベントは install です。ワーカーの実行直後にトリガーされ、サービス ワーカーごとに 1 回だけ呼び出されます。サービス ワーカー スクリプトを変更すると、ブラウザはそれを別のサービス ワーカーと見なし、独自の install イベントを受け取ります。アップデートについては後で詳しく説明します

install イベントは、クライアントを制御する前に必要なものをすべてキャッシュに保存する機会です。event.waitUntil() に渡す Promise により、インストールの完了時と正常に完了したかどうかをブラウザに通知します。

プロミスが拒否された場合、インストールに失敗したことが通知され、ブラウザはサービス ワーカーを破棄します。クライアントを制御することはありません。つまり、fetch イベントのキャッシュに cat.svg が存在することを前提とできます。依存関係です。

有効化

サービス ワーカーがクライアントを制御し、pushsync などの機能イベントを処理する準備が整うと、activate イベントが届きます。ただし、.register() を呼び出したページが制御されるわけではありません。

デモを初めて読み込むとき、サービス ワーカーが有効になってからかなり時間が経ってから dog.svg がリクエストされても、リクエストは処理されず、犬の画像が引き続き表示されます。デフォルトは整合性です。サービス ワーカーなしでページが読み込まれた場合、そのサブリソースも読み込まれません。デモをもう一度読み込む(つまり、ページを更新する)と、デモは制御されます。ページと画像の両方が fetch イベントを処理し、代わりに猫が表示されます。

clients.claim

制御対象外のクライアントを制御するには、サービス ワーカーが有効になったら、サービス ワーカー内で clients.claim() を呼び出します。

activate イベントで clients.claim() を呼び出す上記のデモのバリエーションを次に示します。初めて使用する場合は、猫が表示されます。「すべき」と記載したのは、タイミングが重要であるためです。猫が表示されるのは、サービス ワーカーが有効になり、画像の読み込みを試みる前に clients.claim() が有効になった場合のみです。

サービス ワーカーを使用して、ネットワーク経由で読み込まれるページとは異なる方法でページを読み込む場合は、clients.claim() を使用すると、サービス ワーカーが clients.claim() なしで読み込まれた一部のクライアントを制御することになり、問題が発生する可能性があります。

サービス ワーカーの更新

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

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

猫ではなく馬の写真で応答するように、サービス ワーカー スクリプトを変更したとします。

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 に変更しています。つまり、古いサービス ワーカーがまだ使用している現在のキャッシュを上書きすることなく、新しいキャッシュを設定できます。

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

待機中

更新された Service Worker は、正常にインストールされると、既存の Service Worker がクライアントを制御しなくなるまでアクティベーションを遅らせます。この状態は「待機中」と呼ばれ、ブラウザが一度に実行するサービス ワーカーのバージョンを 1 つだけに制限するために使用されます。

更新されたデモを実行した場合でも、V2 ワーカーはまだ有効になっていないため、猫の写真が表示されます。新しいサービス ワーカーが待機中であることを、DevTools の [Application] タブで確認できます。

新しいサービス ワーカーが待機していることを示す DevTools

デモを表示しているタブが 1 つしかない場合でも、ページを更新するだけでは新しいバージョンに切り替わりません。これは、ブラウザのナビゲーションの仕組みによるものです。移動時に、レスポンス ヘッダーが受信されるまで現在のページは消えません。レスポンスに Content-Disposition ヘッダーが含まれている場合、現在のページは消えないこともあります。この重複により、更新中は常に現在のサービス ワーカーがクライアントを制御します。

更新を取得するには、現在のサービス ワーカーを使用してすべてのタブを閉じるか、タブから移動します。デモに再度移動すると、馬が表示されます。

このパターンは、Chrome の更新方法に似ています。Chrome の更新はバックグラウンドでダウンロードされますが、Chrome を再起動するまで適用されません。なお、それまでは、現在のバージョンを引き続きご利用いただけます。ただし、これは開発中に面倒な作業になります。DevTools には、この作業を容易にする方法があります。これについては、この記事の後半で説明します。

有効化

これは、古いサービス ワーカーが消えて、新しいサービス ワーカーがクライアントを制御できるようになると発動します。このタイミングで、古いワーカーがまだ使用されている間にできなかった作業(データベースの移行やキャッシュの消去など)を行うのが理想的です。

上記のデモでは、存在するはずのキャッシュのリストを保持し、activate イベントで他のキャッシュを削除して、古い static-v1 キャッシュを削除しています。

event.waitUntil() にプロミスを渡すと、プロミスが解決するまで機能イベント(fetchpushsync など)がバッファされます。そのため、fetch イベントが発生すると、有効化は完全に完了します。

待機フェーズをスキップする

待機フェーズでは、サイトのバージョンを一度に 1 つだけ実行します。この機能が不要な場合は、self.skipWaiting() を呼び出して新しいサービス ワーカーをすぐに有効にできます。

これにより、サービス ワーカーは、現在のアクティブなワーカーを強制終了し、待機フェーズに入るとすぐに(またはすでに待機フェーズにある場合はすぐに)自身をアクティブにします。ワーカーがインストールをスキップすることはありません。

skipWaiting() を呼び出すタイミングは、待機中または待機前であれば特に重要ではありません。install イベントで呼び出すのが一般的です。

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

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

ただし、サービス ワーカーへの postMessage() の結果として呼び出すこともできます。たとえば、ユーザー操作後に skipWaiting() を実行したい場合です。

skipWaiting() を使用するデモを以下に示します。ページを移動しなくても、牛の写真が表示されます。clients.claim() と同様に、これは競合状態であるため、ページが画像の読み込みを試みる前に新しいサービス ワーカーがフェッチ、インストール、有効化された場合にのみ、カウが表示されます。

手動アップデート

前述のように、ブラウザはナビゲーションや機能イベントの後に更新を自動的にチェックしますが、手動でトリガーすることもできます。

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

ユーザーがサイトを長時間使用し、再読み込みを行わないことが予想される場合は、一定の間隔(1 時間ごとなど)で update() を呼び出すことをおすすめします。

サービス ワーカー スクリプトの URL を変更しない

キャッシュのベスト プラクティスに関する投稿を読んだことがある場合は、各バージョンのサービス ワーカーに一意の URL を指定することを検討してください。絶対にしないでください。これは通常、Service Worker では好ましい方法ではありません。現在の場所でスクリプトを更新してください。

次のような問題が発生する可能性があります。

  1. index.html は、sw-v1.js をサービス ワーカーとして登録します。
  2. sw-v1.jsindex.html をキャッシュに保存して提供するため、オフライン ファーストで動作します。
  3. index.html を更新して、新しい sw-v2.js を登録します。

上記を行うと、sw-v1.js がキャッシュから古いバージョンの index.html を提供するため、ユーザーは sw-v2.js を受け取ることはありません。サービス ワーカーを更新するために、サービス ワーカーを更新する必要がある状況に陥っています。うわぁ。

ただし、上記のデモでは、サービス ワーカーの URL を変更しています。これは、デモ用にバージョンを切り替えられるようにするためです。本番環境では行わない作業です。

開発を容易にする

サービス ワーカーのライフサイクルはユーザーを念頭に置いて構築されていますが、開発中は少し面倒です。幸い、いくつかのツールが役に立ちます。

再読み込み時に更新

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

DevTools に「再読み込み時に更新」と表示されている

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

  1. サービス ワーカーを再取得します。
  2. バイト単位で同一であっても、新しいバージョンとしてインストールします。つまり、install イベントが実行され、キャッシュが更新されます。
  3. 待機フェーズをスキップして、新しいサービス ワーカーを有効にします。
  4. ページを移動します。

つまり、2 回再読み込みしたりタブを閉じたりしなくても、各ナビゲーション(更新を含む)で最新情報が取得されます。

待機をスキップする

DevTools に「スキップ待機」と表示されている

ワーカーが待機している場合は、DevTools で [skip waiting] をクリックすると、すぐに [active] に昇格できます。

Shift+再読み込み

ページを強制的に再読み込み(Shift キーを押しながら再読み込み)すると、サービス ワーカーは完全にバイパスされます。制御不能になります。この機能は仕様に含まれているため、サービス ワーカーをサポートする他のブラウザでも動作します。

アップデートの処理

サービス ワーカーは、拡張可能なウェブの一部として設計されています。ブラウザ デベロッパーは、ウェブ デベロッパーよりもウェブ開発に精通しているとは限らないことを認識しています。そのため、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.
});

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

ご覧のとおり、サービス ワーカーのライフサイクルを理解することは有益です。理解することで、サービス ワーカーの動作がより論理的でわかりやすくなるはずです。これらの知識があれば、サービス ワーカーのデプロイと更新をより確実に行うことができます。