PWA + Workbox実践ガイド:Service Workerを活用したオフライン対応
PWA + Workbox実践ガイド:Service Workerを活用したオフライン対応
Progressive Web Apps(PWA)は、Webアプリにネイティブアプリのような体験をもたらす技術です。その核となるService Workerの実装を簡単にするのがWorkboxです。本記事では、Workboxを使ったPWA開発の実践的なテクニックを解説します。
Workboxとは
WorkboxはGoogleが開発するService Workerライブラリ群です。複雑なキャッシュ戦略やオフライン対応を、シンプルなAPIで実装できます。
主な特徴
- キャッシュ戦略: 定義済みの最適化されたパターン
- ルーティング: URLパターンベースのリクエスト処理
- プリキャッシング: ビルド時のアセット事前キャッシュ
- バックグラウンド同期: オフライン時のリクエスト保存
- プッシュ通知: Webプッシュの実装支援
- 開発ツール: デバッグとロギング
Service Workerの基本
Service Workerは、Webページとは独立して動作するスクリプトで、以下を可能にします:
- ネットワークリクエストのインターセプト
- キャッシュの管理
- バックグラウンド処理
- プッシュ通知の受信
セットアップ
インストール
# npm
npm install workbox-cli --save-dev
npm install workbox-window
# pnpm
pnpm add -D workbox-cli
pnpm add workbox-window
# Workbox CLI確認
npx workbox --version
Webpackプロジェクトの場合
npm install workbox-webpack-plugin --save-dev
webpack.config.js:
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
// 既存の設定...
plugins: [
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
},
},
},
],
}),
],
};
Viteプロジェクトの場合
npm install vite-plugin-pwa --save-dev
vite.config.js:
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My PWA App',
short_name: 'PWA App',
description: 'My Progressive Web App',
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60, // 1時間
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
}),
],
});
Service Workerの登録
アプリケーション側での登録
src/main.js:
import { Workbox } from 'workbox-window';
if ('serviceWorker' in navigator) {
const wb = new Workbox('/sw.js');
// Service Workerの更新検知
wb.addEventListener('waiting', (event) => {
console.log('新しいService Workerが利用可能です');
// ユーザーに更新を促す
if (confirm('新しいバージョンがあります。更新しますか?')) {
wb.addEventListener('controlling', (event) => {
window.location.reload();
});
// 待機中のService Workerをアクティブ化
wb.messageSkipWaiting();
}
});
// Service Workerがアクティブ化された時
wb.addEventListener('activated', (event) => {
console.log('Service Workerがアクティブ化されました');
// 初回インストール時でなければリロード
if (!event.isUpdate) {
console.log('Service Workerが初めてインストールされました');
}
});
// 登録
wb.register()
.then((registration) => {
console.log('Service Worker登録成功:', registration);
})
.catch((error) => {
console.error('Service Worker登録失敗:', error);
});
}
Service Worker本体
public/sw.js:
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// ビルド時に生成されたアセットをプリキャッシュ
precacheAndRoute(self.__WB_MANIFEST);
// Google Fontsのキャッシュ
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets',
})
);
registerRoute(
({ url }) => url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365, // 1年
maxEntries: 30,
}),
],
})
);
// 画像のキャッシュ
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: 100,
maxAgeSeconds: 60 * 60, // 1時間
}),
],
})
);
キャッシュ戦略
Workboxは5つの主要なキャッシュ戦略を提供します。
1. Cache First(キャッシュ優先)
キャッシュから取得し、なければネットワークから取得。画像やフォントに最適。
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7日
}),
],
})
);
2. Network First(ネットワーク優先)
ネットワークから取得し、失敗したらキャッシュから。APIリクエストに最適。
import { NetworkFirst } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3, // 3秒でタイムアウト
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5分
}),
],
})
);
3. Stale While Revalidate(キャッシュ即返し+バックグラウンド更新)
キャッシュをすぐ返しつつ、バックグラウンドで更新。CSS/JSに最適。
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ request }) =>
request.destination === 'script' ||
request.destination === 'style',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);
4. Network Only(ネットワークのみ)
常にネットワークから取得。キャッシュ不可なリクエストに使用。
import { NetworkOnly } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/auth/'),
new NetworkOnly()
);
5. Cache Only(キャッシュのみ)
常にキャッシュから取得。プリキャッシュされたアセット向け。
import { CacheOnly } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname === '/offline.html',
new CacheOnly({
cacheName: 'offline-page',
})
);
プリキャッシング
ビルド時のプリキャッシュ
workbox-config.js:
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,js,css,png,jpg,svg,woff2}',
],
swDest: 'dist/sw.js',
swSrc: 'src/sw.js',
// リビジョン計算に含めないファイル
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
// 最大ファイルサイズ(2MB)
maximumFileSizeToCacheInBytes: 2 * 1024 * 1024,
};
生成:
npx workbox generateSW workbox-config.js
# または
npx workbox injectManifest workbox-config.js
動的なプリキャッシュ
import { precacheAndRoute } from 'workbox-precaching';
// ビルド時に自動生成されるマニフェスト
precacheAndRoute(self.__WB_MANIFEST);
// 追加のURLを動的にプリキャッシュ
precacheAndRoute([
{ url: '/offline.html', revision: '1' },
{ url: '/fallback-image.png', revision: '1' },
]);
オフライン対応
オフラインフォールバック
import { setCatchHandler } from 'workbox-routing';
import { matchPrecache } from 'workbox-precaching';
// 全てのルートに対するフォールバック
setCatchHandler(async ({ event }) => {
switch (event.request.destination) {
case 'document':
// HTMLページのフォールバック
return matchPrecache('/offline.html');
case 'image':
// 画像のフォールバック
return matchPrecache('/fallback-image.png');
default:
// その他は失敗を返す
return Response.error();
}
});
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>オフライン</title>
<style>
body {
font-family: sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #333;
}
p {
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>オフラインです</h1>
<p>インターネット接続を確認してください</p>
<button onclick="window.location.reload()">再試行</button>
</div>
</body>
</html>
バックグラウンド同期
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSyncPlugin = new BackgroundSyncPlugin('apiQueue', {
maxRetentionTime: 24 * 60, // 24時間リトライ
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
console.log('バックグラウンド同期成功:', entry.request.url);
} catch (error) {
console.error('バックグラウンド同期失敗:', error);
// 失敗したらキューに戻す
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
クライアント側:
// オフライン時のPOSTリクエスト
async function saveData(data) {
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
console.log('保存成功');
}
} catch (error) {
console.log('オフライン時はバックグラウンド同期に追加されます');
}
}
プッシュ通知
Service Workerでの通知受信
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
vibrate: [200, 100, 200],
data: {
url: data.url,
dateOfArrival: Date.now(),
},
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.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
クライアント側での購読
// main.js
async function subscribeToPushNotifications() {
if (!('Notification' in window)) {
console.log('このブラウザはプッシュ通知に対応していません');
return;
}
// 権限リクエスト
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('通知が許可されませんでした');
return;
}
// Service Worker登録を取得
const registration = await navigator.serviceWorker.ready;
// プッシュ購読
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// サーバーに購読情報を送信
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
console.log('プッシュ通知を購読しました');
}
function urlBase64ToUint8Array(base64String) {
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/manifest.json:
{
"name": "My Progressive Web App",
"short_name": "My PWA",
"description": "A Progressive Web App built with Workbox",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3f51b5",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["productivity", "utilities"],
"screenshots": [
{
"src": "/screenshot1.png",
"sizes": "540x720",
"type": "image/png"
}
]
}
HTMLでのリンク:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3f51b5">
<link rel="apple-touch-icon" href="/icon-192x192.png">
デバッグとテスト
Chrome DevToolsでのデバッグ
- Application タブ > Service Workers: 登録状態の確認
- Cache Storage: キャッシュの内容確認
- オフラインモード: ネットワークタブでオフライン化
- Lighthouse: PWAスコアの確認
ログ出力
import { workbox } from 'workbox-core';
// 開発環境でのみログ有効化
if (process.env.NODE_ENV === 'development') {
workbox.setConfig({ debug: true });
}
// カスタムログ
import { logger } from 'workbox-core/_private/logger.js';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
async ({ event }) => {
logger.log('APIリクエスト:', event.request.url);
const response = await fetch(event.request);
logger.log('レスポンス:', response.status);
return response;
}
);
テスト
// service-worker.test.js
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.json({ data: 'test' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('Service Workerがキャッシュを使用する', async () => {
// テストコード
});
ベストプラクティス
1. 適切なキャッシュ戦略の選択
- 静的アセット: Cache First
- APIレスポンス: Network First または Stale While Revalidate
- ユーザー生成コンテンツ: Network First
- 画像・フォント: Cache First + 長期有効期限
2. キャッシュサイズの管理
import { ExpirationPlugin } from 'workbox-expiration';
new ExpirationPlugin({
maxEntries: 50, // 最大エントリ数
maxAgeSeconds: 7 * 24 * 60 * 60, // 7日
purgeOnQuotaError: true, // クォータエラー時に自動削除
})
3. バージョニング
const CACHE_VERSION = 'v1.2.0';
const cacheName = `${CACHE_VERSION}-static`;
4. 段階的な導入
// Feature Detection
if ('serviceWorker' in navigator && 'caches' in window) {
// Service Worker対応ブラウザのみ
registerServiceWorker();
}
まとめ
WorkboxとPWAの組み合わせにより、以下のメリットが得られます:
- オフライン対応: ネットワークに依存しないアプリ体験
- 高速化: 効率的なキャッシュ戦略による読み込み速度向上
- エンゲージメント: プッシュ通知によるユーザー再訪問
- インストール可能: ホーム画面への追加でネイティブアプリライク
- 信頼性: ネットワーク状態に関わらず動作
Service Workerの複雑さをWorkboxが抽象化してくれるため、PWA開発のハードルは大幅に下がっています。モバイルファーストなWebアプリには、PWA化を検討する価値があります。