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)
}
})
}
スコープのルール
- SW のファイル位置の同階層 or 配下がスコープ
/sw.jsはサイト全体スコープにできる/app/sw.jsは/app/以下のみscopeオプションで明示すると安全
ライフサイクル
- install — 1回目だけ。事前キャッシュなど
- activate — install 後。古いキャッシュの掃除
- 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))
)
})
skipWaiting と clients.claim
skipWaiting()— install 直後に古い SW を待たず即 activateclients.claim()— activate 時に既存ページもこの SW に切り替え- UX 注意: 既に開いているタブが急に新しい SW の挙動になる。場合によってはユーザーに更新確認を出す方が良い
更新の流れ
- サーバ上の
sw.jsが変わっている - ブラウザが定期的(タブを開いた時等)に取得して差分を検知
- 新しい SW が
installされる(waiting 状態) - 全タブが閉じられて再オープン →
activate 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 でできること・できないこと
- ○ HTTP リクエストの intercept
- ○ Cache API でキャッシュ管理
- ○ Push 通知の受信
- ○ Background Sync
- ○ IndexedDB アクセス
- ○ 他のタブとのメッセージ送受信
- × DOM アクセス
- ×
localStorage - ×
window/document - × 同期 XHR
デバッグ
- Chrome DevTools → Application → Service Workers
- "Update on reload" を ON にすると毎回最新を取得
- "Bypass for network" でキャッシュを無視
- Unregister でリセット
- Lighthouse の PWA 監査
開発環境での注意
- Vite なら vite-plugin-pwa が便利
- Next.js なら
next-pwaまたは@serwist/next - localhost は HTTPS 不要だが、本番は HTTPS 必須
- 「キャッシュが残って古いコードが動く」が頻発するので、開発中は 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 が長く残る — タブを全部閉じないと交代しない
- キャッシュキーをバージョン付きにする(v1 → v2 等)
- ユーザーに「更新があります」を出すのが UX 上望ましい
- HTML はキャッシュしない or NetworkFirst — 古い HTML が残ると地獄