Service Worker のライフサイクル

Service Worker のライフサイクルは最も複雑な部分です。攻撃者の目的やメリットがわからなければ、戦いのように感じられます。しかし、その仕組みを理解すれば、ウェブ パターンとネイティブ パターンの長所を組み合わせた、シームレスで目立たないアップデートをユーザーに届けることができます。

ここでは深く掘り下げますが、知っておくべきことの大半は、各セクションの冒頭の箇条書きに記載されています。

インテント

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

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

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

最初の Service Worker は、

概要:

  • install イベントは Service Worker が最初に取得するイベントで、1 回のみ発生します。
  • 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 インスタンスになるのか)検出できます。

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

.register() を呼び出すと、最初の Service Worker がダウンロードされます。スクリプトの初回実行時にダウンロード、解析に失敗するか、エラーをスローすると、register Promise は拒否され、Service Worker は破棄されます。

Chrome の DevTools で、コンソールと、アプリケーション タブの 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 回読み込む(つまりページを更新する)と、デモは制御されます。ページと画像の両方で fetch イベントが発生し、代わりに猫が表示されます。

clients.claim

制御されていないクライアントを制御するには、Service Worker が有効になったら、その 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 は更新済みと見なされます。(これを拡張して、インポートされたスクリプト/モジュールも追加します)。
  • 更新された Service Worker は既存の Service Worker とともに起動され、独自の install イベントを取得します。
  • 新しいワーカーのステータス コード(404 など)が正常でない場合、解析に失敗する、実行中にエラーをスローする、インストール中に拒否した場合、新しいワーカーは破棄されますが、現在のワーカーはアクティブなままです。
  • 正常にインストールされると、更新されたワーカーは、既存のワーカーが 0 個のクライアントを制御するまで 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 がクライアントを制御しなくなるまで有効化を遅らせます。この状態は「待機中」と呼ばれ、ブラウザは Service Worker の 1 つのバージョンのみが同時に実行されていることを確認するためのものです。

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

待機中の新しい Service Worker を示す DevTools

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

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

このパターンは、Chrome の更新方法と似ています。Chrome のアップデートはバックグラウンドでダウンロードされますが、Chrome が再起動するまでは適用されません。それまでの間は、中断することなく現在のバージョンを引き続きご利用いただけます。ただし、これは開発時には手間のかかる作業ですが、DevTools にはこれを簡単にする機能があります。これについては、この記事の後半で説明します。

有効化

このコマンドは、古い Service Worker が存在しなくなり、新しい Service Worker がクライアントを制御できるようになると呼び出されます。これは、古いワーカーがまだ使用されている間に実行できなかった処理(データベースの移行やキャッシュの消去など)を実行するのに最適です。

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

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

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

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

これにより、Service Worker は現在アクティブなワーカーを追い出し、待機フェーズに入るとすぐに(または、すでに待機フェーズに入っている場合は直ちに)アクティブ化します。ワーカーがインストールをスキップすることはなく、単に待機するだけです。

skipWaiting() を呼び出すタイミングは、待機中であるか待機中である限り、実際には関係ありません。これは、install イベントで呼び出すのが一般的です。

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

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

ただし、Service Worker への postMessage() の結果として呼び出すこともできます。そのような場合と同様に、ユーザーの操作後に skipWaiting() を実行する必要があります。

skipWaiting() を使用するデモをご覧ください。移動しなくても牛の写真が表示されます。clients.claim() と同様にレースなので、新しい Service Worker がページを取得して画像を読み込もうとする前に、フェッチ、インストール、有効化を行った場合にのみ、牛が表示されます。

手動アップデート

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

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

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

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-v1.js はキャッシュから古いバージョンの index.html を提供するため、ユーザーは sw-v2.js を取得しません。さて、あなたは Service Worker をアップデートするために Service Worker を更新する必要性に迫られています。うーん。

ただし、上記のデモでは、Service Worker の URL を変更しました。デモ用に、これらのバージョンを切り替えることができます。本番環境でやるわけでもありません。

開発を容易にする

Service Worker のライフサイクルはユーザーを念頭に置いて構築されていますが、開発中は少し手間がかかります。その際に役立つツールがいくつかあります。

再読み込み時に更新

私のお気に入りです。

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

これにより、ライフサイクルがデベロッパーにとって扱いやすいものに変更されます。各ナビゲーションは次のように動作します。

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

つまり、2 回再読み込みしたり、タブを閉じたりすることなく、ナビゲーション(更新を含む)のたびに更新を取得できます。

待機をスキップ

DevTools に「skipwait」と表示される

ワーカーが待機している場合は、DevTools の [待機をスキップ] をクリックして、すぐに「アクティブ」にプロモートできます。

シフト再読み込み

ページを強制的に再読み込み(シフト再読み込み)すると、Service Worker が完全にバイパスされます。コントロールされないよ。この機能は仕様に含まれているため、他の Service Worker 対応ブラウザでも動作します。

アップデートの処理

Service Worker は、拡張可能なウェブの一部として設計されています。Google はブラウザ デベロッパーとして、ウェブ開発がウェブ デベロッパーほど得意ではないことを認識しています。そのため、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 のデプロイと更新をより自信を持って行うことができるようになります。