T-CREATOR

Vite × PWA:プログレッシブ Web アプリの作り方

Vite × PWA:プログレッシブ Web アプリの作り方

近年、Web アプリケーションの世界では「ネイティブアプリのような体験を Web で実現したい」という需要が急速に高まっています。その答えとして注目されているのが PWA(Progressive Web App)です。

PWA は、Web 技術を使いながらもネイティブアプリのような機能を提供できる革新的なアプローチですね。オフライン動作、プッシュ通知、ホーム画面へのインストールなど、従来の Web アプリでは実現困難だった機能を実現できます。

そして、この PWA 開発において、Vite が強力な開発環境を提供してくれるのです。従来の複雑な設定や煩雑な手順を大幅に簡素化し、開発者が PWA の本質的な機能実装に集中できる環境を整えてくれます。

今回は、Vite を活用した PWA 開発の全工程を、実際のコード例とともに詳しく解説いたします。初心者の方でも安心して PWA 開発を始められるよう、段階的に進めてまいりましょう。

背景

PWA が注目される理由

現代の Web 開発において、PWA が注目される背景には、ユーザーの期待値の変化があります。スマートフォンの普及により、ユーザーはアプリのような滑らかな操作性と、瞬時の起動速度を Web アプリにも求めるようになりました。

特に以下のような場面で、PWA の価値が際立ちます。

#シーンPWA の効果
1オフライン環境ネットワーク断絶時でも基本機能を提供
2低速回線での利用キャッシュ活用による高速表示
3モバイルデバイスネイティブアプリ風の操作体験
4通知が重要なサービスプッシュ通知による能動的な情報提供
5頻繁にアクセスするサイトホーム画面インストールによる利便性

Vite で PWA を構築するメリット

Vite を使用した PWA 開発には、従来の開発手法と比較して圧倒的なメリットがあります。

まず、開発速度の向上が挙げられますね。Vite の高速な HMR(Hot Module Replacement)により、PWA 機能の開発中でも瞬時に変更が反映されます。Service Worker の更新や Manifest ファイルの変更も、リアルタイムで確認できるのです。

typescript// vite.config.ts での PWA 設定例
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      // 開発中でも PWA 機能をテスト可能
      devOptions: {
        enabled: true,
      },
      // リアルタイムでの設定反映
      registerType: 'autoUpdate',
    }),
  ],
});

次に、設定の簡素化です。従来の PWA 開発では、Service Worker の手動作成、Manifest ファイルの詳細設定、キャッシュ戦略の実装など、多くの複雑な作業が必要でした。Vite の PWA プラグインは、これらの作業を自動化してくれます。

従来の PWA 開発手法との違い

従来の PWA 開発では、以下のような課題がありました。

手動での Service Worker 作成

javascript// 従来の手動 Service Worker(複雑で保守困難)
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/script.js',
        // 手動でファイル一覧を管理する必要
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
  // 複雑なキャッシュ戦略を手動実装
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

Vite を使用することで、これらの複雑な実装が大幅に簡素化されます。

#従来の手法Vite での手法
1手動 Service Worker自動生成・最適化
2手動キャッシュ管理戦略的キャッシング自動設定
3複雑な Manifest 設定設定ファイルでの簡単管理
4手動でのファイル更新HMR による即座反映
5デバッグの困難さ開発ツールとの統合

課題

ネイティブアプリとの差別化

PWA 開発において最初に直面する課題は、「なぜネイティブアプリではなく PWA を選ぶのか」という根本的な問いです。ユーザーは既にアプリストアからのダウンロードに慣れ親しんでおり、PWA の価値を理解してもらうのは簡単ではありません。

特に以下の点で、ネイティブアプリとの差別化が求められます。

パフォーマンスの課題 ネイティブアプリと比較して、Web ベースの PWA は初期起動時間やアニメーションの滑らかさで劣る場合があります。ユーザーは瞬時の反応を期待しており、わずかな遅延でも離脱の原因となってしまいます。

機能制限の課題 Web API の制限により、カメラの高度な制御、ファイルシステムへの深いアクセス、バックグラウンドでの長時間処理など、ネイティブアプリでは当たり前の機能が制限される場合があります。

PWA 実装の複雑さ

PWA の実装には、従来の Web 開発にはない複雑さがあります。特に初心者にとって、以下の要素が大きな障壁となります。

Service Worker の理解 Service Worker は PWA の核心技術ですが、その動作原理は直感的ではありません。メインスレッドとは独立して動作し、イベント駆動型のライフサイクルを持つため、従来の JavaScript 開発とは異なる思考が必要です。

javascript// Service Worker のライフサイクルイベント(理解が困難)
self.addEventListener('install', (event) => {
  // インストール時の処理
  console.log('Service Worker: Install');
});

self.addEventListener('activate', (event) => {
  // アクティベート時の処理
  console.log('Service Worker: Activate');
});

self.addEventListener('fetch', (event) => {
  // ネットワークリクエストの制御
  console.log('Service Worker: Fetch', event.request.url);
});

キャッシュ戦略の選択 適切なキャッシュ戦略の選択は、PWA のパフォーマンスを大きく左右します。しかし、どのリソースにどの戦略を適用すべきかの判断は、経験と深い理解が必要です。

#戦略名適用場面課題
1Cache First静的リソース更新タイミングの制御
2Network First動的コンテンツオフライン時の代替処理
3Stale While Revalidate頻繁に更新されるデータキャッシュ整合性の管理
4Network Only認証が必要なリクエストオフライン対応の困難さ

パフォーマンスとユーザビリティの両立

PWA では、豊富な機能とパフォーマンスの両立が常に課題となります。多機能な PWA ほど、以下のような問題に直面します。

バンドルサイズの増加 PWA 機能を追加するほど、JavaScript バンドルのサイズが増加し、初期読み込み時間に影響します。特に Service Worker、Web App Manifest、各種ポリフィルの追加により、軽量性が損なわれる可能性があります。

メモリ使用量の増加 Service Worker は独立したスレッドで動作するため、追加のメモリを消費します。また、キャッシュデータの蓄積により、デバイスのストレージを圧迫する可能性もあります。

解決策

Vite PWA プラグインの活用

これらの課題を解決する最も効果的な方法が、vite-plugin-pwa の活用です。このプラグインは、PWA 開発の複雑さを大幅に軽減し、ベストプラクティスを自動的に適用してくれます。

プラグインのインストール

bash# Yarn を使用したインストール
yarn add -D vite-plugin-pwa

# 必要な型定義も追加
yarn add -D @types/vite-plugin-pwa

基本設定の実装

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

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        // キャッシュ戦略の自動最適化
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24 * 365, // 1年
              },
              cacheKeyWillBeUsed: async ({ request }) => {
                return `${request.url}?version=1`;
              },
            },
          },
        ],
      },
      manifest: {
        name: 'My PWA App',
        short_name: 'PWA App',
        description: 'Vite で構築した PWA アプリケーション',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
        ],
      },
    }),
  ],
});

Service Worker の自動生成

Vite PWA プラグインの最大の利点は、Service Worker の自動生成機能です。手動での複雑な実装が不要になり、最適化されたコードが自動的に生成されます。

自動生成される Service Worker の特徴

javascript// 自動生成される Service Worker の例(簡略化)
import {
  precacheAndRoute,
  cleanupOutdatedCaches,
} from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  StaleWhileRevalidate,
  CacheFirst,
} from 'workbox-strategies';

// 古いキャッシュの自動クリーンアップ
cleanupOutdatedCaches();

// プリキャッシュファイルの自動登録
precacheAndRoute(self.__WB_MANIFEST);

// 画像リソースのキャッシュ戦略
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      {
        cacheKeyWillBeUsed: async ({ request }) => {
          // キャッシュキーの最適化
          return request.url;
        },
      },
    ],
  })
);

開発環境での Service Worker テスト

typescript// vite.config.ts での開発環境設定
export default defineConfig({
  plugins: [
    VitePWA({
      devOptions: {
        enabled: true, // 開発環境でも Service Worker を有効化
        type: 'module', // ES Module として読み込み
      },
      // 開発中のデバッグ情報を有効化
      workbox: {
        mode: 'development',
      },
    }),
  ],
});

マニフェスト設定の最適化

Web App Manifest は PWA の重要な構成要素です。適切な設定により、ネイティブアプリに近い体験を提供できます。

詳細なマニフェスト設定

typescript// vite.config.ts でのマニフェスト最適化
VitePWA({
  manifest: {
    name: 'My Progressive Web App',
    short_name: 'MyPWA',
    description: '最高のユーザー体験を提供する PWA アプリ',
    lang: 'ja',
    start_url: '/',
    display: 'standalone',
    orientation: 'portrait-primary',
    theme_color: '#2196F3',
    background_color: '#ffffff',
    categories: ['productivity', 'utilities'],

    // 豊富なアイコン設定
    icons: [
      {
        src: '/icons/pwa-64x64.png',
        sizes: '64x64',
        type: 'image/png',
      },
      {
        src: '/icons/pwa-192x192.png',
        sizes: '192x192',
        type: 'image/png',
      },
      {
        src: '/icons/pwa-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'any',
      },
      {
        src: '/icons/maskable-icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'maskable', // Android の適応アイコン対応
      },
    ],

    // ショートカット機能
    shortcuts: [
      {
        name: '新規作成',
        short_name: '新規',
        description: '新しいドキュメントを作成',
        url: '/new',
        icons: [
          {
            src: '/icons/new-96x96.png',
            sizes: '96x96',
          },
        ],
      },
    ],

    // スクリーンショット(デスクトップ PWA 用)
    screenshots: [
      {
        src: '/screenshots/desktop-1.png',
        sizes: '1280x720',
        type: 'image/png',
        form_factor: 'wide',
      },
      {
        src: '/screenshots/mobile-1.png',
        sizes: '750x1334',
        type: 'image/png',
        form_factor: 'narrow',
      },
    ],
  },
});

カスタムマニフェスト生成

より高度な制御が必要な場合は、カスタムマニフェスト生成関数を使用できます。

typescript// vite.config.ts でのカスタムマニフェスト
VitePWA({
  manifest: false, // デフォルトマニフェストを無効化
  manifestFilename: 'app.webmanifest',

  // カスタムマニフェスト生成
  workbox: {
    additionalManifestEntries: [
      { url: '/custom-offline.html', revision: null },
    ],
  },
});

具体例

基本的な PWA プロジェクトの作成

実際に Vite を使用して PWA プロジェクトを作成してみましょう。段階的に進めることで、PWA の各機能を確実に理解できます。

プロジェクトの初期化

bash# React + TypeScript プロジェクトの作成
yarn create vite my-pwa-app --template react-ts

# プロジェクトディレクトリに移動
cd my-pwa-app

# 依存関係のインストール
yarn install

# PWA プラグインの追加
yarn add -D vite-plugin-pwa

基本的な PWA 設定

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

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: [
        'favicon.ico',
        'apple-touch-icon.png',
        'mask-icon.svg',
      ],
      manifest: {
        name: 'My PWA Application',
        short_name: 'MyPWA',
        description: 'Vite で作成した PWA アプリケーション',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        scope: '/',
        start_url: '/',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
        ],
      },
    }),
  ],
});

React コンポーネントでの PWA 機能統合

typescript// src/components/PWAInstallPrompt.tsx
import React, { useState, useEffect } from 'react';

interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: 'accepted' | 'dismissed';
    platform: string;
  }>;
  prompt(): Promise<void>;
}

export const PWAInstallPrompt: React.FC = () => {
  const [deferredPrompt, setDeferredPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [showInstallButton, setShowInstallButton] =
    useState(false);

  useEffect(() => {
    const handleBeforeInstallPrompt = (e: Event) => {
      // デフォルトのインストールプロンプトを防止
      e.preventDefault();

      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setShowInstallButton(true);
    };

    window.addEventListener(
      'beforeinstallprompt',
      handleBeforeInstallPrompt
    );

    return () => {
      window.removeEventListener(
        'beforeinstallprompt',
        handleBeforeInstallPrompt
      );
    };
  }, []);

  const handleInstallClick = async () => {
    if (!deferredPrompt) return;

    // インストールプロンプトを表示
    deferredPrompt.prompt();

    // ユーザーの選択を待機
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      console.log('PWA インストールが承認されました');
    } else {
      console.log('PWA インストールが拒否されました');
    }

    setDeferredPrompt(null);
    setShowInstallButton(false);
  };

  if (!showInstallButton) return null;

  return (
    <div className='install-prompt'>
      <p>このアプリをホーム画面に追加しませんか?</p>
      <button
        onClick={handleInstallClick}
        className='install-button'
      >
        インストール
      </button>
    </div>
  );
};

オフライン機能の実装

PWA の核心機能であるオフライン対応を実装しましょう。Vite PWA プラグインを使用することで、複雑なキャッシュ戦略も簡単に設定できます。

オフライン対応の設定

typescript// vite.config.ts でのオフライン設定
VitePWA({
  workbox: {
    // オフライン時のフォールバックページ
    navigateFallback: '/offline.html',
    navigateFallbackDenylist: [/^\/_/, /\/[^/?]+\.[^/]+$/],

    // キャッシュ戦略の詳細設定
    runtimeCaching: [
      {
        // API レスポンスのキャッシュ
        urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'api-cache',
          expiration: {
            maxEntries: 50,
            maxAgeSeconds: 60 * 60 * 24, // 24時間
          },
          networkTimeoutSeconds: 10,
        },
      },
      {
        // 画像リソースのキャッシュ
        urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'images-cache',
          expiration: {
            maxEntries: 100,
            maxAgeSeconds: 60 * 60 * 24 * 30, // 30日
          },
        },
      },
      {
        // Google Fonts のキャッシュ
        urlPattern:
          /^https:\/\/fonts\.googleapis\.com\/.*/i,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts-cache',
          expiration: {
            maxEntries: 10,
            maxAgeSeconds: 60 * 60 * 24 * 365, // 1年
          },
        },
      },
    ],
  },
});

オフライン状態の検知と UI 表示

typescript// src/hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';

export const useOnlineStatus = () => {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine
  );

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
};
typescript// src/components/OfflineIndicator.tsx
import React from 'react';
import { useOnlineStatus } from '../hooks/useOnlineStatus';

export const OfflineIndicator: React.FC = () => {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;

  return (
    <div className='offline-indicator'>
      <div className='offline-banner'>
        <span className='offline-icon'>📱</span>
        <p>オフラインモードで動作中です</p>
        <small>
          インターネット接続が復旧すると自動的に同期されます
        </small>
      </div>
    </div>
  );
};

オフライン用フォールバックページ

html<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>オフライン - My PWA App</title>
    <style>
      body {
        font-family: -apple-system, BlinkMacSystemFont,
          'Segoe UI', Roboto, sans-serif;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        min-height: 100vh;
        margin: 0;
        background: linear-gradient(
          135deg,
          #667eea 0%,
          #764ba2 100%
        );
        color: white;
        text-align: center;
        padding: 20px;
      }
      .offline-container {
        max-width: 400px;
      }
      .offline-icon {
        font-size: 4rem;
        margin-bottom: 1rem;
      }
      h1 {
        margin-bottom: 1rem;
        font-size: 2rem;
      }
      p {
        margin-bottom: 2rem;
        opacity: 0.9;
        line-height: 1.6;
      }
      .retry-button {
        background: rgba(255, 255, 255, 0.2);
        border: 2px solid rgba(255, 255, 255, 0.3);
        color: white;
        padding: 12px 24px;
        border-radius: 8px;
        cursor: pointer;
        font-size: 1rem;
        transition: all 0.3s ease;
      }
      .retry-button:hover {
        background: rgba(255, 255, 255, 0.3);
        transform: translateY(-2px);
      }
    </style>
  </head>
  <body>
    <div class="offline-container">
      <div class="offline-icon">📱</div>
      <h1>オフラインです</h1>
      <p>
        インターネット接続を確認してください。<br />
        一部の機能はオフラインでも利用できます。
      </p>
      <button
        class="retry-button"
        onclick="window.location.reload()"
      >
        再試行
      </button>
    </div>
  </body>
</html>

プッシュ通知の設定

PWA の魅力的な機能の一つであるプッシュ通知を実装しましょう。ユーザーエンゲージメントの向上に大きく貢献します。

プッシュ通知の基本設定

typescript// src/utils/pushNotification.ts
export class PushNotificationManager {
  private static instance: PushNotificationManager;
  private registration: ServiceWorkerRegistration | null =
    null;

  private constructor() {}

  public static getInstance(): PushNotificationManager {
    if (!PushNotificationManager.instance) {
      PushNotificationManager.instance =
        new PushNotificationManager();
    }
    return PushNotificationManager.instance;
  }

  // Service Worker 登録の確認
  public async initialize(): Promise<boolean> {
    if (
      !('serviceWorker' in navigator) ||
      !('PushManager' in window)
    ) {
      console.error('プッシュ通知がサポートされていません');
      return false;
    }

    try {
      this.registration = await navigator.serviceWorker
        .ready;
      return true;
    } catch (error) {
      console.error(
        'Service Worker の初期化に失敗:',
        error
      );
      return false;
    }
  }

  // 通知許可の要求
  public async requestPermission(): Promise<NotificationPermission> {
    const permission =
      await Notification.requestPermission();

    if (permission === 'granted') {
      console.log('プッシュ通知が許可されました');
    } else if (permission === 'denied') {
      console.log('プッシュ通知が拒否されました');
    } else {
      console.log('プッシュ通知の許可が保留中です');
    }

    return permission;
  }

  // プッシュ通知の購読
  public async subscribe(
    vapidPublicKey: string
  ): Promise<PushSubscription | null> {
    if (!this.registration) {
      console.error('Service Worker が登録されていません');
      return null;
    }

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

      console.log(
        'プッシュ通知の購読が完了:',
        subscription
      );
      return subscription;
    } catch (error) {
      console.error('プッシュ通知の購読に失敗:', error);
      return null;
    }
  }

  // VAPID キーの変換ユーティリティ
  private urlBase64ToUint8Array(
    base64String: string
  ): Uint8Array {
    const padding = '='.repeat(
      (4 - (base64String.length % 4)) % 4
    );
    const base64 = (base64String + padding)
      .replace(/-/g, '+')
      .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
  }

  // 通知の表示
  public async showNotification(
    title: string,
    options: NotificationOptions = {}
  ): Promise<void> {
    if (!this.registration) {
      console.error('Service Worker が登録されていません');
      return;
    }

    const defaultOptions: NotificationOptions = {
      body: 'PWA からの通知です',
      icon: '/pwa-192x192.png',
      badge: '/badge-72x72.png',
      vibrate: [200, 100, 200],
      data: {
        timestamp: Date.now(),
      },
      actions: [
        {
          action: 'open',
          title: '開く',
          icon: '/icons/open.png',
        },
        {
          action: 'close',
          title: '閉じる',
          icon: '/icons/close.png',
        },
      ],
    };

    const mergedOptions = { ...defaultOptions, ...options };

    try {
      await this.registration.showNotification(
        title,
        mergedOptions
      );
    } catch (error) {
      console.error('通知の表示に失敗:', error);
    }
  }
}

React コンポーネントでの通知機能統合

typescript// src/components/NotificationManager.tsx
import React, { useState, useEffect } from 'react';
import { PushNotificationManager } from '../utils/pushNotification';

const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY_HERE'; // 実際のキーに置き換え

export const NotificationManager: React.FC = () => {
  const [permission, setPermission] =
    useState<NotificationPermission>('default');
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [loading, setLoading] = useState(false);

  const notificationManager =
    PushNotificationManager.getInstance();

  useEffect(() => {
    // 初期化と現在の許可状態を確認
    const initializeNotifications = async () => {
      const initialized =
        await notificationManager.initialize();
      if (initialized) {
        setPermission(Notification.permission);
      }
    };

    initializeNotifications();
  }, []);

  const handleRequestPermission = async () => {
    setLoading(true);

    try {
      const newPermission =
        await notificationManager.requestPermission();
      setPermission(newPermission);

      if (newPermission === 'granted') {
        const subscription =
          await notificationManager.subscribe(
            VAPID_PUBLIC_KEY
          );
        setIsSubscribed(!!subscription);

        // サーバーに購読情報を送信
        if (subscription) {
          await sendSubscriptionToServer(subscription);
        }
      }
    } catch (error) {
      console.error('通知許可の処理に失敗:', error);
    } finally {
      setLoading(false);
    }
  };

  const sendSubscriptionToServer = async (
    subscription: PushSubscription
  ) => {
    try {
      const response = await fetch(
        '/api/push-subscription',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            subscription,
            timestamp: Date.now(),
          }),
        }
      );

      if (!response.ok) {
        throw new Error(
          `HTTP error! status: ${response.status}`
        );
      }

      console.log('購読情報をサーバーに送信しました');
    } catch (error) {
      console.error('購読情報の送信に失敗:', error);
    }
  };

  const handleTestNotification = async () => {
    await notificationManager.showNotification(
      'テスト通知',
      {
        body: 'PWA の通知機能が正常に動作しています!',
        icon: '/pwa-192x192.png',
        tag: 'test-notification',
      }
    );
  };

  const getPermissionStatus = () => {
    switch (permission) {
      case 'granted':
        return { text: '許可済み', color: 'green' };
      case 'denied':
        return { text: '拒否済み', color: 'red' };
      default:
        return { text: '未設定', color: 'orange' };
    }
  };

  const status = getPermissionStatus();

  return (
    <div className='notification-manager'>
      <h3>プッシュ通知設定</h3>

      <div className='status-display'>
        <p>
          現在の状態:
          <span
            style={{
              color: status.color,
              fontWeight: 'bold',
            }}
          >
            {status.text}
          </span>
        </p>
      </div>

      {permission === 'default' && (
        <button
          onClick={handleRequestPermission}
          disabled={loading}
          className='permission-button'
        >
          {loading ? '処理中...' : '通知を許可する'}
        </button>
      )}

      {permission === 'granted' && (
        <div className='notification-controls'>
          <button
            onClick={handleTestNotification}
            className='test-button'
          >
            テスト通知を送信
          </button>

          {isSubscribed && (
            <p className='subscription-status'>
              ✅ プッシュ通知の購読が完了しています
            </p>
          )}
        </div>
      )}

      {permission === 'denied' && (
        <div className='denied-message'>
          <p>
            通知が拒否されています。ブラウザの設定から許可してください。
          </p>
          <details>
            <summary>設定方法を見る</summary>
            <ol>
              <li>
                ブラウザのアドレスバー左側の鍵アイコンをクリック
              </li>
              <li>「通知」の設定を「許可」に変更</li>
              <li>ページを再読み込み</li>
            </ol>
          </details>
        </div>
      )}
    </div>
  );
};

インストール可能なアプリの構築

PWA の大きな魅力の一つは、ブラウザを介さずにネイティブアプリのように起動できることです。適切な設定により、ユーザーはホーム画面からアプリを直接起動できます。

アプリインストール体験の最適化

typescript// src/components/InstallBanner.tsx
import React, { useState, useEffect } from 'react';

interface InstallBannerProps {
  onInstallSuccess?: () => void;
  onInstallDeclined?: () => void;
}

export const InstallBanner: React.FC<
  InstallBannerProps
> = ({ onInstallSuccess, onInstallDeclined }) => {
  const [installPrompt, setInstallPrompt] =
    useState<any>(null);
  const [showBanner, setShowBanner] = useState(false);
  const [isInstalling, setIsInstalling] = useState(false);

  useEffect(() => {
    const handleBeforeInstallPrompt = (e: Event) => {
      // デフォルトのプロンプトを防止
      e.preventDefault();
      setInstallPrompt(e);
      setShowBanner(true);
    };

    const handleAppInstalled = () => {
      console.log('PWA がインストールされました');
      setShowBanner(false);
      setInstallPrompt(null);
      onInstallSuccess?.();
    };

    window.addEventListener(
      'beforeinstallprompt',
      handleBeforeInstallPrompt
    );
    window.addEventListener(
      'appinstalled',
      handleAppInstalled
    );

    return () => {
      window.removeEventListener(
        'beforeinstallprompt',
        handleBeforeInstallPrompt
      );
      window.removeEventListener(
        'appinstalled',
        handleAppInstalled
      );
    };
  }, [onInstallSuccess]);

  const handleInstall = async () => {
    if (!installPrompt) return;

    setIsInstalling(true);

    try {
      // インストールプロンプトを表示
      await installPrompt.prompt();

      // ユーザーの選択を待機
      const choiceResult = await installPrompt.userChoice;

      if (choiceResult.outcome === 'accepted') {
        console.log('ユーザーがインストールを承認しました');
      } else {
        console.log('ユーザーがインストールを拒否しました');
        onInstallDeclined?.();
      }
    } catch (error) {
      console.error(
        'インストール処理でエラーが発生:',
        error
      );
    } finally {
      setIsInstalling(false);
      setInstallPrompt(null);
      setShowBanner(false);
    }
  };

  const handleDismiss = () => {
    setShowBanner(false);
    onInstallDeclined?.();
  };

  if (!showBanner) return null;

  return (
    <div className='install-banner'>
      <div className='banner-content'>
        <div className='banner-icon'>
          <img
            src='/pwa-192x192.png'
            alt='App Icon'
            width='48'
            height='48'
          />
        </div>

        <div className='banner-text'>
          <h4>アプリをインストール</h4>
          <p>
            ホーム画面に追加して、より快適にご利用いただけます
          </p>
        </div>

        <div className='banner-actions'>
          <button
            onClick={handleInstall}
            disabled={isInstalling}
            className='install-btn primary'
          >
            {isInstalling
              ? 'インストール中...'
              : 'インストール'}
          </button>

          <button
            onClick={handleDismiss}
            className='install-btn secondary'
          >
            後で
          </button>
        </div>
      </div>
    </div>
  );
};

スタンドアロンモードの検知と最適化

typescript// src/hooks/useStandaloneMode.ts
import { useState, useEffect } from 'react';

export const useStandaloneMode = () => {
  const [isStandalone, setIsStandalone] = useState(false);

  useEffect(() => {
    // PWA がスタンドアロンモードで起動されているかを検知
    const checkStandaloneMode = () => {
      const isStandaloneMode =
        window.matchMedia('(display-mode: standalone)')
          .matches ||
        (window.navigator as any).standalone === true ||
        document.referrer.includes('android-app://');

      setIsStandalone(isStandaloneMode);
    };

    checkStandaloneMode();

    // display-mode の変更を監視
    const mediaQuery = window.matchMedia(
      '(display-mode: standalone)'
    );
    const handleChange = (e: MediaQueryListEvent) => {
      setIsStandalone(e.matches);
    };

    if (mediaQuery.addEventListener) {
      mediaQuery.addEventListener('change', handleChange);
    } else {
      // 古いブラウザ対応
      mediaQuery.addListener(handleChange);
    }

    return () => {
      if (mediaQuery.removeEventListener) {
        mediaQuery.removeEventListener(
          'change',
          handleChange
        );
      } else {
        mediaQuery.removeListener(handleChange);
      }
    };
  }, []);

  return isStandalone;
};
typescript// src/components/StandaloneLayout.tsx
import React from 'react';
import { useStandaloneMode } from '../hooks/useStandaloneMode';

interface StandaloneLayoutProps {
  children: React.ReactNode;
}

export const StandaloneLayout: React.FC<
  StandaloneLayoutProps
> = ({ children }) => {
  const isStandalone = useStandaloneMode();

  return (
    <div
      className={`app-layout ${
        isStandalone ? 'standalone' : 'browser'
      }`}
    >
      {isStandalone && (
        <div className='standalone-header'>
          <div className='status-bar-spacer' />
          <header className='app-header'>
            <h1>My PWA App</h1>
          </header>
        </div>
      )}

      <main className='app-content'>{children}</main>

      {isStandalone && (
        <div className='standalone-footer'>
          <div className='home-indicator' />
        </div>
      )}
    </div>
  );
};

PWA の動作確認とテスト

PWA の品質を保証するためには、適切なテストが不可欠です。Vite の開発環境を活用して、効率的にテストを行いましょう。

開発環境での PWA テスト

typescript// vite.config.ts での開発環境最適化
export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      devOptions: {
        enabled: true,
        type: 'module',
        navigateFallback: 'index.html',
      },
      workbox: {
        // 開発環境でのデバッグ情報を有効化
        mode:
          process.env.NODE_ENV === 'development'
            ? 'development'
            : 'production',

        // 開発中のファイル変更を即座に反映
        skipWaiting: true,
        clientsClaim: true,
      },
    }),
  ],

  // HTTPS での開発環境(PWA 機能のフルテストに必要)
  server: {
    https:
      process.env.NODE_ENV === 'development'
        ? {
            key: './certs/localhost-key.pem',
            cert: './certs/localhost.pem',
          }
        : false,
  },
});

自動テストの実装

typescript// src/__tests__/pwa.test.ts
import {
  describe,
  it,
  expect,
  beforeEach,
  vi,
} from 'vitest';

// Service Worker のモック
const mockServiceWorker = {
  register: vi.fn(),
  ready: Promise.resolve({
    showNotification: vi.fn(),
    pushManager: {
      subscribe: vi.fn(),
    },
  }),
};

// グローバルオブジェクトのモック
Object.defineProperty(window, 'navigator', {
  value: {
    serviceWorker: mockServiceWorker,
    onLine: true,
  },
  writable: true,
});

describe('PWA 機能のテスト', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('Service Worker が正しく登録される', async () => {
    const { PushNotificationManager } = await import(
      '../utils/pushNotification'
    );
    const manager = PushNotificationManager.getInstance();

    const result = await manager.initialize();

    expect(result).toBe(true);
    expect(mockServiceWorker.register).toHaveBeenCalled();
  });

  it('オフライン状態が正しく検知される', async () => {
    const { useOnlineStatus } = await import(
      '../hooks/useOnlineStatus'
    );

    // オフライン状態をシミュレート
    Object.defineProperty(window.navigator, 'onLine', {
      value: false,
      writable: true,
    });

    // オフラインイベントを発火
    window.dispatchEvent(new Event('offline'));

    // ここでhookのテストロジックを実装
    // 実際のReactコンポーネントテストではreact-testing-libraryを使用
  });

  it('インストールプロンプトが適切にハンドリングされる', () => {
    const mockEvent = {
      preventDefault: vi.fn(),
      prompt: vi.fn().mockResolvedValue(undefined),
      userChoice: Promise.resolve({ outcome: 'accepted' }),
    };

    // beforeinstallprompt イベントをシミュレート
    window.dispatchEvent(
      Object.assign(
        new Event('beforeinstallprompt'),
        mockEvent
      )
    );

    expect(mockEvent.preventDefault).toHaveBeenCalled();
  });
});

Lighthouse を使用した PWA 監査

json// package.json にスクリプトを追加
{
  "scripts": {
    "build": "vite build",
    "preview": "vite preview",
    "pwa-audit": "lighthouse http://localhost:4173 --only-categories=pwa --output=html --output-path=./lighthouse-report.html",
    "pwa-test": "yarn build && yarn preview & sleep 3 && yarn pwa-audit && kill %1"
  }
}

PWA チェックリスト

実際の本番環境にデプロイする前に、以下の項目を確認しましょう。

#チェック項目確認方法
1HTTPS での配信ブラウザのアドレスバーで鍵マークを確認
2Service Worker の登録DevTools の Application タブで確認
3Web App Manifest の存在DevTools の Application > Manifest で確認
4適切なアイコンサイズ192x192 と 512x512 の PNG ファイル
5オフライン動作ネットワークを無効にして動作確認
6インストール可能性ブラウザのインストールプロンプト確認
7レスポンシブデザイン各種デバイスサイズでの表示確認
8高速な初期読み込みLighthouse スコア 90 以上を目標

よくあるエラーと対処法

PWA 開発でよく遭遇するエラーとその解決方法をご紹介します。

bash# エラー1: Service Worker の登録失敗
Error: Failed to register service worker: SecurityError: Failed to register a ServiceWorker: The script has an unsupported MIME type ('text/html').

# 対処法: vite.config.ts で正しいMIMEタイプを設定
export default defineConfig({
  plugins: [
    VitePWA({
      filename: 'sw.js', // 正しいファイル名を指定
      strategies: 'injectManifest', // 必要に応じて戦略を変更
    }),
  ],
})
bash# エラー2: Manifest ファイルの読み込み失敗
Error: Manifest: Line: 1, column: 1, Syntax error.

# 対処法: マニフェストファイルの JSON 構文を確認
{
  "name": "My PWA App",
  "short_name": "PWA App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/pwa-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}
bash# エラー3: プッシュ通知の購読失敗
Error: DOMException: Registration failed - push service error

# 対処法: VAPID キーの設定と形式を確認
const vapidPublicKey = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';

// Base64 URL セーフ形式であることを確認
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);

まとめ

Vite を活用した PWA 開発は、従来の複雑な手順を大幅に簡素化し、開発者がより本質的な機能実装に集中できる環境を提供してくれます。

今回ご紹介した内容をまとめますと、以下のようになります。

Vite PWA の主要メリット

  • 自動化された Service Worker 生成により、複雑な実装が不要
  • 最適化されたキャッシュ戦略の自動適用
  • 開発環境での PWA 機能のリアルタイムテスト
  • TypeScript との完全な統合によるタイプセーフな開発

実装のポイント

  • vite-plugin-pwa による設定の一元管理
  • オフライン機能とプッシュ通知の段階的実装
  • インストール体験の最適化とユーザビリティの向上
  • 適切なテストとデバッグ環境の構築

PWA は、Web アプリケーションの未来を切り開く重要な技術です。Vite の強力な開発環境を活用することで、高品質な PWA を効率的に開発できるでしょう。

皆さんも、ぜひ今回の内容を参考に、魅力的な PWA アプリケーションの開発に挑戦してみてくださいね。ユーザーにとって価値のある、そして開発者にとっても保守しやすいアプリケーションが作れることでしょう。

関連リンク