2026年9月19日

2026年9月19日

WordPressをPWA(Progressive Web App)対応してオフライン閲覧とプッシュ通知を実装する方法

はじめに

PWA(Progressive Web App)はWebアプリにネイティブアプリに近い体験を提供する技術で、オフライン閲覧・ホーム画面への追加・プッシュ通知などの機能を実現します。WordPressにPWAを実装することでリピーターのエンゲージメント向上・直帰率低下・モバイルUX改善が期待でき、SEOにも好影響を与えます。

症状・原因

  • ネットワーク接続が不安定な状況でサイトにアクセスできず、ユーザーが離脱している
  • プッシュ通知で新着記事を通知したいがWordPressの標準機能にはない
  • サイトをホーム画面に追加したときにアプリらしい外観にしたい

解決手順

ステップ1:Web App Manifest(manifest.json)を生成してサイト情報を設定する

ブラウザにアプリとしての情報を伝えるmanifest.jsonをWordPressで動的に生成します。

<?php
// functions.php に追加 ─ Web App Manifestの動的生成

// /manifest.json のエンドポイントをWordPressに登録する
add_action( 'init', function() {
    add_rewrite_rule( '^manifest\.json$', 'index.php?manifest_json=1', 'top' );
    add_rewrite_tag( '%manifest_json%', '1' );
} );

// manifest.jsonのコンテンツを出力する
add_action( 'template_redirect', function() {
    if ( ! get_query_var( 'manifest_json' ) ) {
        return;
    }

    $manifest = [
        'name'             => get_bloginfo( 'name' ),
        'short_name'       => substr( get_bloginfo( 'name' ), 0, 12 ), // ホーム画面表示名は12文字以内
        'description'      => get_bloginfo( 'description' ),
        'start_url'        => home_url( '/?source=pwa' ), // PWAからのアクセスをGAで識別する
        'display'          => 'standalone', // ネイティブアプリのようなUI(ブラウザUIなし)
        'background_color' => '#ffffff',
        'theme_color'      => '#0073aa', // ブランドカラーをステータスバーに適用する
        'orientation'      => 'portrait-primary',
        'lang'             => 'ja',
        'icons'            => [
            // PWAアイコン: 複数サイズを用意する(最低192x192と512x512が必要)
            [ 'src' => get_template_directory_uri() . '/icons/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png' ],
            [ 'src' => get_template_directory_uri() . '/icons/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png' ],
            // マスカブルアイコン: 端末によって丸型や正方形にトリミングされる
            [ 'src' => get_template_directory_uri() . '/icons/icon-512-maskable.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable' ],
        ],
        'categories'       => [ 'blog', 'news' ],
    ];

    header( 'Content-Type: application/manifest+json; charset=UTF-8' );
    header( 'Cache-Control: public, max-age=86400' ); // 24時間キャッシュする
    echo wp_json_encode( $manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT );
    exit;
} );

// HTMLのheadにmanifestリンクタグを追加する
add_action( 'wp_head', function() {
    printf( '<link rel="manifest" href="%s">' . "\n", esc_url( home_url( '/manifest.json' ) ) );
    // iOSのSafari向けにapple-mobile-web-app-*メタタグも追加する
    printf( '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n" );
    printf( '<meta name="apple-mobile-web-app-status-bar-style" content="default">' . "\n" );
    printf( '<meta name="apple-mobile-web-app-title" content="%s">' . "\n",
        esc_attr( get_bloginfo( 'name' ) ) );
} );

ステップ2:Service Workerをwp_enqueue_scriptで登録する

Service Workerのインストールとアクティベーションをフロントエンドで設定します。

<?php
// functions.php に追加 ─ Service Workerの登録

// Service Worker登録スクリプトをwp_headで読み込む
add_action( 'wp_head', function() {
    ?>
    <script>
    // Service Worker が対応しているブラウザかどうかを確認する
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
            // /sw.js をService Workerとして登録する
            // スコープをルート(/)に設定してサイト全体をカバーする
            navigator.serviceWorker.register('/sw.js', { scope: '/' })
                .then(function(registration) {
                    console.log('Service Worker 登録成功:', registration.scope);

                    // 新しいバージョンが利用可能な場合に更新を促す
                    registration.addEventListener('updatefound', function() {
                        var newWorker = registration.installing;
                        newWorker.addEventListener('statechange', function() {
                            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                                // 新バージョン利用可能の通知を表示する
                                showUpdateNotification();
                            }
                        });
                    });
                })
                .catch(function(error) {
                    console.error('Service Worker 登録失敗:', error);
                });
        });
    }

    function showUpdateNotification() {
        var bar = document.createElement('div');
        bar.innerHTML = 'サイトが更新されました。<button onclick="location.reload()">再読み込み</button>';
        bar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#0073aa;color:#fff;padding:12px;text-align:center;z-index:9999;';
        document.body.appendChild(bar);
    }
    </script>
    <?php
} );

ステップ3:キャッシュ戦略をService Worker内に実装する

静的アセットにはCache First、ページにはNetwork Firstの戦略を適用します。

// public/sw.js ─ Service Worker本体(WordPressルートに配置する)

const CACHE_VERSION = 'v1.2.0'; // バージョンを更新するとキャッシュがリセットされる
const STATIC_CACHE  = 'static-' + CACHE_VERSION;
const PAGES_CACHE   = 'pages-' + CACHE_VERSION;

// キャッシュするURLのリスト(Precaching)
const PRECACHE_URLS = [
    '/',                    // トップページ
    '/offline/',            // オフライン時の代替ページ
    '/wp-content/themes/my-theme/style.css',
    '/wp-content/themes/my-theme/js/main.js',
];

// Service Worker インストール時に静的リソースをキャッシュする
self.addEventListener('install', function(event) {
    event.waitUntil(
        caches.open(STATIC_CACHE).then(function(cache) {
            console.log('Service Worker: 静的リソースをキャッシュ中...');
            return cache.addAll(PRECACHE_URLS);
        }).then(function() {
            // 古いService Workerをスキップして即座にアクティブ化する
            return self.skipWaiting();
        })
    );
});

// 古いキャッシュを削除する
self.addEventListener('activate', function(event) {
    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(
                cacheNames.filter(function(name) {
                    // 現在のバージョン以外のキャッシュを削除する
                    return name !== STATIC_CACHE && name !== PAGES_CACHE;
                }).map(function(name) {
                    console.log('古いキャッシュを削除:', name);
                    return caches.delete(name);
                })
            );
        }).then(function() {
            return self.clients.claim(); // 即座に全クライアントを制御下に置く
        })
    );
});

// フェッチイベント: リクエストに応じてキャッシュ戦略を切り替える
self.addEventListener('fetch', function(event) {
    var url = new URL(event.request.url);

    // 静的アセット(CSS・JS・画像)はCache First戦略
    if (/\.(css|js|png|jpg|svg|woff2?)(\?.*)?$/.test(url.pathname)) {
        event.respondWith(cacheFirst(event.request, STATIC_CACHE));
        return;
    }

    // HTMLページはNetwork First戦略(常に最新を取得しフォールバックでキャッシュを使用)
    if (event.request.mode === 'navigate') {
        event.respondWith(networkFirst(event.request, PAGES_CACHE));
        return;
    }

    // REST APIはネットワークのみ(キャッシュしない)
    if (url.pathname.startsWith('/wp-json/')) {
        event.respondWith(fetch(event.request));
        return;
    }
});

// Cache First: キャッシュを優先し、なければネットワークから取得する
async function cacheFirst(request, cacheName) {
    var cached = await caches.match(request);
    if (cached) return cached;
    var response = await fetch(request);
    var cache = await caches.open(cacheName);
    cache.put(request, response.clone());
    return response;
}

// Network First: ネットワークを優先し、失敗時はキャッシュを返す
async function networkFirst(request, cacheName) {
    try {
        var response = await fetch(request);
        var cache = await caches.open(cacheName);
        cache.put(request, response.clone()); // 成功したページをキャッシュに保存する
        return response;
    } catch (error) {
        var cached = await caches.match(request);
        return cached || caches.match('/offline/'); // オフラインページにフォールバック
    }
}

ステップ4:VAPIDキーとwp_ajaxでWebプッシュ通知のサブスクリプションを実装する

VAPIDキーを使ったプッシュ通知の購読管理とサーバーからの送信を実装します。

# web-push ライブラリで VAPID キーを生成する(Node.js が必要)
npx web-push generate-vapid-keys

# 出力例:
# Public Key: BGxxx...(フロントエンドで使用)
# Private Key: xxx...(サーバーサイドで使用・絶対に公開しない)
<?php
// functions.php に追加 ─ プッシュ通知サブスクリプションの管理

// VAPIDキーを定数として定義する(wp-config.phpで管理する)
define( 'VAPID_PUBLIC_KEY',  'BGxxx...' );
define( 'VAPID_PRIVATE_KEY', 'xxx...' ); // 絶対に公開リポジトリにコミットしない

// サブスクリプション情報を保存するAJAXエンドポイント
add_action( 'wp_ajax_nopriv_save_push_subscription', function() {
    $subscription = json_decode( file_get_contents( 'php://input' ), true );

    if ( empty( $subscription['endpoint'] ) ) {
        wp_send_json_error( 'Invalid subscription' );
        return;
    }

    // サブスクリプションをDBのカスタムテーブルに保存する
    global $wpdb;
    $wpdb->replace(
        $wpdb->prefix . 'push_subscriptions',
        [
            'endpoint'    => sanitize_url( $subscription['endpoint'] ),
            'p256dh'      => sanitize_text_field( $subscription['keys']['p256dh'] ?? '' ),
            'auth'        => sanitize_text_field( $subscription['keys']['auth'] ?? '' ),
            'created_at'  => current_time( 'mysql' ),
        ],
        [ '%s', '%s', '%s', '%s' ]
    );

    wp_send_json_success( 'Subscription saved' );
} );

// 投稿の公開時にプッシュ通知を送信する
add_action( 'publish_post', function( $post_id ) {
    $post = get_post( $post_id );
    if ( ! $post || $post->post_status !== 'publish' ) {
        return;
    }

    // 非同期で通知を送信するためにWP Cronで遅延実行する
    wp_schedule_single_event(
        time() + 10,
        'send_push_notifications',
        [ $post_id ]
    );
} );

add_action( 'send_push_notifications', function( $post_id ) {
    global $wpdb;

    // 全サブスクライバーを取得する
    $subscriptions = $wpdb->get_results(
        "SELECT * FROM {$wpdb->prefix}push_subscriptions LIMIT 1000",
        ARRAY_A
    );

    $post    = get_post( $post_id );
    $payload = wp_json_encode( [
        'title' => get_bloginfo( 'name' ),
        'body'  => $post->post_title,
        'url'   => get_permalink( $post_id ),
        'icon'  => get_template_directory_uri() . '/icons/icon-192.png',
    ], JSON_UNESCAPED_UNICODE );

    // web-push PHPライブラリを使ってプッシュ通知を送信する
    // composer require minishlink/web-push が必要
    if ( class_exists( 'Minishlink\WebPush\WebPush' ) ) {
        $webPush = new \Minishlink\WebPush\WebPush( [
            'VAPID' => [
                'subject'    => home_url(),
                'publicKey'  => VAPID_PUBLIC_KEY,
                'privateKey' => VAPID_PRIVATE_KEY,
            ],
        ] );

        foreach ( $subscriptions as $sub ) {
            $subscription = \Minishlink\WebPush\Subscription::create( [
                'endpoint' => $sub['endpoint'],
                'keys'     => [ 'p256dh' => $sub['p256dh'], 'auth' => $sub['auth'] ],
            ] );
            $webPush->queueNotification( $subscription, $payload );
        }

        // バッチで送信する(効率的にプッシュ通知を配信する)
        foreach ( $webPush->flush() as $report ) {
            if ( ! $report->isSuccess() ) {
                // 無効なサブスクリプションをDBから削除する
                $wpdb->delete(
                    $wpdb->prefix . 'push_subscriptions',
                    [ 'endpoint' => $report->getRequest()->getUri()->__toString() ],
                    [ '%s' ]
                );
            }
        }
    }
} );

ステップ5:WorkboxでService Workerの高度なキャッシュ戦略を実装する

Googleが提供するWorkboxライブラリで保守性の高いService Workerを構築します。

// public/sw.js ─ Workboxを使ったService Worker(workbox-cliでビルド)

// Workbox CDNから読み込む(本番環境ではnpmでインストールを推奨)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.1.0/workbox-sw.js');

const { registerRoute }    = workbox.routing;
const { CacheFirst,
        NetworkFirst,
        StaleWhileRevalidate } = workbox.strategies;
const { ExpirationPlugin } = workbox.expiration;
const { precacheAndRoute } = workbox.precaching;

// Workboxによるプレキャッシュ(workbox-cliが自動生成するリストを使用する)
precacheAndRoute(self.__WB_MANIFEST || []);

// 画像: Cache First + 30日間保持
registerRoute(
    ({ request }) => request.destination === 'image',
    new CacheFirst({
        cacheName: 'images',
        plugins: [
            new ExpirationPlugin({
                maxEntries: 100,       // 最大100枚の画像をキャッシュする
                maxAgeSeconds: 30 * 24 * 60 * 60, // 30日間保持する
            }),
        ],
    })
);

// CSS・JS: Stale While Revalidate(古いキャッシュを即座に返しつつバックグラウンドで更新)
registerRoute(
    ({ request }) => request.destination === 'script' || request.destination === 'style',
    new StaleWhileRevalidate({
        cacheName: 'static-resources',
    })
);

// ページ(HTML): Network First + オフラインフォールバック
registerRoute(
    ({ request }) => request.mode === 'navigate',
    new NetworkFirst({
        cacheName: 'pages',
        networkTimeoutSeconds: 3, // 3秒以内に応答がなければキャッシュを使用する
        plugins: [
            new ExpirationPlugin({
                maxEntries: 50,  // 最新50ページを保存する
                maxAgeSeconds: 7 * 24 * 60 * 60, // 7日間保持する
            }),
        ],
    })
);

// プッシュ通知を受信したときの処理
self.addEventListener('push', function(event) {
    var data = event.data ? event.data.json() : {};
    var options = {
        body:    data.body || '新着記事があります',
        icon:    data.icon || '/icons/icon-192.png',
        badge:   '/icons/badge-72.png',
        data:    { url: data.url || '/' },
        actions: [
            { action: 'open', title: '記事を読む' },
            { action: 'close', title: '閉じる' },
        ],
    };

    event.waitUntil(
        self.registration.showNotification( data.title || 'New Article', options )
    );
});

// 通知クリック時にページを開く
self.addEventListener('notificationclick', function(event) {
    event.notification.close();
    if (event.action === 'open' || !event.action) {
        event.waitUntil(
            clients.openWindow( event.notification.data.url )
        );
    }
});

注意事項

  • Service WorkerのHTTPS必須: Service WorkerはHTTPS環境(またはlocalhost)でのみ動作します。必ずSSL証明書を設定したサイトで実装してください
  • キャッシュの更新: Service Workerのキャッシュが原因でコンテンツが更新されないことがあります。バージョン管理をCACHE_VERSIONで行い、デプロイ時に変更することで古いキャッシュを確実にクリアしてください
  • VAPIDキーのセキュリティ: VAPID秘密鍵は絶対にGit等のバージョン管理システムにコミットしないこと。wp-config.phpで管理するか、環境変数を使用してください

まとめ

WordPressのPWA対応はWeb App Manifest・Service Worker・Workboxキャッシュ戦略・VAPIDプッシュ通知の4つを組み合わせることで実現できます。オフライン対応とプッシュ通知の実装により、モバイルユーザーのリテンション率を大幅に向上させることができます。関連記事:WordPressにAMP(Accelerated Mobile Pages)を実装してモバイル表示を高速化する方法

お気軽にご相談ください

お見積りへ お問い合わせへ
▲