オフライン アプリケーションを構築する一般的な手法

Service Worker を使用すると、デベロッパーはネットワーク接続の問題を解決できます。キャッシュ保存とリクエストの処理方法を制御できます。つまり、独自のパターンを作成できます。いくつかのパターンを個別に見てみましょう。ただし、実際には、URL とコンテキストに応じて、これらのパターンを組み合わせて使用することになるでしょう。

これらのパターンの動作デモについては、Trained-to-thrill をご覧ください。

リソースを保存するタイミング

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

サービス ワーカーを使用すると、キャッシュ保存とは別にリクエストを処理できるため、ここではサービス ワーカーについて個別に説明します。まず、キャッシュを使用するタイミングを判断します。

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

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

Service Worker API は install イベントを提供します。これを使用して、他のイベントを処理する前に準備しておく必要のあるものを準備できます。install 中は、サービス ワーカーの以前のバージョンが引き続き実行され、ページが提供されます。この時点で実行する処理は、既存のサービス ワーカーを中断しないようにする必要があります。

最適な用途: 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 プロミスを event.waitUntil に渡していないため、失敗した場合でも、ゲームはオフラインで利用できます。もちろん、これらのレベルが存在しない可能性を考慮し、欠落している場合はキャッシュ保存を再試行する必要があります。

レベル 11 ~ 20 のダウンロード中に、サービス ワーカーがイベントの処理を終了したため、強制終了されることがあります。つまり、キャッシュに保存されません。Web Periodic Background Synchronization API は、このようなケースや、映画などの大きなダウンロードを処理できます。

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

有効化時

有効化時。

最適な用途: クリーンアップと移行。

新しいサービス ワーカーがインストールされ、以前のバージョンが使用されなくなると、新しいサービス ワーカーが有効になり、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);
      });
  });
});

Cache API はページと Service Worker から利用できます。つまり、ページから直接キャッシュに追加できます。

Browser Support

  • Chrome: 40.
  • Edge: 16.
  • Firefox: 41.
  • Safari: 11.1.

Source

ネットワーク レスポンス

ネットワーク レスポンス。

最適な用途: ユーザーの受信トレイや記事のコンテンツなど、リソースを頻繁に更新する場合。アバターなどの重要でないコンテンツにも役立ちますが、注意が必要です。

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

アバターなど、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。

最適な用途: 最新バージョンが必須ではないリソースを頻繁に更新する場合。アバターはこのカテゴリに該当する可能性があります。

キャッシュに保存されたバージョンがある場合は、それを使用しますが、次回のためにアップデートを取得します。

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 は、サービス ワーカーの上に構築された別の機能です。これにより、OS のメッセージング サービスからのメッセージに応答して、サービス ワーカーを起動できます。これは、ユーザーがサイトのタブを開いていない場合でも発生します。サービス ワーカーのみが起動します。ページからこの操作を行う権限をリクエストすると、ユーザーにプロンプトが表示されます。

理想的な用途: チャット メッセージ、速報ニュース、メールなど、通知に関連するコンテンツ。また、同期をすぐに反映させることでメリットが得られる、変更頻度の低いコンテンツ(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/');
  }
});

onbackground-sync

On background-sync.

バックグラウンド同期も、サービス ワーカーの上に構築された機能です。バックグラウンドでのデータ同期を 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 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 rejects when a promise rejects before fulfilling.
// To make a 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 つずつ、合計 2 つのリクエストを行う必要があります。このアイデアは、最初にキャッシュに保存されたデータを表示し、ネットワーク データが届いたときにページを更新するというものです。

新しいデータが届いたときに現在のデータを置き換えるだけで済む場合もあります(ゲームのリーダーボードなど)。ただし、コンテンツのサイズが大きい場合は、中断が発生する可能性があります。基本的には、ユーザーが読んだり操作したりしているものを「消さない」ようにします。

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);

サービス ワーカーのコード:

常にネットワークにアクセスし、キャッシュを更新する必要があります。

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 のアウトボックスにメールを保存し、送信は失敗したがデータは正常に保持されたことをページに伝えることで応答する可能性があります。

サービス ワーカー側のテンプレート

サービス ワーカー側のテンプレート。

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

サーバーでページをレンダリングする方が高速ですが、ログイン状態など、キャッシュに保存しても意味がない状態データが含まれる可能性があります。ページがサービス ワーカーによって制御されている場合は、テンプレートとともに 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',
        },
      });
    }),
  );
});

まとめる

これらの方法のいずれか 1 つに限定されることはありません。実際には、リクエスト 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 にも感謝します。