Service Worker 基本

ページとは別の Worker スレッドで動くプロキシfetch イベントで 全ネットワーク要求を捕まえ、キャッシュ・オフライン・通知の中核になる。

登録

main.js
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      const reg = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",                // SW のスコープ
        type: "module",            // ESM の SW(最近)
        updateViaCache: "none",    // sw.js 自体をキャッシュしない
      })
      console.log("registered:", reg)
    } catch (e) {
      console.error(e)
    }
  })
}

スコープのルール

ライフサイクル

  1. install — 1回目だけ。事前キャッシュなど
  2. activate — install 後。古いキャッシュの掃除
  3. fetch / push / sync / message — 通常運転
sw.js
const CACHE = "v1"
const PRECACHE_URLS = ["/", "/styles.css", "/app.js", "/offline.html"]

self.addEventListener("install", (e) => {
  e.waitUntil(
    caches.open(CACHE).then((c) => c.addAll(PRECACHE_URLS))
  )
  self.skipWaiting()        // 即時アクティブ化
})

self.addEventListener("activate", (e) => {
  e.waitUntil((async () => {
    const keys = await caches.keys()
    await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    await self.clients.claim()    // 既存ページもこの SW に切り替え
  })())
})

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((cached) => cached || fetch(e.request))
  )
})

skipWaitingclients.claim

更新の流れ

  1. サーバ上の sw.js が変わっている
  2. ブラウザが定期的(タブを開いた時等)に取得して差分を検知
  3. 新しい SW が install される(waiting 状態
  4. 全タブが閉じられて再オープン → activate
  5. skipWaiting() を呼ぶと即時 activate

更新通知 UI(推奨)

// main.js
const reg = await navigator.serviceWorker.register("/sw.js")

reg.addEventListener("updatefound", () => {
  const newSW = reg.installing
  newSW?.addEventListener("statechange", () => {
    if (newSW.state === "installed" && navigator.serviceWorker.controller) {
      // 新しい SW が waiting に
      showUpdateBanner(() => {
        newSW.postMessage({ type: "SKIP_WAITING" })
      })
    }
  })
})

navigator.serviceWorker.addEventListener("controllerchange", () => {
  window.location.reload()
})
// sw.js
self.addEventListener("message", (e) => {
  if (e.data?.type === "SKIP_WAITING") self.skipWaiting()
})

fetch イベントの中身

self.addEventListener("fetch", (event) => {
  const { request } = event

  // GET 以外は触らない
  if (request.method !== "GET") return

  // 同一オリジンだけキャッシュ
  if (new URL(request.url).origin !== location.origin) return

  event.respondWith(handle(request))
})

async function handle(request) {
  const cache = await caches.open("v1")
  const cached = await cache.match(request)
  if (cached) return cached
  const fresh = await fetch(request)
  if (fresh.ok) cache.put(request, fresh.clone())
  return fresh
}

クライアントとの通信

SW → Client

// sw.js から開いている全タブへ
const clients = await self.clients.matchAll()
clients.forEach((c) => c.postMessage({ type: "UPDATE", payload: ... }))

Client → SW

// main.js
navigator.serviceWorker.controller?.postMessage({ type: "PING" })

// sw.js
self.addEventListener("message", (e) => {
  if (e.data.type === "PING") e.source?.postMessage({ type: "PONG" })
})

SW でできること・できないこと

デバッグ

開発環境での注意

vite-plugin-pwa の例

npm install -D vite-plugin-pwa
vite.config.ts
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: "autoUpdate",
      manifest: { name: "My App", short_name: "App", ... },
      workbox: {
        globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
      },
    }),
  ],
})

ビルド時に SW が自動生成され、Workbox の高機能なキャッシュ戦略を使える。

SW の更新は罠が多い

- 古い SW が長く残る — タブを全部閉じないと交代しない
- キャッシュキーをバージョン付きにする(v1v2 等)
- ユーザーに「更新があります」を出すのが UX 上望ましい
- HTML はキャッシュしない or NetworkFirst — 古い HTML が残ると地獄