Service Worker のマインドセット

サービス ワーカーについて考える際の考え方。

サービス ワーカーは強力で、学ぶ価値が十分にあります。ユーザーにまったく新しいレベルのエクスペリエンスを提供できます。サイトは瞬時に読み込まれます。オフラインで動作できます。プラットフォーム固有のアプリとしてインストールでき、洗練された操作感を実現できます。また、ウェブのリーチと自由度も備えています。

しかし、サービス ワーカーは、ほとんどのウェブデベロッパーが慣れ親しんでいるものとは異なります。習熟に時間がかかるうえ、注意すべき問題がいくつかあります。

Google Developers と私は最近、サービス ワーカーを理解するための無料ゲームである Service Workies というプロジェクトを共同で立ち上げました。作成中に、サービス ワーカーの複雑な仕組みにいくつか問題が発生しました。最も役立ったのは、いくつかの描写的なメタファーを思いついたことです。この記事では、これらのメンタルモデルを探り、サービス ワーカーを複雑かつ優れたものにする矛盾した特性について説明します。

同じ、でも違う

サービス ワーカーをコーディングする際には、多くの部分が既知のものに感じられます。新しい JavaScript 言語機能を使用できます。ライフサイクル イベントは、UI イベントと同様にリッスンします。制御フローは、これまでどおり Promise を使用して管理します。

しかし、他のサービス ワーカーの動作は、混乱を招くものもあります。特に、ページを更新してもコードの変更が適用されない場合は、

新しいレイヤ

通常、サイトを構築する際に考慮すべきレイヤは、クライアントとサーバーのみです。サービス ワーカーは、その間に位置するまったく新しいレイヤです。

サービス ワーカーは、クライアントとサーバー間の中間レイヤとして機能します。

Service Worker は、サイトがユーザーのブラウザにインストールできる一種のブラウザ拡張機能と考えることができます。インストールされると、サービス ワーカーは強力なミドルレイヤでサイトのブラウザを拡張します。この Service Worker レイヤは、サイトが行うすべてのリクエストをインターセプトして処理できます。

サービス ワーカー レイヤには、ブラウザのタブとは独立した独自のライフサイクルがあります。ページを更新するだけでは、サービス ワーカーを更新できません。これは、ページの更新でサーバーにデプロイされたコードが更新されることを期待しないのと同じです。各レイヤには、更新に関する独自のルールがあります。

Service Workies ゲームでは、サービス ワーカーのライフサイクルの詳細を学び、サービス ワーカーの操作をたくさん練習できます。

強力だが制限がある

サイトに Service Worker を導入すると、非常に大きなメリットが得られます。サイトは次のことを行えます。

  • ユーザーがオフラインの場合でも問題なく動作する
  • キャッシュ保存によってパフォーマンスを大幅に向上させる
  • プッシュ通知を使用する
  • PWA としてインストールされている

サービス ワーカーは、設計上制限されています。サイトと同じスレッドで同期的に動作することはできません。つまり、以下のサービスにはアクセスできません。

  • localStorage
  • DOM
  • ウィンドウ

ページがサービス ワーカーと通信する方法はいくつかあります。直接の postMessage、1 対 1 のメッセージ チャネル、1 対多のブロードキャスト チャネルなどです。

長寿だが寿命が短い

アクティブなサービス ワーカーは、ユーザーがサイトから離れたりタブを閉じたりしても存続します。ブラウザは、このサービス ワーカーを保持しておき、ユーザーが次回サイトにアクセスしたときにすぐに使用できるようにします。最初のリクエストが送信される前に、Service Worker がリクエストをインターセプトしてページを制御できます。これにより、サイトをオフラインで使用できるようになります。ユーザーがインターネットに接続していなくても、Service Worker はページ自体のキャッシュ バージョンを提供できます。

Service Workies では、Kolohe(フレンドリーな Service Worker)がリクエストをインターセプトして処理するこのコンセプトを可視化しています。

停止

Service Worker は永続的に動作するように見えますが、ほとんどいつでも停止できます。ブラウザは、現在何も行っていない Service Worker にリソースを浪費したくありません。停止は終了とは異なります。サービス ワーカーはインストールされ、アクティブなままです。スリープ状態になっているだけです。次回(リクエストを処理する場合など)必要になったときに、ブラウザによって再び起動されます。

waitUntil

サービス ワーカーは常にスリープ状態になる可能性があるため、重要な処理を行っていて、休憩をとるつもりがないことをブラウザに知らせる方法が必要です。ここで event.waitUntil() の出番です。このメソッドは、使用されているライフサイクルを延長し、準備が整うまで停止やライフサイクルの次のフェーズへの移行を防ぎます。これにより、キャッシュの設定やネットワークからのリソースの取得などの作業を行うことができます。

この例では、assets キャッシュが作成され、剣の画像が入力されるまで、サービス ワーカーのインストールが完了していないことをブラウザに伝えます。

self.addEventListener("install", event => {
  event.waitUntil(
    caches.open("assets").then(cache => {
      return cache.addAll(["/weapons/sword/blade.png"]);
    })
  );
});

グローバル状態に注意する

この開始/停止が発生すると、サービス ワーカーのグローバル スコープがリセットされます。そのため、サービス ワーカーでグローバル状態を使用しないように注意してください。次にサービス ワーカーが起動したときに、想定していた状態と異なる状態になっていると困ります。

グローバル状態を使用する次の例について考えてみましょう。

const favoriteNumber = Math.random();
let hasHandledARequest = false;

self.addEventListener("fetch", event => {
  console.log(favoriteNumber);
  console.log(hasHandledARequest);
  hasHandledARequest = true;
});

このサービス ワーカーは、リクエストごとに数値(0.13981866382421893 など)をログに記録します。hasHandledARequest 変数も true に変更されます。サービス ワーカーがしばらくアイドル状態になったため、ブラウザはそれを停止します。次回リクエストが発生したときに、サービス ワーカーが再度必要になるため、ブラウザがサービス ワーカーを起動します。スクリプトが再評価されます。これで、hasHandledARequestfalse にリセットされ、favoriteNumber はまったく別の値(0.5907281835659033)になります。

サービス ワーカーで保存された状態に依存することはできません。また、メッセージ チャネルなどのインスタンスを作成するとバグが発生する可能性があります。サービス ワーカーが停止または起動するたびに、新しいインスタンスが作成されるためです。

Service Workies の第 3 章では、停止したサービス ワーカーが、ウェイクアップを待っている間、すべての色を失うように可視化しています。

停止したサービス ワーカーの可視化

一緒にいても別々に

ページを制御できるのは、一度に 1 つの Service Worker のみです。ただし、2 つのサービス ワーカーを同時にインストールすることは可能です。サービス ワーカーのコードに変更を加えてページを更新しても、実際にはサービス ワーカーは編集されません。Service Worker はimmutableです。代わりに、新しいアカウントを作成します。この新しいサービス ワーカー(SW2 とします)はインストールされますが、まだ有効化されません。現在のサービス ワーカー(SW1)が終了するのを待機する必要があります(ユーザーがサイトを離れたとき)。

別のサービス ワーカーのキャッシュを操作する

インストール中に、SW2 は設定を取得できます。通常は、キャッシュの作成と入力を行います。ただし、この新しいサービス ワーカーは、現在のサービス ワーカーがアクセスできるすべてのものにアクセスできます。注意しないと、待機中の新しいサービス ワーカーが現在のサービス ワーカーを混乱させる可能性があります。問題が発生する可能性がある例を以下に示します。

  • SW2 は、SW1 がアクティブに使用しているキャッシュを削除する可能性があります。
  • SW2 が SW1 が使用しているキャッシュの内容を編集すると、SW1 がページが想定していないアセットで応答する可能性があります。

skipWaiting をスキップ

Service Worker は、危険な skipWaiting() メソッドを使用して、インストールが完了するとすぐにページを制御することもできます。バグのあるサービス ワーカーを意図的に置き換える場合を除き、これは通常おすすめしません。新しいサービス ワーカーが、現在のページが想定していない更新されたリソースを使用している場合、エラーやバグが発生する可能性があります。

クリーンな状態から始める

サービス ワーカー同士が干渉しないようにするには、異なるキャッシュを使用するようにします。これを実現する最も簡単な方法は、使用するキャッシュ名にバージョンを付けることです。

const version = 1;
const assetCacheName = `assets-${version}`;

self.addEventListener("install", event => {
  caches.open(assetCacheName).then(cache => {
    // confidently do stuff with your very own cache
  });
});

新しい Service Worker をデプロイするときに、version を増やして、以前の Service Worker とは完全に別のキャッシュで必要な処理を行うようにします。

キャッシュの可視化

きれいに終了する

サービス ワーカーが activated 状態に達すると、そのサービス ワーカーが引き継がれ、以前のサービス ワーカーは不要になります。この時点で、古いサービス ワーカーのクリーンアップを行うことが重要です。これにより、ユーザーのキャッシュ ストレージの上限を尊重するだけでなく、意図しないバグを防ぐこともできます。

caches.match() メソッドは、一致する任意のキャッシュからアイテムを取得するためによく使用されるショートカットです。ただし、キャッシュは作成された順序で反復処理されます。たとえば、2 つの異なるキャッシュ(assets-1assets-2)に、スクリプト ファイル app.js の 2 つのバージョンがあるとします。ページは、assets-2 に保存されている新しいスクリプトを想定しています。ただし、古いキャッシュを削除していない場合、caches.match('app.js')assets-1 から古いキャッシュを返すため、サイトが破損する可能性があります。

以前のサービス ワーカーのクリーンアップに必要なのは、新しいサービス ワーカーに不要なキャッシュを削除することだけです。

const version = 2;
const assetCacheName = `assets-${version}`;

self.addEventListener("activate", event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName !== assetCacheName){
            return caches.delete(cacheName);
          }
        });
      );
    });
  );
});

Service Worker 同士が互いに干渉しないようにするには、少しの作業と規律が必要ですが、その労力に見合う価値があります。

Service Worker のマインドセット

サービス ワーカーについて考える際に、適切な考え方を身に付けておくと、自信を持ってサービス ワーカーを構築できます。使い方をマスターすれば、ユーザーに素晴らしいエクスペリエンスを提供できるようになります。

ゲームをプレイして、これらのことをすべて理解したい場合は、ぜひお試しください。Service Workies をプレイして、オフラインの獣を倒すためのサービス ワーカーの方法を学びましょう。