はじめに
こんにちは!
Dirbatoの社内横断技術支援組織「Backbeat」に所属している岡田です。
今回は、Vite + Reactで作成されているWebサイトを PWA(Progressive Web App) 化した話になります。
実装方法を調べてみて「すぐにできそう!」と思っていたのですが、意外とハマってしまったポイントなどもあったので、そのような点も交えながら書いていこうと思います。
そもそもPWAとは
「Progressive Web Application」の略称で、Webサイトをネイティブアプリのように利用できる技術・仕組みのことです。
具体例を挙げると、通常のブラウザだとURL欄やタブがあると思いますが、PWA対応したWebサイトだとそういった表示がなく、コンテンツ領域をフルスクリーンで表示可能になります。スマホなど画面が小さいデバイスに特に有効ですよね。一度、PWAのサイトをアプリとしてインストールすると、ホーム画面などに追加されたアイコンからすぐに起動できるなど、ネイティブアプリのようなUI/UXに近づけることができます。
実装手順
今回対象のWebサイトがVite環境だったため、簡単に実装ができるvite-plugin-pwaというプラグインを使用していきます。
前提条件
- Viteで構築されたプロジェクトがある
- Node.jsがインストールされている
- npm または yarn / pnpm が使えること
1. まずは vite-plugin-pwa をインストール
npm install -D vite-plugin-pwa
# または
yarn add -D vite-plugin-pwa
2. vite.config.tsファイルに記述を追加
Viteシステムに元々あるvite.config.tsというファイルに設定を追記します。
こうすることで、PWAに必要なmanifest.webmanifestやsw.jsファイルなどをビルド時に自動生成してくれるようになります。
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
// 〜〜〜(省略)〜〜〜
export default defineConfig({
plugins: [
// 〜〜〜(省略)〜〜〜
VitePWA({
devOptions: { enabled: true },
registerType: "autoUpdate",
injectRegister: null,
includeAssets: ["favicon.ico", "pwa-icon-192.png", "pwa-icon-512.png"],
manifest: {
name: "アプリ名",
short_name: "アプリ名",
id: "/",
scope: "/",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
lang: "ja-JP",
description: "このシステムの説明文を書きます",
categories: ["productivity", "business"],
icons: [
{
src: "/pwa-icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any"
},
{
src: "/pwa-icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any"
},
{
src: "/pwa-icon-192-maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable"
},
{
src: "/pwa-icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable"
}
],
screenshots: [
{
src: `/pwa-screenshot-wide.png`,
sizes: "1280x720",
type: "image/png",
label: "ホーム画面",
form_factor: "wide"
},
{
src: `/pwa-screenshot-narrow.png`,
sizes: "720x1280",
type: "image/png",
label: "ホーム画面(スマホ用)"
}
]
},
// 〜〜〜(省略)〜〜〜
})
]
});
// 〜〜〜(省略)〜〜〜
いくつかの項目の補足説明します。
| 項目名 | 説明 |
|---|---|
devOptions |
devOptions: { enabled: true }とすることで、開発環境(npm run dev実行時など)でもPWAの動作確認をすることができます。ただ、開発中はキャッシュが効きすぎると困る場合もあるので、動作確認したい時だけ true にするなど、必要に応じて設定を変えてください。 |
registerType |
アプリの更新方法の設定。promptとすると手動更新、autoUpdate とするとブラウザがアプリの更新を検知し、キャッシュの更新とリロードを自動で行ってくれます。自動で更新されると困るようなアプリの場合はpromptの方が安心かもしれません。 |
includeAssets |
favicon.icoなど、publicフォルダにあるファイルは書きましょう。Service Workerのプレキャッシュ(オフライン用キャッシュ)リストへ追加されるようになります。 |
name |
インストール時の確認モーダル等に表示される文言です。 |
short_name |
インストール後のアイコンの下に表示される文言です。 |
scope |
このURL以下がPWA化の対象ページとなります。 |
display |
fullscreen、standalone、minimal-ui、browserの4種類から選択可能。Safariが対応しているstandalone、browserのどちらかが無難。今回はブラウザのURL欄を消したい目的なのでstandaloneを設定。 |
background_color,theme_color
|
アプリのブランドガイドラインに沿った色を設定しましょう。これらは主にアプリ起動後に実際にアプリ画面が表示されるまでの間に表示される色で、background_colorはボディ部分、theme_colorはツールバー部分です。 |
screenshots |
インストール時にネイティブアプリのようなプレビュー画面が表示され、ユーザーにアプリの機能を視覚的に伝えることができます。form_factor: "wide"と設定した画像を1枚以上入れることが推奨されているようです。 |
その他、各設定項目の詳細は、MDNのドキュメントをご覧ください。
3. アイコン(icons)の準備
上のvite.config.ts設定ファイル内のicons項目に設定した実際のアイコン画像をpublicフォルダ直下に配置します。192x192と512x512 2サイズのPNG画像なければPWAとして認識されない場合があるらしいのでしっかり用意しましょう。
また、マージンを考慮した画像であればpurpose: "any maskable"として1つの画像定義で済ませることも可能ですが、上の設定例のように、any用とmaskable用で画像を分けて設定することが推奨されています。
補足:アイコンのanyとmaskable設定の違いについて
-
any: アイコン画像がそのまま表示されます。背景透過でもOKです -
maskable: Android端末などで、丸や四角に 切り抜いて(マスクして) 表示されます- 注意点①: 切り抜かれてもロゴが切れないよう、周囲に余白(パディング)を持たせた画像にする必要があります(目安として、ロゴを中央約80%のエリアに収める)
- 注意点②: 背景が透明だと意図しない色で埋められてしまうことがあるため、背景色で塗りつぶした画像が推奨されます
4. Service Workerの登録
プラグインが自動生成するService Workerをアプリケーション側で読み込む設定を追記します。
import { registerSW } from "virtual:pwa-register";
// 〜〜〜(省略)〜〜〜
const updateSW = registerSW({
onNeedRefresh() {
const shouldUpdate = window.confirm("新しいバージョンがあります。更新しますか?");
if (shouldUpdate) {
updateSW(true);
}
},
onOfflineReady() {
console.info("オフライン対応の準備ができました。");
},
onRegisteredSW(swScriptUrl, registration) {
console.log("Service Worker を登録しました。:", swScriptUrl);
}
});
※上の例では、簡易的にwindow.confirmを使用していますが、実際のプロダクトではトースト通知や更新ボタンを画面に表示するなど、UIコンポーネントと連携する実装が望ましいです。
また、PWA設定のregisterType項目で「prompt(手動更新)」ではなく、「autoUpdate(自動更新)」設定の場合はregisterSW();と実行するだけでも問題ありません。
動作確認
簡単にPWA設定ができたので、いざ動作確認へ。
1. ローカルでビルド
まずはローカル環境でビルドしてみます。すると、エラーが...
- error TS2307: Cannot find module 'virtual:pwa-register' or its corresponding type declarations.
import { registerSW } from "virtual:pwa-register";
調べてみると、TypeScriptを使っている場合に起こるエラーのようで、virtual:pwa-registerの型定義が足りない状態とのこと。vite-env.d.tsファイルにvite-plugin-pwa/clientとvite-plugin-pwa/vanillajsの型参照を追加することで解消しました。
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="vite-plugin-pwa/vanillajs" />
2. アプリとしてインストール
(Qiitaサイトを例にスクリーンショットをいくつか掲載していきます)
- PCのChromeでは、URLバーの右端(お気に入りアイコンの左)にインストールのアイコンが表示されるのでクリック
これでアプリとしてインストールされます。
-
iPhone のSafari、Chromeでは、「共有」アイコンをタップし、「ホーム画面に追加」 を選択。
-
Android のChromeでは、メニューから「ホーム画面に追加」 を選択。
3. アプリとして起動
そうすると、ブラウザのタブやURL欄の無いアプリ風の見た目で表示されます!(実際には、インストールした時に使用したブラウザで開いています)

4. 最後に、Dev環境で動作確認
ローカルで上手く動作確認できたので、次はDev環境にデプロイして動作確認してみたところ、なぜかアプリインストールアイコンが表示されていませんでした…。
原因は、HTML内のmanifestへのリンクにcrossorigin="use-credentials"属性が付いていなかったことでした。vite.config.tsファイルに useCredentials: trueの1行を追加することで無事にDev環境でもPWAとして認識されました。
// 〜〜〜(省略)〜〜〜
VitePWA({
// 〜〜〜(省略)〜〜〜
manifest: {
// 〜〜〜(省略)〜〜〜
},
useCredentials: true, // この1行を追加!
// 〜〜〜(省略)〜〜〜
通常、ブラウザがmanifestファイルをリクエストする際、Cookieや認証ヘッダーなどの資格情報は送信されないのですが、manifestファイルが認証を必要とするサーバー上にある場合(Basic認証やプロキシ経由など)、この属性がないと読み込みに失敗することがあるそうです。
画面が真っ白になる問題
動作確認も一通り終わり喜んでいたのも束の間、別件で開発を進めていく中で、ビルドやリロードの度に「画面が真っ白になる」現象が発生。
その原因と、Safariなどのブラウザ環境でも安定して動作させるための「鉄壁の設定」について備忘録を兼ねて共有します。
どんな現象が起きたのか?
再ビルド直後にアプリを開くと、画面に何も表示されない「白い画面(White Screen of Death)」が発生。
ブラウザのコンソールを覗くと、こんなエラーが出ていました。
GET https://XXXXXX.com/assets/index-408711ec.js net::ERR_ABORTED 404 (NOT FOUND)
「さっきビルドしたばかりなのに、ファイルがない…?」
何が原因だったのか?
結論から言うと、「古いHTML(キャッシュ)」が「消えた古いJS」を呼び出そうとしていたのが原因でした。
- Viteのビルド: ビルドのたびにJSファイル名にハッシュ(index-xxxx.js)が付与される
- PWAの強力なキャッシュ: Service Workerが古い index.html をキャッシュしている
- 不整合: 古いHTMLは「古いJS(もうサーバーにはない)」を読み込もうとする
- 404エラー: 読み込みに失敗し、JavaScriptが実行されず画面が真っ白になる
特にSafariなどは、この古いキャッシュを「握りしめて離さない」傾向が強く、不具合が顕著に出ました。
どう解決したのか?
「PWAの設定」と「HTML側での保険」の2段構えで対策しました。
1. Vite PWAの設定見直し
index.htmlだけはキャッシュから出さず、常にネットワーク(サーバー)へ最新を取りに行くNetworkFirst戦略を採用しました。
vite.config.tsのVitePWAプラグイン設定内に、manifestと並列になるようにworkbox設定を追加・変更します。
// 〜〜〜(省略)〜〜〜
VitePWA({
registerType: "autoUpdate",
// 〜〜〜(省略)〜〜〜
workbox: {
// 1. 保存袋(プリキャッシュ)には絶対に入れさせない
globIgnores: ["**/index.html"],
globPatterns: ["**/*.{js,css,ico,png,svg}"],
// 2. Safari等の起動時の道しるべとしてFallbackは設定しておく
navigateFallback: "/index.html",
runtimeCaching: [
{
// 3. ページ遷移(navigate)時は常にネットワークから最新HTMLを獲る
urlPattern: ({ request }) => request.mode === 'navigate',
handler: "NetworkFirst",
options: {
cacheName: "html-cache",
expiration: { maxEntries: 1 } // ストレージを綺麗に保つ
}
}
],
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true
}
})
// 〜〜〜(省略)〜〜〜
この設定の意図:
-
globIgnores: ビルド時の古いJSへのパスが書かれたHTMLを保存袋(プリキャッシュ)に入れない -
NetworkFirst: アプリ起動時、何よりも先にサーバーへ最新のHTMLを取りに行かせる。これによって「最新のJSへの正しい地図」が常に手に入る
2. 万が一のための「最後の一手」
それでもSafariでアプリの起動と終了を繰り返していると、まれに、Service Workerの起動が遅れて404が発生…
PWAアプリを開く瞬間、ブラウザ内部では以下の2つが実行されます。
- Service Workerの起動(NetworkFirstのルールを適用しようとする)
- HTMLの読み込み開始
この時、まれに、Service Workerの起動が間に合わない状態で読み込みが始まり、設定した NetworkFirst ルールをすり抜けて、Safariが勝手に持っている「不完全な古いキャッシュ」を読み込んでしまうようです。その結果、404エラーで画面が白くなります。
この根本原因の解決は、PWA設定だけではどうしようもできなかったため、最後の手段としてindex.htmlの<head>内にセーフティネットを仕込みました。
<head>
<script>
// JSの読み込みに失敗(404)したら、ユーザーに確認してリロードする
// (重要:メインのJSが読み込まれる前にこの監視を開始しないと
// 読み込みエラーを検知できないため、head内の一番上に記述します。)
window.addEventListener('error', (e) => {
if (e.target.tagName === 'SCRIPT' && e.target.src.includes('/assets/')) {
const shouldReload = window.confirm("アプリの読み込みに失敗しました。再読み込みして最新の状態に更新しますか?");
if (shouldReload) window.location.reload();
}
}, true);
</script>
<!-- 〜〜 他のmetaタグや、Viteのスクリプト読み込みなどが続く 〜〜 -->
</head>
この処理の内容:
- ブラウザがJSの読み込みエラーを検知した瞬間、ユーザーに通知
- 「再読み込み」によって強制的に最新のサーバー資産を取得し、エラーから復帰させる
これにて一件落着!
この件で、PWAのキャッシュ戦略の奥深さを痛感しました。同じ悩みを持つ方の参考になれば幸いです!
その他補足:autoUpdate 設定による通信について
registerType: autoUpdateを使用すると、アプリは常に最新の状態を保とうとします。そのため、ブラウザを開くたびにService Workerやアイコンに対して更新の有無を確認するリクエストが飛ぶようになります。
これによりサーバーのログに 304 (Not Modified) が並ぶことがありますが、これは 「更新がないことを確認した(=データ転送はしていない)」 という正常な挙動のようです。
この時の通信はヘッダー情報のみでサーバー負荷は極小のため、気にする必要はないそうです。
さいごに
私自身PWA化の実装は初めてで、色々と調べたり、つまづいたりしましたが、そんな中で得た学びが少しでも皆さんの参考になっていれば幸いです。
今回は基本的な設定の紹介でしたが、より詳しく知りたい方は、下のvite-plugin-pwa 公式ドキュメントや、MDN(Progressive web apps)ページをご覧いただければと思います。

