ReactアプリをService Workerでオフライン対応にする方法

​​React​Playwright,Service Worker,TypeScript,Vite,vite-plugin-pwa,Workbox

Vite で作った React の画面は、vite-plugin-pwa に Service Worker を作らせると、オフラインのときでも読み込み直せるようになります。画面を作るファイル(ビルドした HTML・JS・CSS)は、初めてページを開いたときに、まとめてブラウザに保存されます。API へのリクエストは、Service Worker がまずサーバーに送り、応答がなかったとき(オフラインのときなど)だけ、前に保存しておいたデータをページに返します。React の側では、useRegisterSW で「オフラインで開ける」「新しい版がある」を知らせ、useSyncExternalStore でオンラインかどうかを画面に出します。

筆者は検証環境で、メモの一覧を出す小さな画面を作ってビルドし、vite preview で配信して、Chromium を Playwright で操作しました。オフラインにするときは、サーバーも止めています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、typescript 7.0.2、vite 8.3.5、@vitejs/plugin-react 6.1.2、vite-plugin-pwa 2.0.0、workbox-window 7.4.1 です。ブラウザは環境に入っていた Chromium(Google Chrome for Testing 153)を Playwright 1.64.1 で操作し、オフラインは Playwright の setOffline で作りました。http://localhost で試しており、HTTPS の本番のサーバー、Firefox・Safari、スマホの実機、開発サーバー(vite)での動きは試していません。

ポイント

Service Worker が働くのは、ビルドしたものを配信したときです。画面を作るファイルは初めてページを開いたときにブラウザに保存されますが、そのときに取った API のデータは保存されません。オフラインの表示は navigator.onLine を目安にし、新しい版は、利用者が「更新する」を押したときに切り替えます。

vite-plugin-pwa に Service Worker を作らせる

Service Worker は、ページとサーバーのあいだに入って、リクエストに応答を返す仕組みです。中身を自分で書くこともできますが、Vite のビルドでは、中身が変わるとファイル名のハッシュが変わるので、キャッシュする一覧を手で管理し続けるのは大変です。vite-plugin-pwa は、ビルドの出力を見て一覧を作り、Workbox を使った Service Worker を生成します。

パッケージは vite-plugin-pwa と workbox-window を入れます。公式ドキュメントの React の説明では、workbox-window を開発用の依存に入れるよう書かれています(検証ではほかのパッケージと一緒に npm i で入れました)。

設定は次のとおりです。registerType: 'prompt' は、新しい版が届いたときに勝手に切り替えず、画面で知らせるための指定です。

ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'prompt', // 新しい版が届いたら、画面で知らせてから切り替える
      manifest: false, // この記事ではインストール用のマニフェストは作らない
      workbox: {
        // 画面を作るファイル(ビルドした HTML・JS・CSS)は、
        // 初めてページを開いたときに、まとめてブラウザに保存する(プリキャッシュ)
        runtimeCaching: [
          {
            // API へのリクエストは、Service Worker がまずサーバーに送る。応答がないとき
            // (オフラインのときや、3秒待っても返らないとき)だけ、前にブラウザに保存しておいたデータを返す
            urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
            handler: 'NetworkFirst',
            options: { cacheName: 'api', networkTimeoutSeconds: 3 },
          },
        ],
      },
    }),
  ],
});

npx vite build を実行すると、いつものビルドのあとに sw.js が作られます。

text
PWA v2.0.0
mode      generateSW
precache  4 entries (225.18 KiB)
files generated
  dist/sw.js
  dist/workbox-d191b065.js

初めてページを開いたときに保存される4件は、index.html、JS 2つ、CSS 1つでした。公式ドキュメントのプリキャッシュの説明にあるとおり、一覧に入るのは標準では css、js、html だけです。public/ に置いた api/notes.json は dist にコピーされますが、一覧には入りませんでした。画像やフォントもオフラインで使いたいときは、globPatterns に拡張子を足します。

生成された sw.js には、設定した API の扱いのほかに、ページを開く(ナビゲーションの)リクエストに index.html を返す設定も入っていました。

js
e.cleanupOutdatedCaches(),
e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"))),
e.registerRoute(({url:e})=>e.pathname.startsWith("/api/"),new e.NetworkFirst({cacheName:"api",networkTimeoutSeconds:3,plugins:[]}),"GET")});

Workbox のキャッシュの方針の説明によると、handler: 'NetworkFirst' を指定した URL へのリクエストは、Service Worker がまずサーバーに送ります。サーバーから応答が返ってきたら、それをページに返し、ブラウザにも保存します。返ってこなかったときは、前に保存しておいた応答をページに返します。Workbox のソースコードのコメントによると、標準で保存するのは状態コード 200 の応答だけです。また、networkTimeoutSeconds を付けると、その秒数のうちにサーバーから応答が返らないときも、保存しておいた応答を返します。

React の画面から登録して、準備ができたことを知らせる

登録には、プラグインが用意する virtual:pwa-register/react の useRegisterSW を使います。この仮想モジュールを読み込むと、登録の処理もそこから行われます。返ってくる offlineReady と needRefresh は、useState と同じ形の組です。

tsx
import { useRegisterSW } from 'virtual:pwa-register/react';

export function ReloadPrompt() {
  const {
    offlineReady: [offlineReady, setOfflineReady],
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker,
  } = useRegisterSW({
    onRegisterError(error) {
      console.error('Service Worker を登録できませんでした', error);
    },
  });

  if (!offlineReady && !needRefresh) return null;

  const close = () => {
    setOfflineReady(false);
    setNeedRefresh(false);
  };

  return (
    <div role="status" className="toast">
      {offlineReady ? (
        <p>オフラインでも開けるようになりました。</p>
      ) : (
        <p>新しい版があります。</p>
      )}
      {needRefresh && <button onClick={() => updateServiceWorker()}>更新する</button>}
      <button onClick={close}>閉じる</button>
    </div>
  );
}

TypeScript で仮想モジュールの型を読めるように、tsconfig.json の types に vite-plugin-pwa/react を入れます。検証では "types": ["vite/client", "vite-plugin-pwa/react"] にして、tsc がエラーなしで通りました。

画面を初めて開くと、少しして「オフラインでも開けるようになりました。」が出ました。このときブラウザのキャッシュ(Cache Storage)には、先ほどの4件が保存されていました。

Service Worker があるときとないときの、オフラインでの読み込み直し

サーバーを止めてオフラインにし、画面を読み込み直しました。Service Worker がある画面はそのまま開き、ない画面(Playwright で Service Worker を止めた場合)は net::ERR_INTERNET_DISCONNECTED になって、Chromium のエラーの画面が出ました。

上はメモの一覧とオフラインの帯が出た画面、下は Chromium のオフラインのエラー画面
サーバーを止めてオフラインにし、読み込み直したときの画面。上が Service Worker あり、下がなし

オフラインのまま、アプリにないパス(/notes/2)を開いても、同じ画面が出ました。Service Worker がページを開くリクエストに index.html を返しているためです。

初めて開いたときに取った API のデータは、保存されない

上の画像ではメモの一覧が出ていますが、これは2回目に開いたあとの結果です。初めて開いたすぐあとにオフラインで読み込み直したときは、画面は開いたものの、一覧の場所は「メモを読み込めませんでした。」になりました(出力は一部を省略しています)。

text
state: {"h1":"メモ帳 v1","banner":"オフラインです。保存してある内容を表示しています。","list":[],"message":"メモを読み込めませんでした。", ...
requestfailed: ["/api/notes.json net::ERR_FAILED"]

初めて開いたときのページは、Service Worker に制御されていませんでした(navigator.serviceWorker.controller が null)。そのため、そのページの fetch('/api/notes.json') は Service Worker を通らず、API のデータを保存する api という名前のキャッシュは作られませんでした。MDN の Service Worker の使い方にも、開いていたページは読み込み直すまで制御されないこと、clients.claim() でそれを変えられることが書かれています。

オンラインの状態で一度読み込み直すと、ページは制御され、api のキャッシュに /api/notes.json が保存されました。そのあとオフラインで読み込み直すと、保存しておいたデータで、一覧が表示されました。

registerType: 'autoUpdate' にすると、公式ドキュメントの自動更新の説明のとおり clientsClaim と skipWaiting が true になり、新しい版は知らせずに切り替わります。ただ、clientsClaim でページが制御されるのは Service Worker が有効になったあとなので、それより前に出したリクエストは Service Worker を通りません。この画面のように読み込んだ直後に fetch するなら、初めて開いたときに取ったデータは、やはり保存されないと考えられます(筆者は autoUpdate では試していません)。

オンラインかオフラインかを画面に出す

画面上部のピンクの帯は、次のフックで出しています。React の公式リファレンスにある、ブラウザの API を購読する例と同じように navigator.onLine を読み、online と offline のイベントで描画し直します。

ts
import { useSyncExternalStore } from 'react';

function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true, // サーバーで描画するときは、オンラインとして扱う
  );
}

開いたままの画面でオンラインに戻すと帯が消え、オフラインにすると帯が出ました。ただし、MDN の navigator.onLine の説明では、この値はあてにならないことがあるので、機能を止める判断には使わず、目安の表示にとどめるよう書かれています。データが取れたかどうかは、上の「読み込めませんでした」のように fetch の結果で決めるのが確かです。

新しい版を出したときの切り替え

表示する版を v1 から v2 に変えてビルドし直し、オンラインの状態で読み込み直しました。画面は v1 のまま、「新しい版があります。」と「更新する」が出ました。MDN の Service Worker の使い方にあるとおり、新しい Service Worker は、古い版が画面を制御しているあいだは待っているためです。

メモ帳 v1 の画面の下に「新しい版があります。」と「更新する」「閉じる」のボタンが出ている
新しい版をビルドしたあと、開き直した画面。表示はまだ v1

「更新する」を押すと、画面が v2 に変わりました。プリキャッシュ(画面を作るファイルを保存するキャッシュ)の中では、新しい index.html と JS が足され、押したあとには古い index.html と JS が消えていました。新しい Service Worker が有効になると、Workbox のプリキャッシュが、新しい一覧にないファイルを消すためです(Workbox のソースコード)。名前が変わっていない CSS と workbox-window の JS は残りました。

入力の途中で勝手に画面が読み込み直されると困る画面では、この「知らせてから切り替える」形が合います。逆に、autoUpdate から prompt へあとから変えるのは面倒だと、公式ドキュメントの自動更新の説明に注意があります。公開する前に決めておきます。

開発サーバーと HTTPS の条件

ここまでの動きは、vite build のあと vite preview で配信して確かめました。公式ドキュメントの開発時の説明によると、開発サーバーで Service Worker を動かすには devOptions: { enabled: true } が必要です。

また、MDN の Service Worker の使い方にあるとおり、Service Worker は HTTPS のページでしか動きません。手元で試すときに使う localhost は、例外として安全な配信元として扱われます。

オフラインでも開ける画面にするポイント

vite-plugin-pwa を入れ、registerType: 'prompt' と API 用の runtimeCaching を設定すると、画面を作るファイルは初めてページを開いたときにブラウザに保存され、オフラインでも読み込み直せます。API のデータは、Service Worker がページを制御するようになってから取ったものだけが保存されます。React の側では、useRegisterSW で準備ができたことと新しい版を知らせ、useSyncExternalStore で読んだ navigator.onLine を、オフラインの目安として画面に出します。

​Posted by 86