Service Worker のマインドセット

Service Worker について考える際の考え方。

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

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

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

同じ、でも違う

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

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

新しいレイヤ

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

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

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

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

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

強力だが制限がある

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

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

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

  • localStorage
  • DOM
  • ウィンドウ

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

長寿だが寿命が短い

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

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

停止

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

waitUntil

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

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

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

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

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

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

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

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

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

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

Service Worker の第 3 章では、停止した Service Worker が、ウェイクアップを待機している間、すべての色を失うように可視化しています。

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

一緒にいても別々に

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

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

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

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

skipWaiting をスキップ

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

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

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

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

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

以前の Service Worker の後にクリーンアップする必要があるのは、新しい Service Worker に不要なキャッシュを削除することだけです。

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 で遊んで、オフラインの獣を倒すための Service Worker の使い方を学びましょう。