Service Worker のライフサイクル

Service Worker のライフサイクルは、最も複雑な部分です。その目的やメリットがわからない場合は、戦いを挑まれているかのようでしょう。

しかしいったんその仕組みがわかれば、ウェブとネイティブ パターンのよいところを組み合わせて、ユーザーにシームレスかつ目立たないようにアップデートを提供できます。

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

目的

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

  • オフライン ファーストを可能にする。
  • 現行の Service Worker を妨げることなく新しい Service Worker を使用可能にする。

  • スコープ内のページが同じ Service Worker で制御されるようにする(または Service Worker なし)。

  • 一度に 1 つのバージョンのサイトのみが実行されるようにする。

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

これにより、エラーが発生するか、もっと悪い場合はデータが失われる可能性があります。

警告: ユーザーはデータが失われるのを非常に嫌います。悲痛な気持ちになります。

最初の Service Worker

概要:

  • Service Worker が最初に取得するのは install イベントであり、これは一度だけ発生します。

  • installEvent.waitUntil() に渡された Promise によって、インストールの時間と成功または失敗が通知されます。

  • Service Worker は、インストールが正常に終了して「アクティブ」になるまで fetchpush などのイベントを受信しません。

  • デフォルトでは、ページ リクエスト自体が Service Worker を通過した場合を除き、ページの fetch は 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 で制御できるのは、スコープ内のクライアントのみです。 クライアントが制御されるようになると、その fetch はスコープ内の 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 なしでページが読み込まれ、サブリソースも読み込まれない場合、デフォルトは「一貫性」です。 デモを 2 回目に読み込んだ場合(つまり、ページを更新した場合)は制御されます。ページも画像も fetch イベントを通過し、猫が表示されます。

clients.claim

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

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

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

注: 多くのユーザーはボイラプレートとして clients.claim() を使用しているようですが、私自身はめったに使用しません。 本当に重要になるのは最初の読み込み時のみであり、段階的な機能拡張により、ページは通常は Service Worker がなくても問題なく動作します。

Service Worker のアップデート

概要:

  • アップデートは、次の場合にトリガーされます。

    • スコープ内ページへのナビゲーション時
    • pushsync などの機能イベントの発生時(24 時間以内にアップデート チェックが実行された場合を除く)

    • .register() の呼び出し時(Service Worker URL が変更された場合のみ)

    • アップデートの取得時、Service Worker スクリプトのキャッシュ ヘッダーが(最大 24 時間)優先されます。 このオプトイン動作を行うのは、ユーザーを逃がさないためです。 Service Worker スクリプトでは、max-age は 0 になります。
  • 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 キャッシュを削除しています。

警告: 前のバージョンからアップデートしていない場合があります。その場合は、Service Worker の古いバージョンが多数存在する可能性があります。

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

警告: Cache Storage API は、(localStorage や IndexedDB のように)「オリジン ストレージ」です。 同じオリジンで多くのサイト(yourname.github.io/myapp など)を実行する場合は、他のサイトのキャッシュを削除しないように注意してください。

これを回避するには、myapp-static-v1 のようにキャッシュ名に現在のサイトに固有の接頭辞を付け、myapp- で始まらないキャッシュには触れないようにします。

待機段階のスキップ

待機段階とは、一度に 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 がフェッチ、インストール、アクティベートを行うと、牛のみが表示されます。

警告: skipWaiting() は、古いバージョンで読み込まれたページを新しい Service Worker で制御することを意味します。 つまり、ページのフェッチの一部は古い Service Worker で処理され、その後のフェッチは新しい Service Worker で処理されます。

これで問題になる可能性がある場合は、skipWaiting() を使用しないでください。

手動アップデート

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

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] を選択すると、すぐに「アクティブ」になります。

シフト再読み込み

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

アップデートの処理

Service Worker は、拡張可能ウェブの一部としてデザインされました。 我々ブラウザ デベロッパーは、ウェブ デベロッパーよりもウェブ開発が得意ではないと認識しています。

したがって、ブラウザ デベロッパーの好きなパターンを使用して特定の問題を解決する、限られた高レベルの 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 as skipped waiting and become
  // the new active worker. 
});

乗り切りました!

すばらしいですね!技術的な理論をたくさん説明しました。今後の数週間で上記の実用的なアプリケーションのいくつかに踏み込みますのでご期待ください。