💻

PWAマスター講座 第3部:実践開発

に公開

皆さん、PWA開発シリーズの最終記事へようこそ!

前回までの記事からしばらく時間が経ちましたので、簡単に内容をおさらいしておきましょう。

第1回では、PWAの概念と、その特徴について学びました。また、サードパーティのライブラリを使わずに基本的なService Workerを作成し、リソースのキャッシュについて理解し、便利な開発者ツールを使いこなせるようになりました。

第2回では、Workboxを活用することで、開発プロセスが大幅に簡素化されることを体験しました。この記事はWorkboxのリファレンス的な内容になっているため、通して読むのは少し退屈かもしれません。しかし、実際にコードを書く際には非常に役立つはずです。この記事でも、適宜参照していきます。

最終回となる今回は、いよいよ実践編です。PWAのパターンを実装する際の主要なポイントを解説し、ベストプラクティスや開発中に発生しうる注意点についてもお話しします。興味のある章を自由に選んで読み進めていただいて構いません。

まずは皆さんの関心を高めるために、私がこのコースのために特別に開発したPWAのサンプルをご覧ください。この記事を読み終える頃には、このサンプルアプリに実装されているPWAの機能を、ほぼ全てご自身で実装できるようになっているはずです。それでは始めましょう!

インストール可能にする

要件

まず、最も基本的な実践的課題、つまり、Webアプリケーションをデバイスにインストールできるようにする方法について説明します。インストールを可能にするには、アプリケーションが次の要件を満たしている必要があります。

  1. アプリケーションに関する情報をjson形式で含むマニフェストファイル(.webmanifest)
  2. アプリケーションには、少なくとも1つの互換性のあるアイコンが必要です。
  3. サイトはhttpsプロトコルで動作する必要があります (例外: localhost)。 シークレットモードでも、アプリケーションをインストールすることはできません。

最初の2つの項目について、詳しく見ていきましょう。これらは両方とも、マニフェストの内容に関係します。マニフェストには多くの項目を指定できますが、必須なのはアイコン (icons) とアプリケーションの名前 (name または short_name) だけです。
例えば、最小限のwebmanifestは次のようになります。

{
  "name": "MDN Web Docs",
  "icons": [
    {
      "src": "/favicon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

設定可能なフィールドの完全なリストは、こちらのリンクで確認できます。(日本語版には利用可能なフィールドのリストが記載されていないため、英語版のリンクを添付しました。)

コードを書く必要すらありませんでした!しかし、実際にはそれほどスムーズにはいきません。
アプリケーション開発者は、ユーザーがWebサイトをアプリケーションとしてインストールできることを知らない、という問題に直面します。UXの観点からは、ユーザーにアプリケーションをインストールできることを知らせるのが適切です。理想的には、ユーザーがアプリケーションをある程度使用した後で、邪魔にならないように通知するのが良いですが、これは別の機会に詳しく話しましょう。

カスタムインストールプロンプト

それでは、アプリケーションのインターフェースからインストールプロンプトを表示する方法を見ていきましょう。このような感じになります。


アプリのメリットを明確的に説明しているUIの例

解説に入る前に、注意点があります。

それでは実装に移り、エラーを恐れずにこの機能を使う方法を解説します。
この技術を利用する上で理解しておくべき重要なポイントは、beforeinstallpromptという名前のwindowレベルのイベントです。(前述のように、一部のブラウザではサポートされていないため、このイベントはまったく発生しません)

実装の大まかな流れは次のとおりです。

  1. beforeinstallpromptイベントを購読する
  2. イベントハンドラ内で、関数の引数 (例: event) を使用する
  3. event.preventDefault()でデフォルトの動作をキャンセルする
  4. event.prompt関数を変数に保存し、必要に応じて使用する

簡単そうでしょう?しかし、PWA開発には常に注意点があります。
例えば、beforeinstallpromptは、いつ発火するか厳密には決まっていません。
このイベントは、ブラウザがWebアプリケーションをインストール可能であると判断したときに発生します。これは、Webアプリケーションを開いてから数秒後かもしれないし、もっと後かもしれません。
さらに、一度しか発火しないので、見逃さないようにしましょう!

コードは次のようになります。

let showInstallPrompt = undefined;

let timeout = setTimeout(() => showInstallPrompt = null, TIMEOUT_MS)
const handleInstall = (event) => {
    clearTimeout(timeout)
    event.preventDefault();
    showInstallPrompt = event.prompt
}
window.addEventListener("beforeinstallprompt", handleInstall);

このコードでは、showInstallPrompt変数は次の3つの状態のいずれかになります。

  • デフォルトではundefinedが設定されています。これは、アプリケーションがインストール可能かどうかまだ確信がないことを意味します。
  • nullは、beforeinstallpromptイベントがN秒待機しても発生しなかった場合に設定されます。
  • それ以外の場合、変数にはコールバックが割り当てられます。これを呼び出すことで、アプリケーションのインストールプロンプトを表示できます。

この後もいくつか例を紹介するので、ここでは詳細には触れません。上記の例のソースコードへのリンクはこちらです.

宿題: usePwa関数のコードを読んでみてください。実際には、アプリケーションがインストールされているかどうかを判断するためのフラグが必要になるでしょう。ユーザーが既にアプリケーションをインストールしている場合は、インストールボタンを表示する意味はありませんよね?

オフライン対応

PWAの最も顕著な利点の1つは、Webアプリケーションへのオフラインアクセス機能です。たとえネットワークに全く接続されていなくても、一度でもサイトを開いたことがあれば、再びアクセスできます。ちなみに、このためにアプリケーションをインストールする必要さえありません。

例えば、私が正規表現のデバッグに使用しているこのサイトを見てください: https://regex101.com/
ネットワーク接続なしで起動してみてください。それでも動作します!素晴らしいと思いませんか?

この例を始める前に、キャッシュの仕組みを思い出してみましょう。この記事ではWorkboxを使用するため、Service Workerの詳細は省略します。キャッシュのメカニズムについては、第1回で詳しく解説しました。

さて、オフラインモードのサポートを開発する大まかな流れは次のとおりです。

  1. Service Workerを登録する
  2. インストール時に、アプリケーションの動作に必要なすべての静的リソースのキャッシュを要求します (例: cache.addAll)
    • すべてのリソースが正常にキャッシュされた場合、Service Workerは正常にインストールされます。
    • 1つ以上のリソースの取得中にエラーが発生した場合: Service Workerのインストールは完了せず、サイトはService Workerなしで動作し続けます。

多くの場合、最後のシナリオはコードの問題に関連していますが、インターネット接続が悪い状態でサイトを初めて起動したときにも発生する可能性があります。また、キャッシュには2種類あることを思い出してください: precacheとruntime cacheです。

precacheは、Service Workerが動作を開始する前にリソースを事前にキャッシュすることを意味します。これらのリソースのいずれかがキャッシュに失敗した場合、Service Workerはインストールされません。runtime cacheは、Service Workerが完全にインストールされた後、ユーザーがfetchリクエストを送信するたびにリソースをキャッシュします。Webアプリケーションがオフラインモードをサポートするには、事前キャッシュだけで十分です。

私が前回の記事でWorkboxを強く推奨した理由の1つは、必要なすべてのキャッシュ操作を1行のコードで実行できることです。

precacheAndRoute(self.__WB_MANIFEST);

precacheAndRouteの仕組みについては、こちらの章で詳しく説明しました。簡単に言うと、この関数は2つのことを行います。

  1. precache - Workboxマニフェスト__WB_MANIFESTに含まれるすべてのリソースを事前にキャッシュします。
  2. route - クライアントのfetchリクエストをキャッシュ内のファイルにルーティングします。

以上が仕組みのすべてです!こちらが、キャッシュの仕組みを示すインタラクティブな例です。
キャッシュするリソースのさまざまな組み合わせを試して、ページの外観がどのように変化するかを確認してください。


インタラクティブなアプリでキャシューを勉強しましょう

しかし、実際にキャッシュのメカニズムに触れると、非常に多くの注意点があることに気づきます。
例えば、画面の向こう側で「self.__WB_MANIFESTって何?」「キャッシュする必要があるデータはどこから来るの?」という質問が聞こえてきます。これについて詳しく見ていきましょう。

self.__WB_MANIFEST について

__WB_MANIFESTは、Service Worker内のプレースホルダー変数名で、Webアプリケーションのビルド時に静的ファイルに関するデータが格納されます。多くの場合、これはworkbox-webpack-pluginを使用して実装されますが、他のツール用のバリエーションも見られます。例えば、Viteの場合、この機能はVitePWAプラグイン内で実装されています。

このようにして、Webアプリケーションのビルドツールは、ビルドの出力ファイルとその拡張子を見て、静的リソースのリストを作成します。この配列を準備した後、self.__WB_MANIFEST = [すべてのリソースのリスト...]のようなコードがService Workerの先頭に追加されます。
これにより、precacheAndRouteが実行される時点で、キャッシュするリソースの完全なリストがコード内にすでに存在することになります。

最も注意深い読者は、すでに非常に重要な点に気づいているでしょう。マニフェストへのデータの入力は、ビルド段階で行われます。さらに、HMR (Hot Module Replacement) がフロントエンドアプリ開発の標準になりつつあることも、問題を複雑にしています。ここで、最初で最大の難関に直面します。それは、devモードでのprecacheAndRouteの動作確認です。私には2つの解決策がありますが、どちらも完璧ではありません。

最も簡単なのは、devモードでは、アプリケーションが要求するものをすべてキャッシュすることです。
これにより、一度ページを開けば、ネットワーク接続がない場合でも開くことができるようになります。

import { setDefaultHandler } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";

setDefaultHandler(new NetworkFirst());

2つ目の方法ですが、これはより複雑です。さらに、解決策は使用するツールによって異なります。
以降では、Viteを例にプロセスを解説しますが、このプロセスはバンドラーによって大きく異なる可能性があることに注意してください。

【任意】開発モード用の対策について

さまざまなツールに適用できる一般的な考え方を紹介します。
デフォルトでは、開発モードでWorkboxは空の配列を追加します。手動で配列に要素を追加することもできますが (Workboxには、独自の要素で配列を拡張する機能があります)、それでもうまくいきません。その理由は、開発モードでは、Viteはアプリケーション全体をビルドするのではなく、インターフェースから直接要求された部分のみをビルドするためです。さらに、各ファイル名には (おそらくファイルのハッシュに関連する) 識別子が追加されますが、これを事前に知ることはできません。

また、どうやっても克服できない相違点があることも、あらかじめ言っておきます。例えば、コード (jsファイル) だけを事前にキャッシュし、CSSはユーザーの判断でダウンロードするようにしたい場合も、失敗します。ViteはCSSをJSモジュールにバンドルし、それらを段階的にインポートするためです。そのため、どのファイルがスタイルで、どのファイルがJSコードであるかを簡単に判断する方法はなさそうです。(私はさらに一歩踏み込んで、ファイル名で確認しようとしましたが、スタイルを含む要求されたJSファイルが返されない場合、インポートエラーが発生します。ここでは敗北を認めて先に進むしかありません。)

では、開発モードではキャッシュを利用できないのでしょうか?必ずしもそうではありません。precacheをそのまま使うことはできません。しかし、runtime cacheを忘れてはいけません。ブラウザが直接ファイルを要求するまで、ファイル名はわかりません。しかし、できることはあります。Service Worker内でfetchリクエストをすべて捕捉し、特定のパターンに一致するものだけをキャッシュします(特定の拡張子を含む、パスにnode_modulesのようなフォルダ名を含むなど)。実践的な実装としては、次のようなカスタムプラグインが考えられます。

import { setDefaultHandler } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { PartialCachingPlugin } from "./plugins/partial-caching-plugin";

setDefaultHandler(
  new NetworkFirst({
    cacheName: 'dev-static-cache',
    plugins: [
      new PartialCachingPlugin({ include: /\/node_modules\/.*/, exclude: /.*jsx/ }),
    ],
  })
);

このプラグイン内では、cacheWillUpdateハンドラ内で正規表現を使用して、不適切なファイルを除外できます。nullを返すことで、ファイルのキャッシュをキャンセルします。

これは非常に複雑で興味深いトピックであり、別の記事で取り上げる必要があります。大規模なPWAアプリケーションの開発経験が得られ次第、(可能な限り) この問題に対する包括的な解決策を説明する予定です。おそらく、Next.jsアプリケーションを例に解説することになるでしょう。今後の更新にご期待ください!

fetchの仕組みについて

正直なところ、全く予想していなかった、もう一つの重要な実践的な開発上の問題があります。
アクティブなService Workerがある場合、ファイルのダウンロードはクライアントコード内のfetchリクエストによって行われるのではなく、Service Workerコード内の同じようなリクエストによって行われます。Service Workerはクライアントのリクエストを傍受し、特定の方法で処理します。

何が問題なのでしょうか?開発者ツールでアプリケーションをオフラインモードに切り替えてfetchリクエストを繰り返しても、リクエストは成功します。どうやら、Service Workerはブラウザの開発者ツールのネットワーク設定を完全に無視しているようです。そのため、開発を容易にするためには、再び工夫が必要になります。この問題は、シンプルなカスタムプラグインで解決できます。
考え方は次のとおりです。

  1. Service Workerを通過するすべてのfetchリクエストを検討する
  2. リクエストの処理時にアプリケーションがオフラインの場合 (navigator.onLine) - エラーを返す

簡単な実装例を次に示します。

export class RejectFetchPlugin {
  // fetchDidSucceedの代わりにrequestWillFetchを使用することもできます。ここでは、fetchDidSucceedを使用して、
  // ネットワークリクエストの試行時に発生する自然な一時停止をシミュレートしました。
  async fetchDidSucceed(params) {
    // このプラグインは開発バージョンでのみ適用することを忘れないでください。最終ビルドでは必要ありません。
    if (process.env.NODE_ENV === "dev") {
      if (!self.navigator.onLine) throw new Error(`Failed to fetch: offline`);
      else return params.response;
    } else return params.response;
  }
}

次に、このプラグインを次のようにすべてのリクエストに接続できます。

import { setDefaultHandler } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
import { RejectFetchPlugin } from "./plugins/reject-fetch-plugin";

setDefaultHandler(
  new NetworkOnly({
    plugins: [
      new RejectFetchPlugin(),
    ],
  })
);

ボタンをクリックするとHTML、JS、CSS、およびその他のリソースがキャッシュされる例のソースコードはこちらです。興味深い点: useCacheフックの実装を見てください。クライアントとService Worker間のインタラクションの例があります。ボタンをクリックすると、キャッシュリクエストがService Workerに送信され、Service Workerがキャッシュを実行して結果 (成功、変更なし、またはエラー) を返します。

すぐにすべてのコードが理解できなくても、がっかりしないでください。根気強く、control (command) + f を使用して、Workboxに関するすべての質問の答えをコース記事の第2部で見つけてください。

更新の通知

はじめに

アプリケーションの更新は、PWAのライフサイクルにおいて非常に重要な部分ですが、開発者によって無視されることがよくあります。更新という概念自体、Webアプリケーションにはあまり馴染みがありません。ほとんどの場合、新しいバージョンのビルドをサーバーにアップロードするだけで十分です。PWAの場合は、もう少し複雑になります。

考えてみてください。通常のWebアプリケーションとPWAの違いは、オフラインモードでも使用できることです(多くの場合、制限付きで)。これは、クライアント側で静的リソースをキャッシュすることによって実現されます。また、サイトの再読み込みを高速化するのにも役立ちます。静的リソースをサーバーから取得する代わりに、Webアプリケーションはキャッシュから直接データを使用します。

しかし、これには裏面もあります。更新フローを考えなければ、ユーザーは後で追加した素晴らしい機能を決して目にすることはありません。新しい機能を追加する際には、アプリケーションにキャッシュが古くなったことを通知する必要があります。また、Service Worker自体のコードを改善することもできます。例えば、GETリクエストに対するサーバーのレスポンスをキャッシュに保存し始めるなどです。

更新プロセスを便利で「苦痛のない」ものにする方法を見ていきましょう。
私が推奨する実装方法の具体例はこちらです


細かい説明含みます

この例は次のように動作します。
「Service Workerを更新」ボタンをクリックします。更新が成功すると、新しいタブを開くことを提案する通知が表示されます。新しいタブを開くと、アプリケーションの新しいバージョンがあることを示すメッセージが表示されます。更新を確認すると、すべてのタブが同時に再読み込みされ、最新バージョンのコードが適用されます。

これまでと同様に、いくつかの注意点があります。それぞれを詳細に説明するとこの記事では長くなりすぎるため、上記の例では詳細な説明を折りたたみにしました。ここでは、重要なポイントのみを解説します。または、第2部のService Workerのライフサイクルに関する章をもう一度読んでください。

Service Workerの更新の仕組み

ブラウザには、Service Workerを更新するための組み込みメカニズムがあります。ページを開くたびに、ブラウザはサーバーにService Workerの最新コードを要求し、ローカルのコードと比較します。最小限の違い (例えば、新しいバージョンにコメントが追加されたなど) でもあれば、ブラウザは現在のバージョンを古いとみなし、古いバージョンを使用しているタブがなくなった時点で新しいバージョンに置き換えます。例えば、ユーザーがタブを1つだけ開いていて、それを再読み込みする場合などです。Service Workerのバージョンチェックをトリガーする主なイベントについては、第1部で説明しました

ここから次のことがわかります。

最適なアプローチ

実際には、私は次のアルゴリズムにたどり着きました。

  1. 新しいバージョンが利用可能であることをユーザーに通知し、インストールを提案する
  2. ユーザーは適切なタイミングを選び、「更新」ボタンをクリックする
  3. この時点で、新しいバージョンのService Workerが強制的にアクティブ化される (skipWaiting())
  4. アプリケーションのコードはService Workerのバージョンの変更を追跡し、ページのリロード (location.reload()) をトリガーする
  5. すべてのタブがリロードされ、新しいバージョンのService Workerを使用する

location.reload()による完全なリロードは、特にSPAの場合、悪い解決策のように思えるかもしれません。実際には、このアプローチは、そうでなければ非常に捕捉しにくいバグを回避するのに役立ちます。

問題は、更新の瞬間まで、ページは古いバージョンに従って起動し、動作していたということです。
新しいバージョンが古いバージョンと競合しないことを保証できません。詳細が気になる方は、以下のスポイラー内の追加情報を参照してください。そうでない方は、すぐに主要な原則に進みます。

バージョン衝突の例

次のようなシナリオを考えてみましょう。

古いバージョンのアプリケーションでは、APIのバージョン1 (api/v1) を使用していました。アプリケーションのコードはすべてキャッシュから返され、APIへのリクエストに対するレスポンスはキャッシュしていません。新しいバージョンのアプリケーションでは、APIのバージョンを更新し (api/v2)、すべてのGETリクエストをキャッシュするようにしました。

  • サイトは、api/v1にアクセスする古いバージョンのアプリケーションで開かれました。
  • ある時点で、アプリケーションの新しいバージョンが利用可能になり、ユーザーが更新を確認しました。
  • アプリケーションはハードリロードを行わず、新しいバージョンで動作し続け、すべてのAPIリクエストがキャッシュされます。
  • アプリケーションは、(最初のページを開いたとき、つまり更新前に取得された) キャッシュからの古いコードを引き続き使用し、api/v1にアクセスしますが、サーバーからの応答がキャッシュされるようになりました。

問題に気づきましたか?古いバージョンのAPIからのレスポンスをキャッシュしていますが、これらは将来役に立ちません。貴重なディスクスペースが無駄な情報で埋められてしまっています。これは比較的「無害な」シナリオです。したがって、常に次の原則に従います。

Service Workerのライフサイクルに関する追加情報は、こちらの「対策の考え」「ライフサイクルの制御」にあります。

静的リソースのキャッシュの更新

コードに移る前に、静的リソースの更新に関する疑問を解決しておきましょう。
簡単に言うと、Workboxを使用してください。Workboxは、次の1行のコードだけで静的リソースの更新プロセスを完全に自動化します。

precacheAndRoute(self.__WB_MANIFEST);

この章では、次のことを理解していれば十分です。self.__WB_MANIFESTには、(ビルドの結果として得られた) すべての静的ファイルに関する情報が次の形式で含まれています。

self.__WB_MANIFEST = [
    {
        url: "/static/js/app.js", // 静的リソースのアドレスへのリンク
        revision: "3ksla9sd3raf" // コードのハッシュ値
    }
    // 他のオブジェクトは省略
]

revisionフィールドに注目してください。app.jsの内容を変更すると、ビルドの結果としてハッシュ値も変更され、新しいバージョンがあることを知らせます。つまり、アプリケーションのファイルを変更すると、Service Workerのコードも変更されます。そして、前述のスキームに従います。ブラウザはサーバーにService Workerの現在のバージョンを要求し、それをローカルバージョンと比較します。さらに、precacheAndRoute関数は、新しいバージョンのself.__WB_MANIFESTに存在しないファイルを静的キャッシュから削除します。信じられないほど便利です!

実装

さて、理論はこれで終わりにして、コードを見ていきましょう。実際のコードへのリンクはこちらです。

最も重要なのは、WorkboxProvider.tsxsw.tsファイルです。後者から始めましょう。
Service Workerのコードに必要なのは、messageイベントの購読だけです。

self.addEventListener("message", async (event) => {
    switch (event.data.type) {
        case "SKIP_WAITING":
            await self.skipWaiting();
            break;
    }
});

Service Workerはメインスレッドとは別に存在するため、メッセージの交換によってやり取りが行われます。詳細については、「クライアントとのやり取り」を参照してください。このコードでは、クライアントが「バージョンを更新」コマンドを送信するのを待ち、self.skipWaiting()を使用して新しいバージョンを強制的にアクティブ化します。

次に、インターフェース部分についてです。
一般に、Workboxを使用した更新プロセスは次のようになります。

if ("serviceWorker" in navigator) {
    // Workboxクラスのインスタンスを作成
    const sw = new Workbox({type: "module", scope: "/"});

    // アプリケーションの初回インストール時にユーザーに通知します (ユーザーが確認するまでService Workerは動作しません)
    const handleInstalled = (event: WorkboxLifecycleEvent) => {
        if (!event.isUpdate) notifyInstall()
    };
    // インストール成功イベントを購読
    sw.addEventListener("installed", handleInstalled);

    // ブラウザがService Workerの新しいバージョンを検出すると、それをインストールして待機状態にします。
    // 待機イベントが発生した場合、これはアップデートが来たことを確信できます。
    const handleWaiting = () => notifyUpdate();
    // 更新待機イベントを購読
    sw.addEventListener("waiting", handleWaiting);

    // アクティブなService Workerのバージョンが変わると、controllingイベントが発生します。
    // この時点で、新しいバージョンへの移行が正常に完了したことを確信できます。
    const handleControlling = () => window.location.reload();
    // アクティブなService Workerの変更イベントを購読
    sw.addEventListener("controlling", handleControlling);

    // Service Workerをアプリケーションに登録
    sw.register();
}

Workboxは、イベントを提供しており、これらを購読することで更新プロセスの変化を簡単に追跡できます。残るは、ユーザーによる更新の確認ロジックを検討することです。showPopupメソッドによって呼び出される小さなポップアップウィンドウに情報を表示するとしましょう。

function notifyUpdate() {
    showPopup({
        title: "新しいバージョンが利用可能です。更新しますか?",
        onApply: () => sw.messageSkipWaiting()
    });
}

Workboxには、messageSkipWaitingという定義済みのメソッドがあり、Service Workerに新しいバージョンをアクティブ化する必要があることを通知します。Service Workerのコードで"SKIP_WAITING"という文字列を使用した理由は、messageSkipWaiting()メソッドがこのメッセージを送信するためです。手動で送信することもできます。違いはありません。その場合は、好きな文字列を選択できます。

sw.messageSW({type: "SKIP_WAITING"})

特に、初回インストール時にはself.skipWaiting()を呼び出す必要はありません。新しいService Workerはすぐにアクティブ状態になりますが、ページの再読み込み後にのみ動作を開始します。その場合は、ユーザーの同意を得てすぐにページを更新する必要があります (Service Workerにメッセージを送信しないでください - まだコマンドをリッスンしていません (^_^) )。

function notifyInstall() {
    showPopup({
        title: "アプリケーションはオフラインで動作する準備ができました。アクティブにしますか?",
        onApply: () => window.location.reload()
    });
}

この章で説明した原則に従うことで、エンタープライズレベルの更新フローを簡単に実装できます。
このメカニズムを無視しないでください。(ほぼ) すべてのアプリケーションで役立つ、新しいバージョンへのスムーズな移行を促進します。ユーザーはPWAの使用中に重要なデータを失うことはなく、キャッシュストレージは古いデータでいっぱいになることもありません。Win-Winです。

完成したPWAの例

これまでに学んだすべての情報を組み合わせて、「じゃんけん」ゲームのPWAを作成しました。
オフラインモード (コンピューターとの対戦) と、WebSocketによるオンライン対戦をサポートしています。


じゃんけんゲームのリングはこちら

ここでは多くのことについて話すことができます。特に、Webアプリ設計は、この例に非常に適したトピックです。しかし、それについては別の機会に話しましょう。このコースでは、PWAに直接関係することだけに焦点を当てます。

このアプリケーションでは、PWAに特徴的な次の機能を見つけることができます。

  • 独立したアプリケーションとしてインストールできる
  • ネットワークがない状態でも動作する (シングルプレイのみ)
  • 初回インストールと更新を通知する (ただし、記事の公開後に更新する予定がありません)
  • オフラインで使用するためのAPIレスポンスのキャッシュ

最初の3つの項目はすでに説明しました。学習内容を定着させるために、ソースコードを読んで、どのように動作するかを理解することをお勧めします。最後の項目、つまり、接続がない場合に使用できるAPIレスポンスのキャッシュについてのみ説明します。

古いデータに関する通知

実際、これはWorkboxの魔法を使えば非常に簡単に実装できます。Service Workerのコードに次のように記述するだけです。

import { NetworkFirst } from "workbox-strategies";
import { registerRoute } from "workbox-routing";
import { ExpirationPlugin } from "workbox-expiration";

registerRoute(
  /.*\/api\/.*$/, // リクエストURLをフィルタリングするための正規表現
  new NetworkFirst({  // リクエスト処理戦略: まずサーバーから取得を試み、そうでなければキャッシュから返す
    cacheName: "api-cache",  // オプションでキャッシュ名を指定できます (デバッグを容易にするため)
    plugins: [               // オプションでプラグインを追加します
      new ExpirationPlugin({ // 例えば、expirationプラグインはディスク容量を節約するのに役立ちます
        maxEntries: 20, // 最新の20リクエストのみを保存
        maxAgeSeconds: 60 * 60 * 24, // これらのリクエストを24時間保持し、その後削除
      }),
    ],
  }),
);

デフォルトでは、GETリクエストのみがキャッシュされることに注意してください (ほとんどの場合、これで十分です)。registerRouteメソッドとルーティング自体の仕組みについては、workbox-routingの章で詳しく説明しました。戦略についても、workbox-strategiesの章で説明しました。

このままでは、この機能はあまり役に立ちません。確かに、以前にキャッシュされたレスポンスをユーザーに返すことができますが、そのためユーザーはネットワークに問題が発生していることに気づかない可能性があります。誤解を避けるために、ユーザーにデータが古い可能性があることを通知するのが適切だと思います。例えば、サーバーから最新のデータを取得した場合は通常どおり表示します。キャッシュされたデータを返した場合 (接続がない、接続が遅いなど) は、表示されているデータが古い可能性があることを示す警告を表示します。私はこのパターンを「Notify on stale」と呼んでいます。


例えば、上記のように表示できます

このような警告を表示するには、そのレスポンスがキャッシュから送信されたかどうかを判断するためのメタフラグが必要です。このような場合は、Workboxプラグインを使用するのが最適です。
このような機能を備えた標準プラグインは存在しないため、自分でプラグインを作成します。
Workboxの構文のおかげで、これは難しくありません。

Workboxのプラグインはクラス形式をお勧めします。クラス内で実装できるメソッドが決まっています(handlerWillStartcacheWillUpdatecacheDidUpdateなど)。名前が示すように、各メソッドはリクエスト処理のライフサイクルの特定の時点で呼び出されます。例えば、cacheWillUpdateはキャッシュが更新される前に呼び出されます。関数本体では、追加のチェックを実行したり、キャッシュをキャンセルしたり (nullを返す) ことができます。

実装を始める前に、望ましい結果を定義する必要があります。キャッシュから返される各リクエストに対して、リクエストに関する追加情報を含むmetaフィールドを追加します。例えば、次のようになります。

const reponse = {
    payload: {  // APIからのレスポンスの例
        title: "lorem ipsum",
        description: "lorem ipsum dolor sit amet"
    },
    meta: {
        isCached: true  // 将来、他のリクエスト情報も必要になる可能性があるため、すべてをmetaフィールドに格納します
    }
}

PWAに典型的な、ある重要な注意点を考慮することが非常に重要です。Webアプリケーションは、Service Workerなしでも正しく動作する必要があります。つまり

例を挙げて説明します。サーバーが次のレスポンスを返したとします。

{
  "title": "lorem ipsum",
  "description": "lorem ipsum dolor sit amet"
}

上記のService Workerのコードでは、レスポンスを都合の良いようにフォーマットし、コンテンツをpayloadフィールドに配置しました。したがって、クライアントコードでは、titleフィールドの使用は次のようになります: response.payload.title

一見すると問題ないように見えますが、Service Workerがこのリクエストをインターセプトしなかった場合はどうなるでしょうか?その場合、レスポンスにはpayloadフィールドが存在せず、同じコードでエラーが発生します。Service Workerは最新のデバイスの大多数でサポートされているため、この問題に注意を払わない人がたくさんいるでしょう。問題は、これがどのデバイスでも起こりうるということです。その理由は次のとおりです。

前の章で述べたように、アプリケーションの更新はユーザーの同意を得てからのみ行います。初めてアプリケーションを開いたとき、Service Workerはインストールされますが、ページの再読み込み後にのみ動作を開始します。しかし、ユーザーがすぐにページを再読み込みすることに同意しなかった場合はどうなるでしょうか?ユーザーは、(まだ) Service Workerを使用していないWebアプリケーションをしばらく使い続ける可能性があります。したがって、リクエストを変更する際には、次のルールに従う必要があります。

実践的な実装方法

目的を達成するには、cachedResponseWillBeUsedメソッドで十分です。ちなみに、メソッドの完全なリストとその説明はこちらにあります。名前が示すように、cachedResponseWillBeUsedハンドラは、Service Workerがクライアントにキャッシュから取得したレスポンスを返す前に呼び出されます。プラグインの実装は次のようになります。

class MetaPlugin {
    // キャッシュからレスポンスが返されるときに呼び出される
    async cachedResponseWillBeUsed({cachedResponse}) {
        if (cachedResponse) { // レスポンスがundefinedでない場合にのみレスポンスを補足する
            const {headers, status, statusText} = cachedResponse;

            const json = await cachedResponse.clone().json(); // 元のオブジェクトに影響を与えないように、コンテンツをコピーします
            return new Response(
                JSON.stringify({
                    ...json,  // レスポンスの本文にあるものをすべて展開
                    meta: {  // 追加情報を追加
                        isCached: true,
                    },
                }),
                {
                    headers,   // 元のレスポンスのデータを配置することを忘れない
                    status,
                    statusText,
                },
            );
        }
    }
}

このコードでは、すべてのレスポンスのbodyがJSON形式で返されることを想定していることに注意してください。この点についてバックエンド開発者と話し合うことが重要です。metaフィールド (またはその他の新しいフィールド) を追加できるのは、レスポンスのbodyがオブジェクトの場合のみです。

最も探究心のある開発者は、「あるエンドポイントに対して、metaフィールドがすでにサーバーから返される場合はどうなるのか?」と疑問に思うかもしれません。例えば、ページネーションAPIではよくあることです。

{
  "items": [],
  "meta": {
    "hasNext": false,
    "page": 3
  }
}

私が思いつく最善の方法は、同じ名前のフィールドの内容をマージすることです。これにより、元のデータを保持し、新しいデータを追加することができます。これは、競合が発生する可能性のあるフィールドの名前を事前に知っている場合に特に便利です。

JSON.stringify({
    ...json,
    meta: {
        ...json.meta,
        isCached: true,
    },
})

プラグインをpluginsフィールドに追加すれば、設定は完了です!これで、キャッシュから取得されたすべてのレスポンスにmeta.isCachedフィールドが含まれるようになります。

import { MetaPlugin } from "./plugins/meta";

new NetworkFirst({
    cacheName: "api-cache",
    plugins: [new MetaPlugin()],
})

このようにして、アプリケーションの機能を拡張することに成功しました。クライアントがService Workerをサポートしている場合、ネットワークが利用できないときは、アプリケーションはキャッシュからレスポンスを返します。そして、混乱を避けるために、情報が古い可能性があることを示す警告が表示されます。もちろん、このアプローチはすべてのPWAに適しているわけではありませんが、原則を理解することは有益です。Workboxのカスタムプラグインの可能性は、開発者の想像力によってのみ制限されます。

まとめ

PWA開発は、次の原則に基づいています。できるだけ多くの機能をアプリケーションの基本バージョンに含めます。ユーザーのブラウザがService Workerをサポートしていない場合でも、Webアプリケーションを使用する機能を奪うべきではありません。次に、Service Workerなしでは実現できない機能で機能を拡張します。これらの機能はすべて不可欠ではありませんが、組み合わせることで非常にポジティブな効果を得ることができます。

キャッシュのおかげで、PWAは通常のWebアプリケーションよりも高速に動作し、ユーザー維持率が向上します。ユーザーはそのようなWebアプリケーションについて友人に話す可能性が高くなります。これはまさに、スターバックスがPWAを開発することで達成した効果です。場合によっては、モバイルアプリケーション開発をPWA開発に置き換えることさえ可能になり、クライアントは動作するモバイルアプリケーションとデスクトップアプリケーションだけでなく、動作するWebサイトも手に入れることができます。

もしかしたら、これが将来のクロスプラットフォーム開発の姿なのかもしれません。

これでPWA開発コースは終わりです。このパートでは、最も一般的なPWA機能の実践的な実装例を紹介しました。すべてを一度に覚えることは不可能なので、実際に知識を適用する際に記事を参照してください。幸運を祈ります。

ハッピーコーディング!

Sun* Developers

Discussion