【Vue.js】PWA化してオフライン対応する方法|Service Workerの導入

【Vue.js】PWA化してオフライン対応する方法|Service Workerの導入 Vue.js

VueアプリをPWA(Progressive Web Apps)対応すると、オフラインでも動く、ホーム画面に追加できる、再訪時が高速といったメリットが得られます。本記事では、Vue 3 + Viteを前提に、最短の導入手順(vite-plugin-pwa)と、細かく制御したい場合の手書きService Workerの2パターンで解説します。さらに、キャッシュ戦略、オフラインフォールバック、アップデート検知まで実装例を示します。

最短ルート:vite-plugin-pwaでPWA対応

npm i -D vite-plugin-pwa

vite.config.tsにPWAプラグインを設定します。これだけでビルド時にマニフェストとService Workerが生成され、静的アセットは自動キャッシュされます。

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

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: 'autoUpdate',          // SW更新を自動適用
      includeAssets: ['favicon.svg', 'robots.txt'],
      manifest: {
        name: 'My Vue PWA',
        short_name: 'VuePWA',
        start_url: '/',
        display: 'standalone',
        background_color: '#ffffff',
        theme_color: '#42b883',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
          { src: 'pwa-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable any' }
        ]
      },
      workbox: {
        navigateFallback: '/offline.html', // オフライン時のフォールバック
        runtimeCaching: [
          {
            urlPattern: ({ request }) => request.destination === 'image',
            handler: 'CacheFirst',
            options: {
              cacheName: 'images',
              expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 7 } // 7日
            }
          },
          {
            urlPattern: /^https:\/\/api\.example\.com\//,
            handler: 'StaleWhileRevalidate',
            options: { cacheName: 'api-cache', networkTimeoutSeconds: 3 }
          }
        ]
      }
    })
  ]
})

main.tsで登録します(プラグインが自動でSWを生成・登録)。

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

// PWA:自動登録モード(vite-plugin-pwaが仕込みを行う)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').catch(console.error)
  })
}

オフライン用ページ(public/offline.html)を用意しておくと、ネットワークが切れた時に自動で表示されます。

<!-- public/offline.html -->
<!doctype html>
<html lang="ja">
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>オフライン</title></head>
  <body><h1>オフラインです</h1><p>接続が回復したら自動で復旧します。</p></body>
</html>

より細かく制御:手書きService Worker + Workbox

キャッシュ戦略を自前で設計したい場合は、Workboxを使ってpublic/sw.jsを作成します。

// public/sw.js
/* global workbox */
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.6.0/workbox-sw.js')

// 事前キャッシュ(プレキャッシュ)
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST || [])

// ルーティング:ページはNetworkFirst(オフライン時はfallback)
workbox.routing.registerRoute(
  ({ request }) => request.mode === 'navigate',
  new workbox.strategies.NetworkFirst({
    cacheName: 'pages',
    networkTimeoutSeconds: 4,
    plugins: [new workbox.broadcastUpdate.BroadcastUpdatePlugin()]
  })
)

// 画像はCacheFirst
workbox.routing.registerRoute(
  ({ request }) => request.destination === 'image',
  new workbox.strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new workbox.expiration.ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 14 })
    ]
  })
)

// APIはStaleWhileRevalidate
workbox.routing.registerRoute(
  ({ url }) => url.origin === 'https://api.example.com',
  new workbox.strategies.StaleWhileRevalidate({ cacheName: 'api' })
)

// オフラインフォールバック
const FALLBACK_URL = '/offline.html'
workbox.routing.setCatchHandler(async ({ event }) => {
  if (event.request.destination === 'document') return caches.match(FALLBACK_URL)
  return Response.error()
})

// 更新の即時適用(skipWaiting + clientsClaim)
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (e) => {
  e.waitUntil(self.clients.claim())
})

index.htmlでSWを登録します。

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
  })
}
</script>

キャッシュ戦略の設計指針

静的アセット(JS/CSS/フォント/ロゴ)はCacheFirstで長期キャッシュ+ハッシュ付きファイル名。HTMLはNetworkFirstで最新優先、オフライン時はキャッシュを返す。APIはStaleWhileRevalidateで体感を軽くしつつ背後更新。画像はCacheFirst+期限で肥大化を抑制。この基本の組み合わせでほとんどのアプリはカバーできます。

アップデートの扱い(更新検知と再読み込み)

SW更新は即時適用か、次回アクセスで適用かを選べます。即時適用する場合、skipWaiting()clientsClaim()を使い、UI上で「新しいバージョンがあります。更新しますか?」を出して再読み込みします。

// src/pwaUpdate.ts(簡易実装)
export function listenPwaUpdate() {
  if (!('serviceWorker' in navigator)) return
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    // 新SWが制御を開始したタイミングでリロード
    window.location.reload()
  })
}

開発・検証のコツ

Chrome DevToolsのApplication > Service Workersで「Update on reload」を有効化するとデバッグが捗ります。Offlineチェックで通信を切断し、オフラインフォールバックが効くかを確認しましょう。キャッシュはApplication > Cache Storageで確認・削除できます。

よくある落とし穴と対策

  • APIのPOST/PUT/DELETEは基本的にキャッシュしない(読み取り専用のGETのみ対象)。
  • 古いキャッシュが残る場合はキャッシュ名にバージョンを付けて段階的に破棄。
  • SPAのルーティングで404になるときは、navigateFallback(例:/index.html or /offline.html)を設定。
  • iOS SafariはPWA周りの挙動が独特。ホーム追加や容量制限を考慮し、manifestとアイコンを適切に準備。

まとめ

VueアプリのPWA化は、vite-plugin-pwaで最短導入しつつ、必要に応じてWorkboxで戦略を微調整するのが実用的です。静的アセットはCacheFirst、HTMLはNetworkFirst、APIはStaleWhileRevalidate、オフライン時はフォールバック。この定石を押さえれば、オフラインでも壊れにくく、再訪が高速なプロダクション品質の体験を提供できます。