T-CREATOR

SolidJS で始める PWA 開発ガイド

SolidJS で始める PWA 開発ガイド

現代の Web 開発において、ネイティブアプリのような体験を提供する PWA(Progressive Web App)は、もはや選択肢ではなく必須の技術となっています。

SolidJS という軽量で高速なフレームワークと PWA を組み合わせることで、ユーザーに驚くべき体験を提供できるアプリケーションを作成できます。

この記事では、SolidJS を使った PWA 開発の実践的な手順を、実際のエラーとその解決策も含めて詳しく解説していきます。

あなたも、この記事を読み終えるころには、SolidJS で PWA を開発する自信がついているはずです。

PWA とは

PWA(Progressive Web App)は、Web 技術を使って構築されながら、ネイティブアプリのような体験を提供するアプリケーションです。

PWA の主要な特徴

特徴説明ユーザー体験への影響
オフライン動作インターネット接続なしでも動作いつでもどこでも利用可能
インストール可能ホーム画面に追加できるネイティブアプリのような利便性
プッシュ通知リアルタイムで情報を配信ユーザーエンゲージメント向上
高速読み込みキャッシュによる高速化ストレスフリーな操作

PWA は、Web の柔軟性とネイティブアプリの利便性を両立させる革新的なアプローチです。

SolidJS の特徴と PWA 開発での利点

SolidJS は、React ライクな API を持ちながら、より軽量で高速なフレームワークです。

SolidJS の主要な特徴

リアクティブシステム SolidJS は、細粒度のリアクティブシステムを採用しており、必要な部分のみが更新されます。

javascript// SolidJSのリアクティブな状態管理
import { createSignal } from 'solid-js';

function Counter() {
  const [count, setCount] = createSignal(0);

  return (
    <div>
      <p>カウント: {count()}</p>
      <button onClick={() => setCount(count() + 1)}>
        増加
      </button>
    </div>
  );
}

軽量なバンドルサイズ SolidJS は約 7KB のランタイムサイズで、PWA の初期読み込み速度を大幅に改善します。

TypeScript サポート 型安全性を保ちながら、開発効率を向上させます。

PWA 開発での利点

  1. 高速な初期読み込み: 軽量なランタイムにより、PWA の起動が高速
  2. 効率的なメモリ使用: リアクティブシステムにより、メモリ使用量を最適化
  3. 開発者体験: 直感的な API で、PWA 機能の実装が容易

開発環境の準備

SolidJS で PWA 開発を始める前に、必要な開発環境を整えましょう。

必要なツール

Node.js のインストール まず、Node.js がインストールされていることを確認します。

bash# Node.jsのバージョン確認
node --version
# v18.0.0以上を推奨

# Yarnのインストール(まだの場合)
npm install -g yarn

エディタの準備 VS Code を使用する場合、以下の拡張機能をインストールすることをお勧めします:

  • SolidJS Snippets
  • TypeScript Importer
  • Auto Rename Tag

よくあるエラーと解決策

Node.js のバージョンエラー

bash# エラー例
Error: Node.js version 14.0.0 is not supported. Please upgrade to Node.js 16.0.0 or later.

# 解決策:Node.jsをアップグレード
# macOSの場合(Homebrew使用)
brew update
brew upgrade node

# または、nvmを使用
nvm install 18
nvm use 18

Yarn の権限エラー

bash# エラー例
Error: EACCES: permission denied, access '/usr/local/lib/node_modules'

# 解決策:権限を修正
sudo chown -R $USER /usr/local/lib/node_modules

SolidJS プロジェクトの作成

SolidJS プロジェクトを作成し、PWA 開発の基盤を整えましょう。

プロジェクトの初期化

bash# SolidJSプロジェクトの作成
yarn create solid my-pwa-app
cd my-pwa-app

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

プロジェクト構造の確認

作成されたプロジェクトの構造を確認しましょう。

bash# プロジェクト構造の表示
tree -I node_modules

典型的な構造は以下のようになります:

arduinomy-pwa-app/
├── public/
├── src/
│   ├── components/
│   ├── index.tsx
│   └── App.tsx
├── package.json
├── tsconfig.json
└── vite.config.ts

開発サーバーの起動

bash# 開発サーバーの起動
yarn dev

よくあるエラーと解決策

ポートが使用中のエラー

bash# エラー例
Error: listen EADDRINUSE: address already in use :::3000

# 解決策:別のポートを使用
yarn dev --port 3001

依存関係の競合エラー

bash# エラー例
error An unexpected error occurred: "ENOTEMPTY: directory not empty"

# 解決策:node_modulesを削除して再インストール
rm -rf node_modules
yarn install

PWA マニフェストの設定

PWA の基本となるマニフェストファイルを作成し、アプリのメタデータを定義しましょう。

マニフェストファイルの作成

public​/​manifest.jsonファイルを作成します。

json{
  "name": "My PWA App",
  "short_name": "PWA App",
  "description": "SolidJSで作成されたPWAアプリケーション",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

HTML ファイルでのマニフェスト読み込み

public​/​index.htmlファイルにマニフェストを読み込む設定を追加します。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1"
    />
    <meta name="theme-color" content="#000000" />

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

    <!-- iOS用のメタタグ -->
    <meta
      name="apple-mobile-web-app-capable"
      content="yes"
    />
    <meta
      name="apple-mobile-web-app-status-bar-style"
      content="default"
    />
    <meta
      name="apple-mobile-web-app-title"
      content="My PWA App"
    />

    <!-- アイコンの設定 -->
    <link rel="icon" href="/favicon.ico" />
    <link
      rel="apple-touch-icon"
      href="/icons/icon-192x192.png"
    />

    <title>My PWA App</title>
  </head>
  <body>
    <noscript>JavaScriptを有効にしてください。</noscript>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

アイコンファイルの準備

PWA に必要なアイコンファイルを作成します。オンラインのアイコンジェネレーターを使用するか、以下のコマンドでアイコンを生成できます。

bash# アイコンディレクトリの作成
mkdir -p public/icons

# アイコン生成ツールのインストール(例:pwa-asset-generator)
yarn add -D pwa-asset-generator

# アイコンの生成
npx pwa-asset-generator ./src/assets/logo.png ./public/icons

よくあるエラーと解決策

マニフェストの構文エラー

json// エラー例:JSONの構文エラー
{
  "name": "My PWA App",
  "short_name": "PWA App",  // カンマが不足
  "description": "SolidJSで作成されたPWAアプリケーション"
}

// 解決策:正しいJSON構文
{
  "name": "My PWA App",
  "short_name": "PWA App",
  "description": "SolidJSで作成されたPWAアプリケーション"
}

アイコンファイルが見つからないエラー

bash# エラー例
Failed to load resource: the server responded with a status of 404 (Not Found)

# 解決策:アイコンファイルの存在確認
ls -la public/icons/

マニフェストファイルが正しく設定されると、ブラウザで PWA のインストールプロンプトが表示されるようになります。

この段階で、基本的な PWA の基盤が整いました。次は、より高度な機能である Service Worker の実装に進みましょう。

Service Worker の実装

Service Worker は、PWA の心臓部とも言える重要なコンポーネントです。バックグラウンドで動作し、キャッシュ管理やオフライン機能を提供します。

Service Worker の基本概念

Service Worker は、ブラウザとネットワークの間に位置するプロキシとして動作します。これにより、ネットワークリクエストをインターセプトし、キャッシュ戦略を実装できます。

Service Worker ファイルの作成

public​/​sw.jsファイルを作成します。

javascript// Service Workerのバージョン管理
const CACHE_NAME = 'my-pwa-cache-v1';

// キャッシュするリソースのリスト
const urlsToCache = [
  '/',
  '/index.html',
  '/manifest.json',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png',
];

// Service Workerのインストールイベント
self.addEventListener('install', (event) => {
  console.log('Service Worker: インストール中...');

  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => {
        console.log(
          'Service Worker: キャッシュを開きました'
        );
        return cache.addAll(urlsToCache);
      })
      .catch((error) => {
        console.error(
          'Service Worker: キャッシュエラー',
          error
        );
      })
  );
});

// Service Workerのアクティベートイベント
self.addEventListener('activate', (event) => {
  console.log('Service Worker: アクティベート中...');

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log(
              'Service Worker: 古いキャッシュを削除中',
              cacheName
            );
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// フェッチイベントの処理
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // キャッシュに存在する場合はキャッシュから返す
      if (response) {
        return response;
      }

      // キャッシュにない場合はネットワークから取得
      return fetch(event.request)
        .then((response) => {
          // 有効なレスポンスでない場合はそのまま返す
          if (
            !response ||
            response.status !== 200 ||
            response.type !== 'basic'
          ) {
            return response;
          }

          // レスポンスをクローンしてキャッシュに保存
          const responseToCache = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseToCache);
          });

          return response;
        })
        .catch(() => {
          // ネットワークエラーの場合のフォールバック
          if (event.request.destination === 'document') {
            return caches.match('/offline.html');
          }
        });
    })
  );
});

Service Worker の登録

src​/​index.tsxファイルに Service Worker の登録コードを追加します。

typescript// Service Workerの登録関数
function registerServiceWorker() {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('/sw.js')
        .then((registration) => {
          console.log(
            'Service Worker: 登録成功',
            registration.scope
          );
        })
        .catch((error) => {
          console.error('Service Worker: 登録失敗', error);
        });
    });
  }
}

// アプリケーションの初期化時にService Workerを登録
registerServiceWorker();

よくあるエラーと解決策

Service Worker の登録エラー

javascript// エラー例
TypeError: Failed to register a ServiceWorker: A bad HTTP response code (404) was received when fetching the script.

// 解決策:Service Workerファイルのパス確認
// public/sw.jsが存在することを確認
ls -la public/sw.js

キャッシュエラー

javascript// エラー例
TypeError: Failed to execute 'addAll' on 'Cache': Request failed

// 解決策:キャッシュするURLの存在確認
// すべてのURLが正しく存在することを確認

オフライン機能の実装

PWA の最大の魅力の一つが、オフラインでも動作することです。SolidJS と Service Worker を組み合わせて、強力なオフライン機能を実装しましょう。

オフラインページの作成

public​/​offline.htmlファイルを作成します。

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;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        background-color: #f5f5f5;
      }
      .offline-container {
        text-align: center;
        padding: 2rem;
        background: white;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }
      .offline-icon {
        font-size: 4rem;
        margin-bottom: 1rem;
      }
      h1 {
        color: #333;
        margin-bottom: 1rem;
      }
      p {
        color: #666;
        margin-bottom: 2rem;
      }
      .retry-button {
        background: #007bff;
        color: white;
        border: none;
        padding: 0.75rem 1.5rem;
        border-radius: 4px;
        cursor: pointer;
        font-size: 1rem;
      }
      .retry-button:hover {
        background: #0056b3;
      }
    </style>
  </head>
  <body>
    <div class="offline-container">
      <div class="offline-icon">📶</div>
      <h1>オフラインです</h1>
      <p>
        インターネット接続を確認して、もう一度お試しください。
      </p>
      <button
        class="retry-button"
        onclick="window.location.reload()"
      >
        再試行
      </button>
    </div>
  </body>
</html>

オフライン状態の検出

SolidJS コンポーネントでオフライン状態を管理します。

typescript// src/components/OfflineIndicator.tsx
import { createSignal, onMount, onCleanup } from 'solid-js';

export function OfflineIndicator() {
  const [isOffline, setIsOffline] = createSignal(
    !navigator.onLine
  );

  const handleOnline = () => setIsOffline(false);
  const handleOffline = () => setIsOffline(true);

  onMount(() => {
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
  });

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

  return (
    <>
      {isOffline() && (
        <div
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            background: '#ff6b6b',
            color: 'white',
            padding: '0.5rem',
            textAlign: 'center',
            zIndex: 1000,
          }}
        >
          📶 オフラインです - 一部の機能が制限されます
        </div>
      )}
    </>
  );
}

データのオフライン保存

IndexedDB を使用してオフライン時のデータを保存します。

typescript// src/utils/offlineStorage.ts
export class OfflineStorage {
  private db: IDBDatabase | null = null;
  private readonly dbName = 'PWAOfflineDB';
  private readonly version = 1;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(
        this.dbName,
        this.version
      );

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest)
          .result;

        // データストアの作成
        if (!db.objectStoreNames.contains('userData')) {
          db.createObjectStore('userData', {
            keyPath: 'id',
          });
        }
      };
    });
  }

  async saveData(id: string, data: any): Promise<void> {
    if (!this.db) await this.init();

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(
        ['userData'],
        'readwrite'
      );
      const store = transaction.objectStore('userData');
      const request = store.put({
        id,
        data,
        timestamp: Date.now(),
      });

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getData(id: string): Promise<any> {
    if (!this.db) await this.init();

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(
        ['userData'],
        'readonly'
      );
      const store = transaction.objectStore('userData');
      const request = store.get(id);

      request.onsuccess = () =>
        resolve(request.result?.data);
      request.onerror = () => reject(request.error);
    });
  }
}

よくあるエラーと解決策

IndexedDB の初期化エラー

javascript// エラー例
TypeError: Failed to execute 'open' on 'IDBFactory': The database connection is being closed.

// 解決策:データベース接続の適切な管理
// アプリケーション終了時にデータベースを閉じる
window.addEventListener('beforeunload', () => {
  if (offlineStorage.db) {
    offlineStorage.db.close();
  }
});

Service Worker のキャッシュエラー

javascript// エラー例
TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is not supported

// 解決策:キャッシュ対象のURLを適切にフィルタリング
if (event.request.url.startsWith('http')) {
  // キャッシュ処理を実行
}

プッシュ通知の追加

プッシュ通知は、ユーザーエンゲージメントを向上させる重要な機能です。SolidJS でプッシュ通知を実装しましょう。

プッシュ通知の基本設定

まず、プッシュ通知の権限を要求する関数を作成します。

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

  async init(): Promise<void> {
    if (
      'serviceWorker' in navigator &&
      'PushManager' in window
    ) {
      this.registration = await navigator.serviceWorker
        .ready;
    }
  }

  async requestPermission(): Promise<boolean> {
    const permission =
      await Notification.requestPermission();
    return permission === 'granted';
  }

  async subscribeToPush(): Promise<PushSubscription | null> {
    if (!this.registration) {
      await this.init();
    }

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

      console.log(
        'プッシュ通知の購読に成功しました:',
        subscription
      );
      return subscription;
    } catch (error) {
      console.error(
        'プッシュ通知の購読に失敗しました:',
        error
      );
      return null;
    }
  }

  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;
  }
}

プッシュ通知の UI コンポーネント

ユーザーがプッシュ通知を有効にできる UI コンポーネントを作成します。

typescript// src/components/PushNotificationButton.tsx
import { createSignal, onMount } from 'solid-js';
import { PushNotificationManager } from '../utils/pushNotification';

export function PushNotificationButton() {
  const [isSupported, setIsSupported] = createSignal(false);
  const [isSubscribed, setIsSubscribed] =
    createSignal(false);
  const [isLoading, setIsLoading] = createSignal(false);

  const pushManager = new PushNotificationManager();

  onMount(async () => {
    setIsSupported(
      'serviceWorker' in navigator &&
        'PushManager' in window
    );

    if (isSupported()) {
      await pushManager.init();
      // 既存の購読状態を確認
      const registration = await navigator.serviceWorker
        .ready;
      const subscription =
        await registration.pushManager.getSubscription();
      setIsSubscribed(!!subscription);
    }
  });

  const handleSubscribe = async () => {
    setIsLoading(true);

    try {
      const hasPermission =
        await pushManager.requestPermission();

      if (hasPermission) {
        const subscription =
          await pushManager.subscribeToPush();
        if (subscription) {
          setIsSubscribed(true);
          // サーバーにサブスクリプション情報を送信
          await sendSubscriptionToServer(subscription);
        }
      }
    } catch (error) {
      console.error(
        'プッシュ通知の設定に失敗しました:',
        error
      );
    } finally {
      setIsLoading(false);
    }
  };

  const sendSubscriptionToServer = async (
    subscription: PushSubscription
  ) => {
    try {
      await fetch('/api/push-subscription', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(subscription),
      });
    } catch (error) {
      console.error(
        'サーバーへの送信に失敗しました:',
        error
      );
    }
  };

  if (!isSupported()) {
    return (
      <div style={{ color: '#666', fontSize: '0.9rem' }}>
        このブラウザはプッシュ通知をサポートしていません
      </div>
    );
  }

  return (
    <div>
      {!isSubscribed() ? (
        <button
          onClick={handleSubscribe}
          disabled={isLoading()}
          style={{
            background: '#007bff',
            color: 'white',
            border: 'none',
            padding: '0.75rem 1.5rem',
            borderRadius: '4px',
            cursor: isLoading() ? 'not-allowed' : 'pointer',
            opacity: isLoading() ? 0.6 : 1,
          }}
        >
          {isLoading()
            ? '設定中...'
            : 'プッシュ通知を有効にする'}
        </button>
      ) : (
        <div
          style={{ color: '#28a745', fontSize: '0.9rem' }}
        >
          ✅ プッシュ通知が有効です
        </div>
      )}
    </div>
  );
}

Service Worker でのプッシュ通知処理

Service Worker にプッシュ通知の処理を追加します。

javascript// public/sw.js に追加
// プッシュ通知の受信処理
self.addEventListener('push', (event) => {
  console.log('プッシュ通知を受信しました:', event);

  const options = {
    body: event.data
      ? event.data.text()
      : '新しい通知があります',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/icon-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1,
    },
    actions: [
      {
        action: 'explore',
        title: '詳細を見る',
        icon: '/icons/icon-72x72.png',
      },
      {
        action: 'close',
        title: '閉じる',
        icon: '/icons/icon-72x72.png',
      },
    ],
  };

  event.waitUntil(
    self.registration.showNotification(
      'My PWA App',
      options
    )
  );
});

// 通知クリック時の処理
self.addEventListener('notificationclick', (event) => {
  console.log('通知がクリックされました:', event);

  event.notification.close();

  if (event.action === 'explore') {
    // 詳細ページを開く
    event.waitUntil(clients.openWindow('/'));
  }
});

よくあるエラーと解決策

VAPID キーのエラー

javascript// エラー例
TypeError: Failed to execute 'subscribe' on 'PushManager': Invalid applicationServerKey

// 解決策:VAPIDキーの正しい形式確認
// 公開キーが正しいBase64形式であることを確認

権限拒否エラー

javascript// エラー例
NotAllowedError: The request is not allowed by the user agent or the platform

// 解決策:ユーザーが手動で権限を許可するよう案内
// ブラウザの設定から通知権限を確認

アプリのインストール機能

PWA のインストール機能を実装し、ユーザーがアプリをホーム画面に追加できるようにしましょう。

インストールプロンプトの実装

typescript// src/components/InstallPrompt.tsx
import { createSignal, onMount, onCleanup } from 'solid-js';

export function InstallPrompt() {
  const [showPrompt, setShowPrompt] = createSignal(false);
  const [deferredPrompt, setDeferredPrompt] =
    createSignal<any>(null);

  onMount(() => {
    // インストール可能なタイミングを検出
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault();
      setDeferredPrompt(e);
      setShowPrompt(true);
    });

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

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

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

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

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

    setDeferredPrompt(null);
    setShowPrompt(false);
  };

  const handleDismiss = () => {
    setShowPrompt(false);
    setDeferredPrompt(null);
  };

  return (
    <>
      {showPrompt() && (
        <div
          style={{
            position: 'fixed',
            bottom: '20px',
            left: '20px',
            right: '20px',
            background: 'white',
            border: '1px solid #ddd',
            borderRadius: '8px',
            padding: '1rem',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            zIndex: 1000,
          }}
        >
          <div style={{ marginBottom: '1rem' }}>
            <h3
              style={{
                margin: '0 0 0.5rem 0',
                fontSize: '1.1rem',
              }}
            >
              📱 アプリをインストール
            </h3>
            <p
              style={{
                margin: 0,
                color: '#666',
                fontSize: '0.9rem',
              }}
            >
              ホーム画面に追加して、より快適にご利用いただけます
            </p>
          </div>
          <div style={{ display: 'flex', gap: '0.5rem' }}>
            <button
              onClick={handleInstall}
              style={{
                background: '#007bff',
                color: 'white',
                border: 'none',
                padding: '0.5rem 1rem',
                borderRadius: '4px',
                cursor: 'pointer',
                flex: 1,
              }}
            >
              インストール
            </button>
            <button
              onClick={handleDismiss}
              style={{
                background: '#6c757d',
                color: 'white',
                border: 'none',
                padding: '0.5rem 1rem',
                borderRadius: '4px',
                cursor: 'pointer',
              }}
            >
              後で
            </button>
          </div>
        </div>
      )}
    </>
  );
}

インストール状態の管理

typescript// src/utils/installManager.ts
export class InstallManager {
  static isInstalled(): boolean {
    return (
      window.matchMedia('(display-mode: standalone)')
        .matches ||
      (window.navigator as any).standalone === true
    );
  }

  static isInstallable(): boolean {
    return (
      'serviceWorker' in navigator &&
      'PushManager' in window
    );
  }

  static getInstallCriteria(): string[] {
    const criteria = [];

    if (!this.isInstallable()) {
      criteria.push(
        'Service Workerがサポートされていません'
      );
    }

    if (
      !window.location.protocol.includes('https') &&
      window.location.hostname !== 'localhost'
    ) {
      criteria.push('HTTPSが必要です');
    }

    return criteria;
  }
}

よくあるエラーと解決策

インストールプロンプトが表示されない

javascript// エラー例:インストール条件を満たしていない
// 解決策:PWAの要件を確認
// 1. HTTPSまたはlocalhost
// 2. 有効なマニフェスト
// 3. Service Workerの登録
// 4. ユーザーインタラクション

インストール後の動作異常

javascript// エラー例:インストール後も通常のブラウザとして動作
// 解決策:display: standaloneの確認
if (
  window.matchMedia('(display-mode: standalone)').matches
) {
  // スタンドアロンモードでの処理
}

テストとデバッグ

PWA の開発において、適切なテストとデバッグは不可欠です。SolidJS での PWA テスト方法を解説します。

Chrome DevTools での PWA テスト

Chrome DevTools の Application タブを使用して PWA をテストします。

bash# 開発サーバーを起動
yarn dev

# ブラウザで http://localhost:3000 を開く
# F12でDevToolsを開き、Applicationタブを選択

Service Worker のデバッグ

Service Worker の状態を確認する方法です。

javascript// ブラウザコンソールで実行
// Service Workerの登録状態確認
navigator.serviceWorker
  .getRegistrations()
  .then((registrations) => {
    console.log('登録済みService Worker:', registrations);
  });

// Service Workerの更新
navigator.serviceWorker
  .getRegistration()
  .then((registration) => {
    if (registration) {
      registration.update();
    }
  });

オフラインテスト

javascript// オフライン状態のシミュレーション
// DevToolsのNetworkタブで「Offline」にチェック

// または、コンソールで実行
// オフライン状態の強制
Object.defineProperty(navigator, 'onLine', {
  writable: true,
  value: false,
});

// オンライン状態の復旧
Object.defineProperty(navigator, 'onLine', {
  writable: true,
  value: true,
});

プッシュ通知のテスト

javascript// プッシュ通知のテスト送信
// Service Workerのコンソールで実行
self.registration.showNotification('テスト通知', {
  body: 'これはテスト通知です',
  icon: '/icons/icon-192x192.png',
  badge: '/icons/icon-72x72.png',
});

Lighthouse での PWA 監査

bash# Lighthouse CLIのインストール
yarn add -g lighthouse

# PWA監査の実行
lighthouse http://localhost:3000 --output=html --output-path=./lighthouse-report.html

よくあるエラーと解決策

Service Worker の更新エラー

javascript// エラー例
TypeError: Service Worker script update failed

// 解決策:キャッシュのクリア
// DevToolsのApplication > Storage > Clear storage

マニフェストの検証エラー

json// エラー例:マニフェストの構文エラー
{
  "name": "My PWA App",
  "icons": [
    {
      "src": "/icon.png",
      "sizes": "192x192"
      // "type"が不足
    }
  ]
}

// 解決策:必須フィールドの追加
{
  "name": "My PWA App",
  "icons": [
    {
      "src": "/icon.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}

デプロイと配布

開発が完了した PWA を本番環境にデプロイし、ユーザーに配布する方法を解説します。

ビルドの最適化

bash# 本番用ビルドの実行
yarn build

# ビルド結果の確認
ls -la dist/

Vite 設定の最適化

vite.config.tsファイルで PWA 用の設定を追加します。

typescriptimport { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';

export default defineConfig({
  plugins: [solid()],
  build: {
    target: 'esnext',
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['solid-js'],
          utils: ['solid-js/web'],
        },
      },
    },
  },
  server: {
    https: true, // PWA開発用のHTTPS
  },
});

ホスティングサービスの選択

Netlify でのデプロイ

bash# Netlify CLIのインストール
yarn add -g netlify-cli

# デプロイ
netlify deploy --prod --dir=dist

Vercel でのデプロイ

bash# Vercel CLIのインストール
yarn add -g vercel

# デプロイ
vercel --prod

Firebase Hosting でのデプロイ

bash# Firebase CLIのインストール
yarn add -g firebase-tools

# プロジェクトの初期化
firebase init hosting

# デプロイ
firebase deploy

ドメインと SSL 証明書

PWA には HTTPS が必須です。以下の方法で SSL 証明書を取得できます。

Let's Encrypt(無料)

bash# Certbotのインストール
sudo apt-get install certbot

# SSL証明書の取得
sudo certbot certonly --webroot -w /var/www/html -d yourdomain.com

Cloudflare(無料プランあり)

  • Cloudflare にドメインを登録
  • SSL/TLS 設定で「Flexible」または「Full」を選択

配布の最適化

App Store での配布 PWA をネイティブアプリとして配布する場合、以下のツールが利用できます。

bash# Bubblewrap(Android)
yarn add -g @bubblewrap/cli

# PWA Builder
# https://www.pwabuilder.com/ でPWAをネイティブアプリに変換

メタデータの最適化

html<!-- SEO対策のためのメタタグ -->
<meta
  name="description"
  content="SolidJSで作成された高性能PWAアプリケーション"
/>
<meta
  name="keywords"
  content="PWA, SolidJS, Progressive Web App"
/>
<meta name="author" content="Your Name" />

<!-- ソーシャルメディア用 -->
<meta property="og:title" content="My PWA App" />
<meta
  property="og:description"
  content="SolidJSで作成されたPWAアプリケーション"
/>
<meta
  property="og:image"
  content="/icons/icon-512x512.png"
/>
<meta property="og:url" content="https://yourdomain.com" />

よくあるエラーと解決策

ビルドエラー

bash# エラー例
Error: ENOENT: no such file or directory, open 'dist/index.html'

# 解決策:ビルドディレクトリの確認
# vite.config.tsでoutDirを確認

HTTPS エラー

bash# エラー例
Mixed Content: The page was loaded over HTTPS, but requested an insecure resource

# 解決策:すべてのリソースをHTTPSで提供
# 画像、API、外部スクリプトなど

Service Worker のキャッシュエラー

javascript// エラー例:本番環境でのキャッシュ問題
// 解決策:キャッシュバージョンの管理
const CACHE_VERSION = 'v1.0.1';
const CACHE_NAME = `my-pwa-cache-${CACHE_VERSION}`;

まとめ

SolidJS と PWA を組み合わせることで、軽量で高速、かつユーザー体験に優れたアプリケーションを作成できることを学びました。

今回学んだこと

  1. PWA の基本概念: オフライン動作、インストール可能、プッシュ通知などの特徴
  2. SolidJS の利点: 軽量なランタイムとリアクティブシステム
  3. Service Worker: キャッシュ管理とオフライン機能の実装
  4. プッシュ通知: ユーザーエンゲージメント向上のための通知システム
  5. インストール機能: ネイティブアプリのような体験の提供
  6. テストとデバッグ: 適切な開発フローの確立
  7. デプロイと配布: 本番環境での運用方法

次のステップ

この記事で学んだ知識を基に、さらに高度な PWA 機能に挑戦してみてください:

  • バックグラウンド同期: オフライン時のデータ同期
  • Web Share API: ネイティブの共有機能
  • Web Bluetooth: デバイスとの連携
  • WebAssembly: パフォーマンスの向上

SolidJS と PWA の組み合わせは、現代の Web 開発において非常に強力なツールです。この技術を活用して、ユーザーに驚くべき体験を提供するアプリケーションを作成してください。

あなたの PWA 開発が成功することを心から願っています!

関連リンク