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
は、インストールの長さと成功を定義するプロミスを受け取ります。プロミスが拒否された場合、インストールは失敗と見なされ、この Service Worker は破棄されます(古いバージョンが実行されている場合は、そのまま残ります)。caches.open()
と cache.addAll()
は Promise を返します。いずれかのリソースの取得に失敗すると、cache.addAll()
呼び出しは拒否されます。
trained-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
に返さないため、失敗してもゲームはオフラインで引き続き利用できます。もちろん、これらのレベルが存在しない可能性を考慮し、存在しない場合はキャッシュに保存し直す必要があります。
レベル 11 ~ 20 のダウンロード中に、Service Worker がイベントの処理を完了したため終了することがあります。この場合、レベルはキャッシュに保存されません。今後、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
などの他のイベントがキューに追加されるため、有効化に時間がかかるとページの読み込みがブロックされる可能性があります。有効化はできる限りシンプルに保ち、古いバージョンが有効なときにできなかったことに対してのみ使用します。
trained-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()
を使用して、個別に読み取ることができる追加のコピーを作成しています。
trained-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 API は、Service Worker 上に構築されたもう 1 つの機能です。これにより、OS のメッセージ サービスからのメッセージに応答して Service Worker を起動できます。これは、ユーザーがサイトのタブを開いていない場合でも発生します。サービス ワーカーのみが起動されます。ページからこの操作を行う権限をリクエストすると、ユーザーにプロンプトが表示されます。
適しているコンテンツ: 通知に関連するコンテンツ(チャット メッセージ、ニュース速報、メールなど)。また、ToDo リストの更新やカレンダーの変更など、即時同期が有効な頻繁に変更されないコンテンツも対象です。
一般的な最終的な結果は、タップすると関連するページが開く/フォーカスされる通知ですが、この前にキャッシュを更新することは非常に重要です。プッシュ メッセージを受信した時点ではユーザーはオンラインですが、通知を操作する時点ではオンラインでない可能性があります。そのため、このコンテンツをオフラインで利用できるようにすることが重要です。
このコードは、通知を表示する前にキャッシュを更新します。
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 回限り、または(非常にヒューリスティックの)間隔でリクエストできます。これは、ユーザーがサイトのタブを開いていない場合でも発生します。サービス ワーカーのみが起動されます。ページからこの操作を行う権限をリクエストすると、ユーザーにプロンプトが表示されます。
適している場合: 緊急性のない更新、特にソーシャル タイムラインやニュース記事など、更新ごとにプッシュ メッセージを送信するとユーザーにとって頻度が高すぎる更新。
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、ファイル システム アクセス、そしてもちろんキャッシュなど、すべてのオリジン ストレージ間で共有されます。
受け取る金額は指定されていません。デバイスと保管状況によって異なります。残高を確認するには、以下の方法があります。
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> Number of bytes used.
// quota.quota -> Maximum number of bytes available.
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`You've used ${percentageUsed}% of the available storage.`);
const remaining = quota.quota - quota.usage;
console.log(`You can write up to ${remaining} more 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 を使用します。
ユーザーがこのフローに参加することは重要です。これにより、ユーザーが削除を管理できるようになるからです。デバイスのストレージ容量が不足し、不要なデータを削除しても問題が解決しない場合は、ユーザーがどのアイテムを保持して削除するかを判断できます。
これを実現するには、オペレーティング システムが、ブラウザを単一のアイテムとして報告するのではなく、ストレージ使用量の内訳で「永続的」なオリジンをプラットフォーム固有のアプリと同等に扱う必要があります。
サービング リクエスト - リクエストへの対応
キャッシュをどれだけ保存しても、いつ、どのようにキャッシュを使用するかを指定しない限り、サービス ワーカーはキャッシュを使用しません。リクエストを処理するパターンは次のとおりです。
キャッシュのみ

適している場合: サイトの特定の「バージョン」に対して静的と見なされるもの。インストール イベントでこれらの値をキャッシュに保存しておけば、キャッシュに保存されている値に依存できます。
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);
}),
);
});
キャッシュ、ネットワーク

適しているコンテンツ: 頻繁に更新されるコンテンツ。例: 記事、ソーシャル メディアのタイムライン、ゲームのリーダーボード。
これにより、ページはキャッシュとネットワークの 2 つのリクエストを送信する必要があります。キャッシュに保存されたデータを最初に表示し、ネットワーク データが届いたときにページを更新します。
新しいデータが届いたときに現在のデータを置き換えるだけで済む場合もありますが(ゲームのリーダーボードなど)、大規模なコンテンツの場合は中断が発生する可能性があります。基本的に、ユーザーが読んでいるものや操作しているものを「消えない」ようにします。
Twitter は、新しいコンテンツを古いコンテンツの上に追加し、スクロール位置を調整して、ユーザーの操作を中断しないようにします。これは、Twitter ではコンテンツの順序がほぼリニアであるため可能です。最新のコンテンツが届き次第表示し、コンテンツをできるだけ早く画面に表示するために、このパターンをスリル満点のコンテンツにコピーしました。
ページのコード:
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;
});
}),
);
});
trained-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.
}),
);
});
フォールバックするアイテムは、インストール依存関係である可能性があります。
ページがメールを送信している場合、サービス ワーカーは IndexedDB の「送信トレイ」にメールを保存し、送信に失敗したがデータは正常に保持されたことをページに通知することで応答することがあります。
サービス ワーカーサイド テンプレート

最適な用途: サーバー レスポンスをキャッシュに保存できないページ。
サーバー上でページをレンダリングすると処理が高速化されますが、その場合、キャッシュに意味のない状態データ(「ログイン済みユーザー」など)が含まれる可能性があります。ページが 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 に応じて多くのメソッドを使用することになります。たとえば、スリルを求めては次を使用します。
- インストール時にキャッシュに保存: 静的 UI と動作
- ネットワーク レスポンスのキャッシュ(Flickr の画像とデータ用)
- ほとんどのリクエストでキャッシュから取得し、ネットワークにフォールバック
- Flickr の検索結果をキャッシュから取得し、ネットワークから取得する
リクエストを見て、次のように対応します。
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);
}),
);
});
といった具合です。
クレジット
以下のアイコンが使用されます。
- コード: buzzyrobot
- カレンダー(Scott Lewis)
- Network by Ben Rizzo
- SD(Thomas Le Bas 作)
- CPU(iconsmind.com)
- ゴミ箱(trasnik 作成)
- @daosme による通知
- Layout by Mister Pixel
- Cloud(P.J. Onori 著)
また、Jeff Posnick には、私が「公開」ボタンを押す前に多くのエラーを検出していただきました。
関連情報
- サービス ワーカー - 概要
- Service Worker は準備ができていますか? - 主要なブラウザでの実装ステータスを追跡する
- JavaScript Promises - 概要 - Promise のガイド