PWAとは?Service Workerで実現するスマホアプリのような次世代Web体験

PWA(Progressive Web Apps)は、Webサイトにスマホアプリのような操作感や機能を持たせる次世代のWeb技術です。インストール不要でオフラインでも利用でき、プッシュ通知やホーム画面追加などの機能を提供します。本記事では、PWAの概要からメリット、具体的な導入手順までをわかりやすく解説します。

PWAとは何か

PWA(Progressive Web Apps)は、最新のWeb標準技術を活用して、Webサイトをまるでネイティブアプリのように動作させる手法です。Googleが提唱し、以下のような特徴を備えています。

機能説明
インストール・ブラウザ両対応通常のウェブサイトとしてブラウザから直接アクセスする事もでき、必要に応じて端末にインストールしてアプリのように利用する事も可能。
オフライン対応Service Workerが事前に必要なファイルをキャッシュし、ネットワークが切断されても利用可能。また、オフライン時には事前に用意したoffline.htmlなどを表示する事も可能。
プッシュ通知サーバーからの通知をリアルタイム配信し、重要な情報や更新を即時に届けることが可能。
ホーム画面追加スマホのホーム画面にアイコンを追加し、ワンタップで起動できるようにすることで、アプリ同様の起動性と操作性を実現。

PWAは「HTTPS必須」の安全な環境で動作し、最新のブラウザ機能を活用してユーザー体験を向上させます。


PWAの仕組み

PWAは以下の3つの要素で構成されます。

  1. HTTPS — セキュアな環境でのみ動作し、通信の盗聴や改ざんを防止します。
  2. Web App Manifest — アプリ名、アイコン、テーマカラー、表示モードなどを定義するJSONファイル。ホーム画面追加や外観設定を制御します。
  3. Service Worker — ブラウザとネットワークの間に位置するスクリプト。キャッシュ管理、オフライン対応、更新制御、プッシュ通知処理などを担当します。

PWAのメリット

メリット説明
UX向上オフライン対応やアプリのような操作感でユーザー満足度を高める
開発コスト削減iOS / Android / PCに同一コード( html / css / js )で対応可能
SEO・再訪率UPキャッシュを利用した高速表示とホーム画面からの起動により再訪率が向上
配信の柔軟性アプリストアを経由せず直接配信が可能なため、ストアのポリシーに縛られることがない
軽量性ネイティブアプリよりも容量が軽くなることが期待でき、インストールのハードルが低い
プッシュ通知リアルタイムで情報を配信でき、ユーザーエンゲージメントを向上させる

サンプルプログラムの概要

今回作成するのは、PWAの基礎を理解するための最小構成サンプルです。HTTPS環境で動作し、以下の機能を備えています。

  • Service Workerによるキャッシュ戦略として、htmlはキャッシュせず、js/cssはキャッシュする
  • ネットワーク接続がない場合はoffline.htmlを表示
  • アプリ更新(バージョンアップ)時にはユーザーへ通知し、ユーザーの許可を得てから更新する
  • ホーム画面追加やインストールにも対応

ファイル構成は以下の通りです。

/.htaccess         — HTTPSやキャッシュ制御の設定を行うApache設定ファイル
/index.html        — PWAのメインページ
/offline.html      — オフライン時に表示する予備ページ(お好みで)
/style.css         — サイト全体のスタイル定義(お好みで)
/app.js            — Service Worker登録や更新通知の設定とアプリのメインスクリプト(お好みで)
/service-worker.js — キャッシュ戦略やオフライン表示を制御するスクリプト
/manifest.json     — PWAの設定(名前・アイコン・テーマカラーなど)を定義するファイル
/icon-192x192.png  — アイコンファイル(maskable)
/icon-512x512.png  — アイコンファイル(maskable)

今回は説明を簡略化するため全てのファイルをルート直下に配置していますが、jsやcssの配置場所はプロジェクトに応じて変更してください。

.htaccess

# manifest.json は更新を検知させるためキャッシュしない
<FilesMatch "manifest\.json$">
    Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>

# Service Worker は毎回取得(更新検知のため必須)
<FilesMatch "service-worker\.js$">
    Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>

# HTML はネットワーク優先なのでキャッシュしない
<FilesMatch "\.(html)$">
    Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>

# CSS / JS / 画像 / フォントは長期キャッシュ(immutable)
# バージョン管理で更新対応する前提
<FilesMatch "\.(css|js|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|otf)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PWAサンプル</title>

    <!-- PWA マニフェスト -->
    <link rel="manifest" href="manifest.json" />

    <!-- テーマカラー -->
    <meta name="theme-color" content="#0078d7" />

    <!-- CSS(キャッシュ優先) -->
    <link rel="stylesheet" href="style.css" />

    <!-- JS(キャッシュ優先) -->
    <script src="app.js"></script>
</head>
<body>
    <h1>PWA サンプルページ</h1>
    <p>このページは <strong>ネットワーク優先</strong> で読み込みます。</p>
    <button id="btn">ボタンを押す</button>
</body>
</html>

offline.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>オフラインです</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <h1>オフラインです</h1>
  <p>ネットワーク接続が必要です。<br>再接続後にページを再読み込みしてください。</p>
</body>
</html>

style.css

body {
    font-family: sans-serif;
    background: #f4f4f4;
    padding: 20px;
}
h1 {
    color: #333;
}
button {
    padding: 10px 20px;
    font-size: 16px;
}

app.js

// app.js
// -----------------------------------------------------------
// 【このスクリプトの役割】
//
// 1. Service Worker の登録と更新監視
//    - ページ読み込み時に SW を登録
//    - 新しい SW が検出されたらユーザーに更新許可を確認
//
// 2. 更新の適用方法
//    - OK の場合:SKIP_WAITING を送信して即アクティブ化
//    - アクティブ化後は controllerchange イベントで自動リロード
//    - キャンセルの場合:同一セッション中は再通知しない
//
// 3. 更新チェックのタイミング
//    - ページ復帰時(visibilitychange)に1時間おきで reg.update() 実行
//    - ブラウザの自動チェックより短い間隔で更新を検知可能
//
// 4. アプリ本体のプログラム
//    - ボタン押下でalertを表示するだけ(DOMContentLoaded 後に実行)
// -----------------------------------------------------------

const DISMISS_KEY = 'swUpdateDismissed';
const UPDATE_INTERVAL = 60 * 60 * 1000; // 1時間
let lastUpdateCheck = 0;

// ===== Service Worker 登録と更新確認 =====
if ('serviceWorker' in navigator) {
    navigator.serviceWorker
        .register('/service-worker.js')
        .then((reg) => {
            console.log('SW registered:', reg.scope);

            // 新SWがコントロールを引き継いだら一度だけリロード
            let reloading = false;
            navigator.serviceWorker.addEventListener('controllerchange', () => {
                if (reloading) return;
                reloading = true;
                location.reload();
            });

            // 既に waiting 中の SW がある場合
            if (reg.waiting && shouldPrompt()) {
                promptAndUpdate(reg.waiting);
            }

            // 新しい SW が検出されたとき
            reg.addEventListener('updatefound', () => {
                const nw = reg.installing;
                if (!nw) return;

                nw.addEventListener('statechange', () => {
                    if (
                        nw.state === 'installed' &&
                        navigator.serviceWorker.controller &&
                        shouldPrompt()
                    ) {
                        promptAndUpdate(nw);
                    }
                });
            });

            // ページ復帰時に1時間おきで更新チェック
            document.addEventListener('visibilitychange', () => {
                if (document.visibilityState === 'visible') {
                    const now = Date.now();
                    if (now - lastUpdateCheck > UPDATE_INTERVAL) {
                        reg.update().catch(() => {});
                        lastUpdateCheck = now;
                    }
                }
            });
        })
        .catch((err) => {
            console.error('SW register failed:', err);
        });
}

// ---- 更新確認ダイアログ ----
function promptAndUpdate(worker) {
    const ok = window.confirm('新しいバージョンがあります。今すぐ更新しますか?');
    if (ok) {
        worker.postMessage('SKIP_WAITING'); // SW側で skipWaiting()
    } else {
        // 同一セッションでは再通知しない
        sessionStorage.setItem(DISMISS_KEY, 'true');
        console.log('更新はユーザーによってキャンセルされました。');
    }
}

// ---- 再通知するか判定(セッション内のみ抑止)----
function shouldPrompt() {
    return !sessionStorage.getItem(DISMISS_KEY);
}

// ===== アプリ本体のプログラム =====
document.addEventListener('DOMContentLoaded', () => {
    document.getElementById('btn')?.addEventListener('click', () => {
        alert('ボタンが押されました!');
    });
});

service-worker.js

// service-worker.js
// -----------------------------------------------------------
// 【このSWの動作ポリシー】
//
// 1. HTMLファイル
//    - 常にネットワークから取得(キャッシュ保存しない)
//    - ネットワーク取得に失敗したら、共通の /offline.html を表示
//
// 2. CSS / JS / 画像 / フォント
//    - キャッシュ優先(オフラインでも利用可能)
//    - キャッシュに無ければ取得して保存
//
// 3. キャッシュの管理
//    - バージョン番号(VERSION)でキャッシュを管理
//    - VERSION を上げると旧キャッシュを削除
//
// 4. 更新の反映タイミング
//    - 新SWがインストールされても即切り替えしない
//    - ページ側(app.js)からの "SKIP_WAITING" メッセージ受信後に即切り替え
//
// -----------------------------------------------------------

const VERSION    = 'v1.0.1';                      // ← バージョンを上げるだけで更新配信
const CACHE_NAME = `pwa-sample-${VERSION}`;

const PRECACHE = [
  '/',
  '/index.html',
  '/offline.html',
  '/style.css',
  '/app.js',
  // 必要ならアイコンやフォント等も追加
  // '/icon-192.png',
  // '/icon-512.png',
  // '/fonts/your-font.woff2',
];

// ----- install: 新バージョンのプリキャッシュ(HTTPキャッシュを回避) -----
self.addEventListener('install', event => {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME);
    await Promise.all(
      PRECACHE.map(async (url) => {
        const res = await fetch(url, { cache: 'reload' }); // 常に新鮮なリソース
        if (res.ok) await cache.put(url, res.clone());
      })
    );
  })());

  // ここでは skipWaiting しない(ユーザー確認後に切り替える)
});

// ----- activate: 旧キャッシュ削除 & 直ちにクライアント制御 -----
self.addEventListener('activate', event => {
  event.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)));
    await self.clients.claim();
  })());
});

// ----- ページ側からの更新許可で即アクティブ化 -----
self.addEventListener('message', (event) => {
  if (event.data === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// ----- fetch: ルーティング戦略 -----
self.addEventListener('fetch', event => {
  const req = event.request;
  const url = new URL(req.url);

  // 1) ページ遷移(HTML)はネットワーク優先
  if (req.mode === 'navigate') {
    event.respondWith(networkFirst(req));
    return;
  }

  // 2) 静的アセット(CSS/JS/画像/フォント)はキャッシュ優先
  if (/\.(css|js|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|otf)$/.test(url.pathname)) {
    event.respondWith(cacheFirst(req));
    return;
  }

  // 3) その他はデフォルト(必要に応じてAPI等の戦略を追加)
});

// ========== 戦略実装 ==========
async function networkFirst(request) {
  try {
    // HTMLは基本的にネットワークから取得(キャッシュ保存しない)
    const fresh = await fetch(request);
    return fresh;
  } catch {
    const cache = await caches.open(CACHE_NAME);

    // オフライン用の固定ページを返す(事前にPRECACHEに含めること)
    return (await cache.match('/offline.html')) ||
           new Response('Offline', { status: 503, statusText: 'Offline' });
  }
}

async function cacheFirst(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);
  if (cached) return cached;

  const fresh = await fetch(request);
  // 成功時のみキャッシュ(エラーレスポンスは保存しない)
  if (fresh && fresh.ok) {
    cache.put(request, fresh.clone());
  }
  return fresh;
}

manifest.json

{
  "name": "PWAサンプル",
  "short_name": "PWAサンプル",
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0078d7",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

実際に試す

1. HTTPS環境で配置(ローカルならlocalhostでも可)

PWAはセキュリティの観点からHTTPS接続が必須です。公開サーバーに設置する場合はSSL証明書を有効化してください。ローカル開発ではlocalhostであればHTTPSでなくても動作しますが、スマホ実機でテストする場合はローカルでもHTTPS環境を構築することをおすすめします。

2. スマホのブラウザでアクセス

PWAをインストールするには、まず対象のWebサイトをスマートフォンの対応ブラウザ(Chrome、Safariなど)で開きます。(もしくは別のページから<a>タグなどで遷移させます。)

3. アプリとしてインストール

ブラウザ上で表示された状態から「アプリをインストール」もしくは「ホーム画面に追加」操作を行うことで、端末にインストールできます。

4. ホーム画面に追加

インストールが完了するとホーム画面にアプリのアイコンが追加されます。

4. アプリとして起動

ホーム画面に追加したPWAアイコンをタップすると、ブラウザのUI(アドレスバーやタブ)が表示されず、専用アプリのように起動します。表示モードはmanifest.jsondisplay設定で制御でき、

  • standalone:アドレスバーなどを非表示にし、単独アプリのように表示
  • fullscreen:ステータスバーも含め完全な全画面表示
  • minimal-ui:最低限のブラウザUI(戻る・進むなど)を表示

これにより、Webサイトではなくネイティブアプリのような操作感を提供できます。

5. オフライン時にoffline.htmlが表示されるか確認

スマホやPCを機内モードにする、またはネットワーク接続を切断した状態でPWAを起動します。ネットワークが利用できない場合でも、Service Workerが事前にキャッシュしたoffline.htmlが表示されれば成功です。これにより、ユーザーはオフライン環境でも最低限の情報や案内ページを閲覧できます。

6. 更新通知の動作を確認

古いバージョンを表示中の状態で、service-worker.jsのキャッシュ名や内容を変更して再アップロードします。アプリ側でページを再読み込みすると、新しいバージョンが検知され、確認ダイアログが表示されます。「今すぐ更新」を選択すると、新しいコンテンツが適用されます。今回の例はあくまでもPWAの動作確認用のサンプルとしてwindow.confirmを利用していますが、window.confirmはユーザーの操作をブロックするため、UXの観点からはあまり推奨されません。より良いアプローチとして、カスタムUI(トースト表示など)で通知し、更新を許可するボタンを用意するなどが推奨されます。


まとめ

PWAは、Web技術だけでネイティブアプリに近い体験を提供できる強力な手段です。特にService Workerを活用することで、キャッシュ戦略や更新通知など、柔軟かつ高度なアプリ体験を構築できます。今回のサンプルは最小構成ですが、ここから発展させてプッシュ通知、バックグラウンド同期、オフラインフォーム送信などを実装すれば、より便利で魅力的なアプリケーションが作れます。さらに、PWAは一度実装すれば複数のOS・デバイスに対応できるため、開発・運用コスト削減にも直結します。将来的には、ネイティブアプリと遜色ないUI/UXを持つWebアプリが主流になる可能性が高く、今のうちに習得しておくことで大きなアドバンテージを得られるでしょう。

Contact

ウェブサイトの制作や運用に関わる
お悩みやご相談
お気軽にお問い合わせ下さい

ウェブサイトと一口に言っても、企業サイトやECサイト、ブログ、SNSなど、その“カタチ”は目的に応じてさまざまであり、構築方法や使用する技術も大きく異なります。株式会社コナックスでは、お客様のご要望やブランドの個性を丁寧に汲み取り、最適なウェブサイトの“カタチ”をご提案いたします。

デザイン、ユーザビリティ、SEO対策はもちろん、コンテンツ制作やマーケティング戦略に至るまで、あらゆるフェーズでお客様のビジネスに寄り添い、成果につながるウェブサイトづくりをサポートいたします。私たちは、ウェブサイトの公開をゴールではなくスタートと捉え、お客様のビジネスの成功に向けて共に伴走してまいります。