オフライン クックブック

Service Worker により、オフラインでの解決をあきらめ、デベロッパーが自ら解決できるような移動手段を提供しました。これにより、キャッシュとリクエストの処理方法を制御できます。つまり、独自のパターンを作り出せるということです。考えられるパターンを個別に見ていきましょうが、実際には URL やコンテキストによっては、その多くは組み合わせて使用される可能性があります。

これらのパターンの実際のデモについては、Trained-to-thrill と、パフォーマンスへの影響を示すこちらの動画をご覧ください。

キャッシュ マシン - リソースを保存するタイミング

Service Worker では、リクエストをキャッシュから独立して処理できるため、これについて別々に説明します。まず、キャッシュはいつ行う必要がありますか。

インストール時 - 依存関係として

インストール時 - 依存関係として。
インストール時 - 依存関係として。

Service Worker が install イベントを生成します。これを使用して、他のイベントを処理する前に準備する必要があるものを準備することができます。その間、以前のバージョンの Service Worker はまだ実行されていてページを処理しているので、ここで行う処理によって中断が発生しないようにする必要があります。

最適な用途: CSS、画像、フォント、JS、テンプレートなど、基本的にはサイトのその「バージョン」に対して静的だと考えられるすべてのもの。

こうしたケースは、取得に失敗した場合にサイトが完全に機能しなくなる原因となり、プラットフォーム固有の同等のアプリが初回ダウンロードの一部になります。

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil は、Promise を受け取ってインストールの長さと成功を定義します。Promise が拒否された場合、インストールは失敗とみなされ、この Service Worker は破棄されます(古いバージョンが実行されている場合、そのまま残ります)。caches.open()cache.addAll() は Promise を返します。いずれかのリソースの取得に失敗した場合、cache.addAll() 呼び出しは拒否されます。

training-to-thrill ではこれを使用して静的アセットをキャッシュに保存します。

依存関係としてではなくインストール時

インストール時 - 依存関係としてではなく
インストール時 - 依存関係としてではなく

これは上記と似ていますが、インストールの完了が遅延することはなく、キャッシュ保存が失敗してもインストールが失敗することはありません。

最適な用途: ゲームの後半で使用するアセットなど、すぐには必要のないサイズの大きいリソース。

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

上記の例では、レベル 11 ~ 20 の cache.addAll Promise を event.waitUntil に戻していません。そのため、失敗してもゲームはオフラインでプレイできます。もちろん、これらのレベルがない可能性に対応し、不足している場合はキャッシュを再試行する必要があります。

Service Worker は、レベル 11 ~ 20 のダウンロード中にイベントの処理が完了し、キャッシュに保存されなくなるため、強制終了される可能性があります。将来的には、Web Periodic Background Synchronization API が、このようなケースや、より大きなダウンロード(映画など)を処理できるようにする予定です。現在、この API は Chromium フォークでのみサポートされています。

有効化時

有効化時。
有効化時。

最適なケース: クリーンアップと移行。

新しい Service Worker がインストールされ、以前のバージョンが使用されなくなると、新しい Service Worker が有効になり、activate イベントが発生します。古いバージョンは不要なため、IndexedDB のスキーマ移行を行い、未使用のキャッシュを削除することをおすすめします。

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

有効化の際、fetch などの他のイベントはキューに入れられるため、有効化が長いと、ページの読み込みがブロックされる可能性があります。有効化はできるだけシンプルにし、古いバージョンが有効であった間に実行できなかった作業に限って使用します。

training-to-thrill ではこれを使用して古いキャッシュを削除します。

ユーザー操作時

ユーザー操作時。
ユーザー操作時。

最適なケース: サイト全体をオフラインにできず、オフラインで使用可能にするコンテンツをユーザーが選択できるようにしている場合。たとえば、YouTube の動画、Wikipedia の記事、Flickr の特定のギャラリーなどです。

ユーザーに [後で読む] ボタンまたは [オフライン用に保存] ボタンを提供します。クリックしたら 必要な情報を ネットワークからフェッチしてキャッシュに格納します

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

caches API は、Service Worker のページだけでなくページからも使用できます。つまり、ページから直接キャッシュに追加できます。

ネットワーク応答時

ネットワーク応答時。
ネットワーク レスポンス時。

最適な用途: ユーザーの受信トレイ、記事のコンテンツなど、頻繁に更新されるリソース。アバターなどの重要でないコンテンツにも便利ですが、注意が必要です。

リクエストがキャッシュ内の何も一致しない場合、ネットワークからリクエストを取得してページに送信し、同時にキャッシュに追加します。

アバターなどの URL の範囲に対してこれを行う場合は、オリジンのストレージが肥大化しないように注意する必要があります。ユーザーがディスク容量を再利用する必要がある場合は、最有力候補にはしないでください。キャッシュ内の不要なアイテムは必ず削除してください。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

メモリを効率的に使用するために、レスポンスやリクエストの本文は 1 回だけ読み取ることができます。上記のコードでは、.clone() を使用して、個別に読み取り可能な追加のコピーを作成しています。

training-to-thrill ではこれを使用して Flickr 画像をキャッシュに保存します。

最新でない再検証

最新でない再検証。
Stale-while-revalidate。

最適なケース: 頻繁に更新されるが、最新バージョンである必要はないリソース。アバターはこのカテゴリに分類されます。

利用可能なキャッシュ バージョンがある場合はそれを使用します。ただし、次回の更新に備えて取得します。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

これは HTTP の stale-while-revalidate によく似ています。

push メッセージ時

push メッセージ時。
push メッセージ時。

Push API は、Service Worker 上に構築された別の機能です。これにより、OS のメッセージング サービスからのメッセージに応じて Service Worker を起動できます。これは、ユーザーがサイトでタブを開いていない場合でも行われます。起動されるのは Service Worker のみです。ページからこの操作を実行する権限をリクエストすると、ユーザーにこの操作を求めるメッセージが表示されます。

最適なケース: チャット メッセージ、ニュース速報、メールなど、通知に関連するコンテンツ。また、ToDo リストの更新やカレンダーの変更など、変更頻度の低いコンテンツでも即時の同期が役立ちます。

一般的な最終結果は通知で、タップすると関連ページが開くかフォーカスされますが、そのためにキャッシュを更新することが非常に重要になります。extremelyユーザーがプッシュ メッセージを受信した時点では明らかにオンラインですが、最終的に通知を操作した時点ではオンラインになっていない可能性があるため、このコンテンツをオフラインで利用できるようにすることが重要です。

次のコードは、通知を表示する前にキャッシュを更新します。

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

バックグラウンド同期時

バックグラウンド同期時。
バックグラウンド同期時。

バックグラウンド同期は、Service Worker 上に構築された別の機能です。これにより、バックグラウンドのデータ同期を 1 回限りまたは(非常にヒューリスティックな)間隔でリクエストできます。これは、ユーザーがサイトでタブを開いていない場合でも行われます。起動されるのは Service Worker のみです。ページからこれを行う権限をリクエストすると、ユーザーにメッセージが表示されます。

最適なケース: 緊急ではない更新。特に、ソーシャルのタイムラインやニュース記事など、更新ごとのプッシュ メッセージの頻度が高すぎるために定期的に発生する更新。

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

キャッシュの永続性

オリジンには、必要な処理を行うための一定量の空き容量が付与されます。この空き容量は、すべての送信元ストレージ((ローカル)ストレージIndexedDBファイル システム アクセス、そしてもちろんキャッシュ)で共有されます。

受け取れる金額は規定されていません。デバイスやストレージの状態によって異なります。以下により、どのくらいの量であるかを確認できます。

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

ただし、他のブラウザ ストレージと同様に、デバイスのストレージの負荷が高まった場合、ブラウザはデータを自由に破棄できます。残念なことにブラウザでは、残しておきたい映画と、デベロッパーがあまり重要でないゲームを見分けることができません。

この問題を回避するには、StorageManager インターフェースを使用します。

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

もちろん、ユーザーが権限を付与する必要があります。そのためには Permissions API を使用します。

ユーザーが削除を制御できるようになったため、ユーザーをこのフローに関与させることが重要です。デバイスのストレージの負荷が高くなり、重要でないデータを消去しても解決しない場合、ユーザーはどのアイテムを保持して削除するかを判断できます。

そのためには、オペレーティング システムがストレージ使用量の内訳において、ブラウザを 1 つのアイテムとしてレポートするのではなく、「永続性のある」オリジンをプラットフォーム固有のアプリと同等として扱う必要があります。

提案の提供—リクエストへの対応

キャッシュに保存する量に関係なく、Service Worker は、いつ、どのようにキャッシュするかを指定しない限り、キャッシュを使用しません。以下に、リクエストを処理するためのパターンをいくつか示します。

キャッシュのみ

キャッシュのみ。
キャッシュのみ。

最適な用途: サイトの特定の「バージョン」に対して静的と見なすもの。これらをインストール イベントでキャッシュに保存しておくと、それらが存在することを確認できます。

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

特にこのケースを扱う必要はほとんどありませんが、キャッシュ、ネットワークへのフォールバックで説明しています。

ネットワークのみ

ネットワークのみ。
ネットワークのみ。

最適な用途: 分析 ping や GET 以外のリクエストなど、オフラインに相当するものがないもの。

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

特にこのケースを扱う必要はほとんどありませんが、キャッシュ、ネットワークへのフォールバックで説明しています。

キャッシュ、ネットワークにフォールバック

キャッシュ、ネットワークにフォールバック
キャッシュ、ネットワークにフォールバック。

最適な用途: オフラインファーストの構築。そのような場合、ほとんどのリクエストがこの方法で処理されます。他のパターンは、受信リクエストに基づく例外となります。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

これにより、キャッシュ内にあるものについては「キャッシュのみ」の動作、キャッシュに保存されていないものについては「ネットワークのみ」の動作が適用されます(GET 以外のリクエストはキャッシュに保存できないため、すべて含まれます)。

キャッシュとネットワークの競合

キャッシュとネットワークの競合
キャッシュとネットワークの競合。

最適な用途: ディスク アクセスが低速なデバイスでパフォーマンスを追求する小規模なアセット。

古いハードドライブ、ウイルス スキャナ、高速インターネット接続の組み合わせによっては、ディスクにアクセスするよりもネットワークからリソースを取得する方が時間を短縮できます。ただし、ユーザーのデバイスにコンテンツがあるときにネットワークにアクセスすると、データの無駄になる可能性があります。その点に留意してください。

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

ネットワーク キャッシュへのフォールバック

ネットワークがキャッシュにフォールバックしています。
キャッシュへのフォールバック ネットワーク

最適なケース: サイトの「バージョン」外で頻繁に更新されるリソースのクイック フィックス。(記事、アバター、ソーシャル メディアのタイムライン、ゲームのリーダーボードなど)。

つまり、オンライン ユーザーには最新のコンテンツを提供し、オフライン ユーザーにはキャッシュに保存されている古いバージョンを表示するということです。ネットワーク リクエストが成功した場合は、キャッシュ エントリの更新が必要になることがあります。

ただし、この方法には欠点があります。ユーザーの接続が断続的または遅い場合は、ネットワーク障害が発生するのを待ってから、すでにデバイス上にある許容されるコンテンツを取得する必要があります。この作業には非常に長い時間がかかり、ユーザー エクスペリエンスの低下につながります。より適切な解決策については、次のキャッシュの次にネットワークをご覧ください。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

キャッシュ、次にネットワーク

キャッシュ、次にネットワーク。
キャッシュ、次にネットワーク。

最適なケース: 頻繁に更新されるコンテンツ。(記事、ソーシャル メディアのタイムライン、ゲームなど)。

この場合、ページにはキャッシュに対して 1 回、ネットワークに対して 1 回、合計 2 回のリクエストが行われます。まずキャッシュ データを表示し、ネットワーク データが届いたらページを更新します。

新しいデータが届いたら現在のデータを置き換えるだけで済む場合もありますが(ゲームのリーダーボードなど)、コンテンツが大きいと混乱を招く可能性があります。ユーザーが読んだり操作したりする可能性があるものを「消失」させないでください。

Twitter は、古いコンテンツの上に新しいコンテンツを追加し、ユーザーの邪魔にならないようにスクロール位置を調整します。これが可能なのは、Twitter がコンテンツに対してほぼ直線的な順序を保持することがほとんどにあるからです。私は、このパターンを training-to-thrill にコピーして、コンテンツをできるだけ早く画面に表示し、最新のコンテンツが届くとすぐに表示できるようにしました。

ページのコード:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Service Worker のコード:

その際、必ずネットワークにアクセスしてキャッシュを更新するようにしてください。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

training-to-thrill ではこの問題を回避するために、fetch の代わりに XHR を使用し、Accept ヘッダーを悪用して結果の取得元を Service Worker に指示しました(ページコードService Worker コード)。

一般的なフォールバック

汎用的なフォールバック。
一般的なフォールバック。

キャッシュやネットワークから何かを提供できない場合は、汎用のフォールバックを提供できます。

最適な用途: アバター、失敗した POST リクエスト、「オフライン時は使用できません」ページなどの二次画像。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

フォールバックするアイテムは、インストールの依存関係である可能性が高くなります。

ページがメールを送信している場合、Service Worker はフォールバックして IndexedDB の「送信トレイ」にメールを保存し、送信に失敗したがデータが正常に保持されたことをページに通知します。

Service Worker 側のテンプレート

Service Worker 側のテンプレート。
ServiceWorker 側のテンプレート

最適なケース: サーバー レスポンスをキャッシュに保存できないページ。

サーバー上でページをレンダリングすると処理が速くなりますが、これは意味をなさない状態データをキャッシュに含めることにもなりかねません(「ログイン名...」など)。ページが Service Worker によって制御されている場合は、JSON データとテンプレートをリクエストしてレンダリングすることもできます。

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

まとめ

この方法以外の方法も使用できます。リクエスト URL によっては、これらの多くはよく使用されます。たとえば、training-to-thrill では以下を使用します。

リクエストを確認して、どのように対応すべきかを判断してください。

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

...わかります。

クレジット

愛らしいアイコンについて:

公開する前に多数のエラーをキャッチしてくれた Jeff Posnick にも感謝します。

関連情報