オフライン クックブック

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() 呼び出しは拒否されます。

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

Stale-while-revalidate。
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 をベースに構築された別の機能です。これにより、OS のメッセージング サービスからのメッセージに応じて Service Worker を起動できます。このとき、ユーザーがサイトのタブを開いていなくても、起動されるのは 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 回限りまたは(非常にヒューリスティックな)間隔でリクエストできます。このとき、ユーザーがサイトのタブを開いていなくても、起動されるのは 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ファイル システム アクセス、そしてもちろんキャッシュなど、すべてのオリジン ストレージ間で共有されます。

受け取る金額は指定されていません。デバイスとストレージの状態によって異なります。残り容量を確認するには、次のコードを使用します。

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 を使用します。

ユーザーをこのフローに組み込むことが重要です。そうすることで、削除の可否をユーザーに委ねることができます。端末の容量が残り少なくなったとき、不要なデータを削除しても容量不足が解決しない場合は、どのアイテムを残すかまたは削除するかをユーザーが決定します。

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

提案の提供 - リクエストへの応答

どれだけキャッシュを行っても、それらのキャッシュをいつどのように使用するかを Service Worker に指示しないと、キャッシュは使用されません。リクエスト処理に関するパターンをいくつか示します。

キャッシュのみ

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

最適なケース: サイトの特定の「バージョン」に対して静的と見なしうるすべてのもの。これらは install イベントでキャッシュされているはずなので、既にあるものとして当てにできます。

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 つのリクエスト(キャッシュとネットワークに対してそれぞれ 1 つずつ)を生成する必要があります。つまり、まずはキャッシュされたデータを表示し、その後ネットワークから取得できたらページを更新します。

新しいデータを取得したら現在のデータを置き換えられる場合もありますが(ゲームのリーダーボードなど)、コンテンツの大きい部分の置き換えは混乱を招くことがあります。基本的に、ユーザーが視聴中または操作中のデータが「消失」してしまわないようにしてください。

Twitter は古いコンテンツの上に新しいコンテンツを追加してスクロール位置を調整するので、ユーザーの邪魔をすることがありません。これが可能なのは、Twitter のコンテンツでは、ほぼリニアな順序が維持されているためです。trained-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;
     
});
   
}),
 
);
});

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 側のテンプレート

ServiceWorker 側のテンプレート。
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 に応じて複数の方法を使用することになります。たとえば、trained-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 にも感謝します。

関連情報