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 のパフォーマンスを大きく左右します。しかし、どのリソースにどの戦略を適用すべきかの判断は、経験と深い理解が必要です。
# | 戦略名 | 適用場面 | 課題 |
---|---|---|---|
1 | Cache First | 静的リソース | 更新タイミングの制御 |
2 | Network First | 動的コンテンツ | オフライン時の代替処理 |
3 | Stale While Revalidate | 頻繁に更新されるデータ | キャッシュ整合性の管理 |
4 | Network 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 チェックリスト
実際の本番環境にデプロイする前に、以下の項目を確認しましょう。
# | チェック項目 | 確認方法 |
---|---|---|
1 | HTTPS での配信 | ブラウザのアドレスバーで鍵マークを確認 |
2 | Service Worker の登録 | DevTools の Application タブで確認 |
3 | Web 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 アプリケーションの開発に挑戦してみてくださいね。ユーザーにとって価値のある、そして開発者にとっても保守しやすいアプリケーションが作れることでしょう。
関連リンク
- blog
うちのチーム、これやってない?アジャイル開発を腐らせる、ありがちなアンチパターン 10 選と処方箋
- blog
CD パイプラインを構築して、開発チームを「リリース疲れ」から解放しよう
- blog
見積もりが全然当たらないあなたへ。プランニングポーカーで楽しく、納得感のある見積もりをするコツ
- blog
「QA は最後の砦」という幻想を捨てる。開発プロセスに QA を組み込み、手戻りをなくす方法
- blog
ドキュメントは「悪」じゃない。アジャイル開発で「ちょうどいい」ドキュメントを見つけるための思考法
- blog
「アジャイルコーチ」って何する人?チームを最強にする影の立役者の役割と、あなたがコーチになるための道筋
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体
- review
瞬時に答えが出る脳に変身!『ゼロ秒思考』赤羽雄二が贈る思考力爆上げトレーニング
- review
関西弁のゾウに人生変えられた!『夢をかなえるゾウ 1』水野敬也が教えてくれた成功の本質
- review
「なぜ私の考えは浅いのか?」の答えがここに『「具体 ⇄ 抽象」トレーニング』細谷功