PWA(プログレッシブウェブアプリ)入門|オフライン対応・プッシュ通知の実装

kento_morota 27分で読めます

ネイティブアプリのような体験をWebで実現する「PWA(Progressive Web App)」は、開発コストの削減とユーザー体験の向上を両立する技術として、多くの企業で採用が進んでいます。オフラインでの動作、プッシュ通知、ホーム画面へのインストールなど、従来のWebサイトでは実現できなかった機能をWeb標準技術だけで実装できます。

本記事では、PWAの基礎概念から、Service Workerによるオフライン対応、プッシュ通知の実装まで、実務で使えるPWA構築の手法を具体的なコード例とともに解説します。

PWAとは?基本概念とメリット

PWA(Progressive Web App)は、Webの技術(HTML、CSS、JavaScript)で構築されながら、ネイティブアプリに近い体験を提供するWebアプリケーションです。

PWAの3つの要件

PWAとして認識されるためには、以下の3つの要件を満たす必要があります。

HTTPS:セキュアな接続が必須です。Service WorkerはHTTPS環境でのみ動作します(localhostは開発用に例外として許可されています)。

Service Worker:バックグラウンドで動作するスクリプトで、ネットワークリクエストの制御、キャッシュ管理、プッシュ通知の受信などを担います。

Web App Manifest:アプリの名前、アイコン、テーマカラーなどのメタ情報を定義するJSONファイルです。ホーム画面への追加時の見た目を制御します。

PWAのメリット

開発コストの削減:iOS、Android、Webの3つのプラットフォームに対応するアプリを、1つのコードベースで開発できます。ネイティブアプリの開発と比較して、開発コストを大幅に削減できます。

アプリストア不要:App StoreやGoogle Playを介さずに配布できるため、審査プロセスが不要です。URLを共有するだけでユーザーにアプリを届けられます。

自動更新:Service Workerの仕組みにより、アプリが自動的に最新バージョンに更新されます。ユーザーに手動でのアップデートを求める必要がありません。

オフライン対応:ネットワーク接続がない環境でも、キャッシュされたコンテンツを表示できます。通信環境が不安定なモバイル環境で特に効果を発揮します。

軽量:ネイティブアプリのようなインストール容量が不要で、数百KBから数MBのキャッシュデータだけで動作します。

PWAの導入事例

世界的な企業がPWAを採用し、ビジネス成果を上げています。Twitterは「Twitter Lite」PWAの導入で、セッション数が65%増加し、送信ツイート数が75%増加しました。Starbucksは、PWA版の注文アプリでネイティブアプリの99.84%小さいサイズを実現し、デスクトップでのDAU(日次アクティブユーザー)が2倍になりました。

Web App Manifestの設定

Web App Manifestは、PWAのメタ情報を定義するJSONファイルです。ブラウザはこのファイルを参照して、アプリのインストール体験を提供します。

manifest.jsonの基本構成

{
  "name": "マイPWAアプリ",
  "short_name": "マイアプリ",
  "description": "PWAのサンプルアプリケーション",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "orientation": "portrait-primary",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

各プロパティの役割を解説します。

name / short_namenameはアプリのフルネーム、short_nameはホーム画面のアイコン下に表示される短い名前です。

start_url:ホーム画面からアプリを起動した際に開くURLです。

display:アプリの表示モードを指定します。standaloneはブラウザのUIを非表示にし、ネイティブアプリのような見た目になります。fullscreenはゲームなどに適し、minimal-uiは最小限のナビゲーションを表示します。

background_color:アプリ起動時のスプラッシュスクリーンの背景色です。

theme_color:ブラウザのアドレスバーやステータスバーの色です。

icons:各サイズのアプリアイコンを指定します。192x192と512x512は必須です。purpose: "maskable"はAndroidのアダプティブアイコンに対応するためのものです。

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="#2563eb">
  <link rel="manifest" href="/manifest.json">
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">
  <title>マイPWAアプリ</title>
</head>
<body>
  <!-- アプリのコンテンツ -->
  <script src="/app.js"></script>
</body>
</html>

apple-touch-iconは、iOS Safariでホーム画面に追加する際のアイコンとして使用されます。iOSではmanifestのicons設定が完全にはサポートされていないため、この指定が必要です。

Service Workerの基本と登録

Service Workerは、PWAの核となる技術です。ブラウザとネットワークの間に位置し、リクエストの傍受、キャッシュの管理、プッシュ通知の受信などを行います。

Service Workerの登録

メインのJavaScriptファイルからService Workerを登録します。

// app.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker 登録成功:', registration.scope);
    } catch (error) {
      console.error('Service Worker 登録失敗:', error);
    }
  });
}

'serviceWorker' in navigatorでブラウザのサポートを確認してから登録します。Service Workerのファイル(sw.js)はサイトのルートに配置するのが一般的です。

Service Workerのライフサイクル

Service Workerには3つの主要なライフサイクルイベントがあります。

install:Service Workerが初めてインストールされる時に発火します。静的アセットのキャッシュを行うのに最適なタイミングです。

activate:インストール完了後に発火します。古いキャッシュの削除などのクリーンアップ処理を行います。

fetch:ページからのネットワークリクエストを傍受します。キャッシュから返すか、ネットワークにフォワードするかを制御できます。

// sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192x192.png',
  '/offline.html'
];

// インストール時に静的アセットをキャッシュ
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('静的アセットをキャッシュ中...');
      return cache.addAll(STATIC_ASSETS);
    })
  );
});

// 古いキャッシュの削除
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

オフライン対応のキャッシュ戦略

Service Workerのfetchイベントで、さまざまなキャッシュ戦略を実装できます。コンテンツの種類に応じて適切な戦略を選びましょう。

Cache First(キャッシュ優先)

キャッシュにあればキャッシュから返し、なければネットワークにフォールバックする戦略です。静的アセット(CSS、JavaScript、画像など)に適しています。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse; // キャッシュがあればそれを返す
      }
      return fetch(event.request).then((networkResponse) => {
        // ネットワークから取得したレスポンスをキャッシュに保存
        const responseClone = networkResponse.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return networkResponse;
      });
    })
  );
});

Network First(ネットワーク優先)

まずネットワークにリクエストし、失敗した場合にキャッシュにフォールバックする戦略です。APIレスポンスや頻繁に更新されるコンテンツに適しています。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((networkResponse) => {
        // 成功したらキャッシュを更新して返す
        const responseClone = networkResponse.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return networkResponse;
      })
      .catch(() => {
        // ネットワーク失敗時はキャッシュから返す
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate(キャッシュ返却+バックグラウンド更新)

キャッシュがあればすぐに返しつつ、バックグラウンドでネットワークから最新版を取得してキャッシュを更新する戦略です。ニュースフィードやSNSのタイムラインなど、速度と鮮度のバランスが必要なコンテンツに適しています。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        // キャッシュがあればすぐ返し、なければネットワークを待つ
        return cachedResponse || fetchPromise;
      });
    })
  );
});

実践的なキャッシュ戦略の組み合わせ

実際のアプリケーションでは、リソースの種類に応じて異なる戦略を組み合わせます。

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // API リクエスト → Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // 静的アセット → Cache First
  if (request.destination === 'style' ||
      request.destination === 'script' ||
      request.destination === 'image') {
    event.respondWith(cacheFirst(request));
    return;
  }

  // HTML ページ → Network First(オフライン時はフォールバックページ)
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request).catch(() => caches.match('/offline.html'))
    );
    return;
  }
});

プッシュ通知の実装

PWAのプッシュ通知は、ユーザーがアプリを開いていなくても通知を送れる強力な機能です。

通知の許可リクエスト

プッシュ通知を送るには、まずユーザーの許可が必要です。

// 通知の許可を求める
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();

  if (permission === 'granted') {
    console.log('通知が許可されました');
    await subscribeToPush();
  } else if (permission === 'denied') {
    console.log('通知が拒否されました');
  }
}

// ユーザーの操作(ボタンクリック等)をトリガーにする
document.getElementById('notifyBtn').addEventListener('click', () => {
  requestNotificationPermission();
});

ページ読み込み直後に許可を求めるのは避けましょう。ユーザーが通知の価値を理解できるタイミング(例:アカウント設定画面)で許可をリクエストするのがベストプラクティスです。

Push APIによるサブスクリプション

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  // サブスクリプション情報をサーバーに送信
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// VAPID鍵のBase64をUint8Arrayに変換するユーティリティ
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

VAPID(Voluntary Application Server Identification)鍵は、サーバーがプッシュ通知を送信する権限を証明するための鍵ペアです。公開鍵はクライアントで、秘密鍵はサーバーで使用します。

Service Workerでの通知表示

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || '新しい通知があります',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/'
    },
    actions: [
      { action: 'open', title: '開く' },
      { action: 'close', title: '閉じる' }
    ]
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'お知らせ', options)
  );
});

// 通知クリック時の処理
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

サーバーからの通知送信(Node.js)

// server.js
const webpush = require('web-push');

// VAPID鍵の設定
webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// プッシュ通知の送信
async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: '新着メッセージ',
        body: 'メッセージが届きました',
        url: '/messages'
      })
    );
    console.log('プッシュ通知送信成功');
  } catch (error) {
    console.error('プッシュ通知送信失敗:', error);
    if (error.statusCode === 410) {
      // サブスクリプションが無効 → DBから削除
      await removeSubscription(subscription.endpoint);
    }
  }
}

インストール体験とアップデート管理

PWAのインストール体験とバージョン管理は、ユーザーの継続利用に影響する重要な要素です。

インストールプロンプトのカスタマイズ

ブラウザのデフォルトのインストールプロンプトを制御し、最適なタイミングでインストールを促すことができます。

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // デフォルトのプロンプトを抑制
  event.preventDefault();
  deferredPrompt = event;

  // カスタムインストールUIを表示
  document.getElementById('installBanner').style.display = 'block';
});

document.getElementById('installBtn').addEventListener('click', async () => {
  if (deferredPrompt) {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log(`インストール結果: ${outcome}`);
    deferredPrompt = null;
    document.getElementById('installBanner').style.display = 'none';
  }
});

// インストール完了の検知
window.addEventListener('appinstalled', () => {
  console.log('PWAがインストールされました');
  deferredPrompt = null;
});

アップデートの通知

Service Workerが更新された場合、ユーザーに更新の適用を促す仕組みを実装しましょう。

// app.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').then((registration) => {
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;

      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // 新しいバージョンが利用可能
          showUpdateNotification();
        }
      });
    });
  });
}

function showUpdateNotification() {
  const banner = document.getElementById('updateBanner');
  banner.style.display = 'block';

  document.getElementById('updateBtn').addEventListener('click', () => {
    // Service WorkerにskipWaitingを指示
    navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
    window.location.reload();
  });
}

// sw.js
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

PWA開発のベストプラクティスとツール

PWA開発を効率化するためのベストプラクティスとツールを紹介します。

Workboxの活用

Workboxは、Googleが提供するService Worker用のライブラリで、キャッシュ戦略の実装を大幅に簡略化します。

// sw.js(Workbox使用)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// ビルド時に生成されるプリキャッシュリスト
precacheAndRoute(self.__WB_MANIFEST);

// 画像のキャッシュ戦略
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
      }),
    ],
  })
);

// APIリクエストのキャッシュ戦略
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5分
      }),
    ],
  })
);

// CSSとJSのキャッシュ戦略
registerRoute(
  ({ request }) =>
    request.destination === 'style' || request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
  })
);

Workboxは、webpack、Vite、Rollupなどのビルドツールと統合するプラグインも提供しており、プリキャッシュリストの自動生成が可能です。

Lighthouseによるチェック

Chrome DevToolsのLighthouseタブで「Progressive Web App」の監査を実行すると、PWAの要件を満たしているかどうかをチェックできます。

主なチェック項目には以下が含まれます。

Service Workerが登録されているか

オフラインで動作するか

Web App Manifestが正しく設定されているか

HTTPSが使用されているか

適切なアイコンが設定されているか

フレームワーク別のPWA対応

Next.jsnext-pwaプラグインを使うことで、設定ファイルの追記だけでPWA対応が可能です。Workboxの設定も自動的に行われます。

Vitevite-plugin-pwaを使うことで、ビルド時にService WorkerとManifestが自動生成されます。

Astro@vite-pwa/astroインテグレーションで、静的サイトにもPWA機能を追加できます。

まとめ:PWAでWebアプリの体験を向上させよう

本記事では、PWAの基礎概念からService Workerによるオフライン対応、プッシュ通知の実装、キャッシュ戦略まで解説しました。

PWA導入のステップを整理します。

HTTPS化とManifestの設定から始める:まずはHTTPS環境を確保し、manifest.jsonを作成してホーム画面追加に対応しましょう。これだけでもユーザー体験は向上します。

基本的なキャッシュ戦略を実装する:Service Workerで静的アセットのキャッシュを行い、オフラインでもアプリが表示されるようにしましょう。Workboxを使えば少ないコードで実現できます。

キャッシュ戦略をリソース別に最適化する:静的アセットはCache First、APIはNetwork First、ニュースフィードはStale While Revalidateなど、コンテンツの特性に応じた戦略を選択しましょう。

プッシュ通知は慎重に導入する:プッシュ通知は強力な機能ですが、乱用するとユーザーの離脱を招きます。ユーザーにとって本当に価値のある通知のみを送り、頻度も適切にコントロールしましょう。

Lighthouseで継続的にチェックする:PWAの品質を維持するために、定期的にLighthouseでスコアを確認し、改善点があれば対応しましょう。

PWAは、Webの技術だけでネイティブアプリに近い体験を提供できる強力なアプローチです。段階的に機能を追加していけるのがPWAの大きな利点ですので、まずは小さく始めて、ユーザーの反応を見ながら機能を拡充していくのがおすすめです。

#PWA#オフライン#Service Worker
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう