オフラインの代替ページを作成する

Google アシスタント アプリ、Slack アプリ、Zoom アプリ、スマートフォンやパソコンの他のほぼすべてのプラットフォーム固有のアプリに共通するものは何ですか。そうですね、少なくとも何かを与えてくれるゲームです。 ネットワークに接続されていなくても、アシスタント アプリを開く、Slack に入る、Zoom を起動できます。特に意味のあることは得られなかったり、目的を達成できなかったりするかもしれませんが、少なくとも何かを得ることができ、アプリが制御できるのです。

オフライン時の Google アシスタント モバイルアプリ。
Google アシスタント。

オフライン時の Slack モバイルアプリ。
Slack。

オフラインでもモバイルアプリをズームできます。
ズーム。

プラットフォーム固有のアプリでは、ネットワークに接続されていなくても、何も通知されません。

一方、ウェブの場合、通常、オフラインであれば何も得られません。Chrome にはオフライン恐竜ゲームが用意されていますが、それだけです。

オフラインの恐竜ゲームを表示している Google Chrome モバイルアプリ。
iOS 向け Google Chrome

オフラインの恐竜ゲームを表示している Google Chrome デスクトップ アプリ。
macOS 版 Google Chrome

ウェブの場合、ネットワーク接続がないと、デフォルトでは何も表示されません。

カスタム Service Worker を含むオフライン フォールバック ページ

ただし、必ずしもこのようにする必要はありません。Service Worker と Cache Storage API を使用すると、カスタマイズされたオフライン エクスペリエンスをユーザーに提供できます。ユーザーが現在オフラインであるという情報を含むシンプルなブランドページでも、手動の [再接続] ボタンと自動再接続のカウントダウンを備えた有名な trivago オフライン迷路ゲームのように、よりクリエイティブなソリューションにすることもできます。

trivago のオフライン 迷路が表示された trivago のオフライン ページ。
trivago のオフライン迷路。

Service Worker の登録

これを行うには、Service Worker を使用します。以下のコードサンプルのように、メインページから Service Worker を登録できます。通常はアプリが読み込まれた後に 行います

window.addEventListener("load", () => {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("service-worker.js");
  }
});

Service Worker のコード

実際の Service Worker ファイルの内容は、一見すると少し複雑に見えるかもしれませんが、以下のサンプルのコメントを見るとわかりやすくなっています。その中心となる考え方は、失敗したナビゲーション リクエストでのみ提供される offline.html という名前のファイルを事前キャッシュし、他のすべてのケースはブラウザで処理できるようにすることです。

/*
Copyright 2015, 2019, 2020, 2021 Google LLC. All Rights Reserved.
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 http://www.apache.org/licenses/LICENSE-2.0
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
*/

// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// Add a comment for your linter if you want:
// eslint-disable-next-line no-unused-vars
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "offline.html";

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      // Setting {cache: 'reload'} in the new request ensures that the
      // response isn't fulfilled from the HTTP cache; i.e., it will be
      // from the network.
      await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
    })()
  );
  // Force the waiting service worker to become the active service worker.
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Enable navigation preload if it's supported.
      // See https://developers.google.com/web/updates/2017/02/navigation-preload
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
      }
    })()
  );

  // Tell the active service worker to take control of the page immediately.
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {
  // Only call event.respondWith() if this is a navigation request
  // for an HTML page.
  if (event.request.mode === "navigate") {
    event.respondWith(
      (async () => {
        try {
          // First, try to use the navigation preload response if it's
          // supported.
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) {
            return preloadResponse;
          }

          // Always try the network first.
          const networkResponse = await fetch(event.request);
          return networkResponse;
        } catch (error) {
          // catch is only triggered if an exception is thrown, which is
          // likely due to a network error.
          // If fetch() returns a valid HTTP response with a response code in
          // the 4xx or 5xx range, the catch() will NOT be called.
          console.log("Fetch failed; returning offline page instead.", error);

          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(OFFLINE_URL);
          return cachedResponse;
        }
      })()
    );
  }

  // If our if() condition is false, then this fetch handler won't
  // intercept the request. If there are any other fetch handlers
  // registered, they will get a chance to call event.respondWith().
  // If no fetch handlers call event.respondWith(), the request
  // will be handled by the browser as if there were no service
  // worker involvement.
});

オフライン フォールバック ページ

offline.html ファイルを使用すると、創造力を発揮してニーズに合わせて調整し、ブランディングを追加できます。以下の例は、可能な限り最低限の機能を示しています。ボタンの押下に基づく手動再読み込みと、online イベントに基づく自動再読み込み、および通常のサーバー ポーリングの両方を示しています。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <title>You are offline</title>

    <!-- Inline the page's stylesheet. -->
    <style>
      body {
        font-family: helvetica, arial, sans-serif;
        margin: 2em;
      }

      h1 {
        font-style: italic;
        color: #373fff;
      }

      p {
        margin-block: 1rem;
      }

      button {
        display: block;
      }
    </style>
  </head>
  <body>
    <h1>You are offline</h1>

    <p>Click the button below to try reloading.</p>
    <button type="button">⤾ Reload</button>

    <!-- Inline the page's JavaScript file. -->
    <script>
      // Manual reload feature.
      document.querySelector("button").addEventListener("click", () => {
        window.location.reload();
      });

      // Listen to changes in the network state, reload when online.
      // This handles the case when the device is completely offline.
      window.addEventListener('online', () => {
        window.location.reload();
      });

      // Check if the server is responding and reload the page if it is.
      // This handles the case when the device is online, but the server
      // is offline or misbehaving.
      async function checkNetworkAndReload() {
        try {
          const response = await fetch('.');
          // Verify we get a valid response from the server
          if (response.status >= 200 && response.status < 500) {
            window.location.reload();
            return;
          }
        } catch {
          // Unable to connect to the server, ignore.
        }
        window.setTimeout(checkNetworkAndReload, 2500);
      }

      checkNetworkAndReload();
    </script>
  </body>
</html>

デモ

以下の埋め込みデモで、実際のオフライン フォールバック ページを確認できます。興味があれば、Glitch のソースコードをご覧ください。

アプリをインストール可能にするための注意事項

サイトにオフラインの代替ページが追加されたので、次に行うべきことを知りたい方もいらっしゃるでしょう。アプリをインストール可能にするには、ウェブアプリ マニフェストを追加し、必要に応じてインストール戦略を用意する必要があります。

Workbox.js を使用してオフライン フォールバック ページを提供する際の注意事項

Workbox」をご存じの方もいらっしゃるでしょう。 Workbox は、ウェブアプリにオフライン サポートを追加するための JavaScript ライブラリのセットです。Service Worker のコードを自分で記述する場合は、オフライン ページのみに対して Workbox レシピを使用できます。

次は、アプリのインストール戦略を定義する方法について学びます。