今回やること
PWA(Progressive Web App)は、Webサイトへインストールやオフライン対応などを段階的に加える考え方です。
この記事ではViteの小さなページに次の2つを追加します。
- アプリ名やアイコンをブラウザへ伝えるWeb App Manifest
- オフライン時に固定の
offline.htmlを返すService Worker
プッシュ通知やバックグラウンド同期は扱いません。まず、オンラインで一度開いた後、通信を切っても説明画面が出る状態を作ります。

前提条件
- Node.js 24 LTSとnpmが使える(推奨)
- HTMLとJavaScriptの基本を知っている
- 192×192pxと512×512pxの正方形PNG画像を用意できる
この記事で使うVite 8の最低要件は、Node.js 20.19以上または22.12以上です。初学者にはNode.js 24 LTSを推奨します。
Service Workerは安全な接続でのみ利用できます。ただし、開発用のlocalhostは例外として利用できます。
1. プロジェクトを作る
npm create vite@latest pwa-offline -- --template vanilla
cd pwa-offline
npm install
mkdir -p public/icons
Windows PowerShellでは、最後の行の代わりに次を実行します。
New-Item -ItemType Directory -Force public/icons
用意した正方形PNGを、次の名前で保存してください。
public/icons/icon-192.png
public/icons/icon-512.png
アイコンが存在しないと、Manifestを正しく読み込めてもインストール要件を満たせません。
2. Web App Manifestを作る
public/manifest.webmanifestを作ります。JSONにはコメントを書けないため、次の内容をそのまま保存してください。
{
"id": "/",
"name": "Offline Practice",
"short_name": "Offline",
"description": "Service Workerのオフライン表示を試す学習用PWA",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#4338ca",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
index.htmlを次の内容に置き換えます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4338ca" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/src/style.css" />
<title>Offline Practice</title>
</head>
<body>
<main>
<h1>Offline Practice</h1>
<p>オンラインで表示しています。</p>
</main>
<script type="module">
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.catch((error) => console.error("Service Worker registration failed", error));
}
</script>
</body>
</html>
src/style.cssは次の内容にします。
:root {
font-family: system-ui, sans-serif;
color: #1e293b;
background: #f8fafc;
}
body {
margin: 0;
padding: 2rem;
}
main {
max-width: 40rem;
margin: 4rem auto;
padding: 2rem;
border-radius: 1rem;
background: white;
box-shadow: 0 1rem 3rem rgb(15 23 42 / 0.12);
}
3. 固定のオフライン画面を作る
public/offline.htmlを作ります。外部のCSSや画像へ依存させず、単体で表示できる内容にします。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4338ca" />
<title>オフラインです</title>
<style>
body {
margin: 0;
padding: 2rem;
font-family: system-ui, sans-serif;
color: #1e293b;
background: #f8fafc;
}
main {
max-width: 40rem;
margin: 4rem auto;
padding: 2rem;
border-radius: 1rem;
background: white;
}
</style>
</head>
<body>
<main>
<h1>オフラインです</h1>
<p>接続を確認してから、もう一度読み込んでください。</p>
</main>
</body>
</html>
4. Service Workerを作る
public/sw.jsを作ります。
const CACHE_PREFIX = "offline-practice-";
const CACHE_NAME = `${CACHE_PREFIX}v1`;
const STATIC_PATHS = [
"/offline.html",
"/src/style.css",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
const STATIC_URLS = new Set(
STATIC_PATHS.map((path) => new URL(path, self.location.origin).href),
);
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_PATHS)),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(
names
.filter(
(name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME,
)
.map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin) {
return;
}
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() => caches.match("/offline.html")),
);
return;
}
if (STATIC_URLS.has(request.url)) {
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
);
}
});
この例では、ページ移動のレスポンスをキャッシュへ保存しません。ログイン後の画面や利用者ごとに異なるHTMLを誤って他人へ見せる危険を避け、通信失敗時だけ固定のoffline.htmlを返します。
静的キャッシュも、STATIC_PATHSへ明示した同一オリジンのファイルだけです。API、POSTリクエスト、外部サイトのファイルには介入しません。
成功確認
npm run dev
- 表示された
localhostのURLをChromeまたはEdgeで開く - DevToolsの「Application」→「Manifest」で名前とアイコンを確認する
- 「Application」→「Service Workers」で登録を確認する
- Service Workerがページを制御するよう、一度ページを再読み込みする
- DevToolsの「Network」で「Offline」を選ぶ
- ページを再読み込みし、「オフラインです」と表示されることを確認する
- 「Online」へ戻し、再読み込みする
これで、Manifestの読込と固定オフライン画面の表示を確認できました。
よくあるつまずき
プロジェクト作成直後にNode.jsのバージョンエラーが出る
Vite 8はNode.js 20.19以上または22.12以上を必要とします。Node.js 18や20.18以前では、npm create vite@latestまたはnpm run devが失敗します。
node --version
古い場合はNode.js 24 LTSへ更新し、ターミナルを開き直してからプロジェクトを作り直してください。
Service Workerが登録されない
file:///で直接HTMLを開かず、npm run devのURLを使うsw.jsをpublic直下へ保存したか確認する- DevToolsのConsoleに404や構文エラーがないか確認する
オフライン画面が表示されない
最初の表示時点では、Service Workerがまだページを制御していない場合があります。オンラインのまま一度再読み込みしてから、Offlineへ切り替えてください。
cache.addAllでインストールに失敗する
STATIC_PATHSのうち1つでも404になると、事前キャッシュ全体が失敗します。特に2つのPNGアイコンのファイル名と配置を確認してください。
修正内容が反映されない
sw.jsを変更したらCACHE_NAMEのv1をv2へ変更します。確認中の古いService Workerは、DevToolsの「Application」から登録解除できます。
練習
offline.htmlの文章と色を変更するpublic/help.htmlを作ってSTATIC_PATHSへ追加するCACHE_NAMEをv2へ変更し、古いキャッシュが削除されることを確認する
キャッシュ対象を追加した時は、存在しないパスや個人情報を含むページを入れていないか確認してください。
次のステップ
実サービスでは、ページごとの公開範囲、更新方法、保存容量を決めてからキャッシュ戦略を設計します。認証済みページやAPIレスポンスを無条件にキャッシュしないことが重要です。
次は、ブラウザのインストール条件と、更新を利用者へ知らせる方法を公式資料で確認しましょう。
参考リソース
- web.dev: Web app manifest
- web.dev: Service workers
- MDN: Using Service Workers
- MDN: Secure contexts
- Vite公式: Getting Started