キャッシュ

Cache Storage API でレスポンスを保存・再利用。Cache First / Network First / Stale While Revalidate など コンテンツの種類に応じた戦略を組み合わせる。

Cache API の基本

// 開く(無ければ作成)
const cache = await caches.open("v1")

// 単一の URL を取って入れる
await cache.add("/styles.css")

// 複数まとめて
await cache.addAll(["/", "/app.js", "/styles.css"])

// 任意の Response を入れる
await cache.put("/api/data", new Response(JSON.stringify({ ok: true })))

// 取得
const res = await cache.match("/styles.css")

// 削除
await cache.delete("/old-asset.js")

// キャッシュ全体を削除
await caches.delete("v1")

// すべてのキャッシュを列挙
const keys = await caches.keys()

主な戦略

1. Cache First(オフライン優先)

キャッシュにあれば返す、無ければ network。静的ファイル(CSS / JS / 画像)に最適。

async function cacheFirst(req) {
  const cached = await caches.match(req)
  if (cached) return cached
  const fresh = await fetch(req)
  const cache = await caches.open("static")
  cache.put(req, fresh.clone())
  return fresh
}

2. Network First(最新優先)

まず network、失敗したらキャッシュ。HTML / API レスポンスに。

async function networkFirst(req) {
  try {
    const fresh = await fetch(req)
    const cache = await caches.open("dynamic")
    cache.put(req, fresh.clone())
    return fresh
  } catch {
    const cached = await caches.match(req)
    return cached || new Response("Offline", { status: 503 })
  }
}

3. Stale While Revalidate

まずキャッシュを返して、裏で network から取り直してキャッシュ更新。 速度と新鮮さの折衷。アバター画像・ニュース等に。

async function staleWhileRevalidate(req) {
  const cache = await caches.open("v1")
  const cached = await cache.match(req)
  const fetching = fetch(req).then((res) => {
    cache.put(req, res.clone())
    return res
  })
  return cached || fetching
}

4. Network Only

async function networkOnly(req) {
  return fetch(req)
}

5. Cache Only

async function cacheOnly(req) {
  const cached = await caches.match(req)
  if (cached) return cached
  return new Response(null, { status: 504 })
}

戦略の使い分け

コンテンツ戦略
HTML(メイン)Network First or NetworkOnly
CSS / JS(バージョン付き URL)Cache First
CSS / JS(バージョン無し)Network First
画像(アセット)Cache First
API(変動)Network First or SWR
API(不変)Cache First
フォントCache First(永続)

パスごとに戦略を分ける

self.addEventListener("fetch", (e) => {
  const url = new URL(e.request.url)

  // HTML は network first
  if (e.request.mode === "navigate") {
    e.respondWith(networkFirst(e.request))
    return
  }

  // 画像は cache first
  if (e.request.destination === "image") {
    e.respondWith(cacheFirst(e.request))
    return
  }

  // API は SWR
  if (url.pathname.startsWith("/api/")) {
    e.respondWith(staleWhileRevalidate(e.request))
    return
  }

  // それ以外
  e.respondWith(cacheFirst(e.request))
})

事前キャッシュ(Precache)

SW install 時に必須リソースをまとめて取得しておく。

const PRECACHE = "precache-v1"
const PRECACHE_URLS = [
  "/",
  "/app.js",
  "/styles.css",
  "/icons/icon-192.png",
  "/offline.html",
]

self.addEventListener("install", (e) => {
  e.waitUntil(
    caches.open(PRECACHE).then((c) => c.addAll(PRECACHE_URLS))
  )
})

キャッシュのバージョニング

デプロイで内容が変わったらキャッシュキーを変える。古い物は activate で削除。

const VERSION = "v3"
const CACHES = {
  static: `static-${VERSION}`,
  dynamic: `dynamic-${VERSION}`,
}

self.addEventListener("activate", (e) => {
  e.waitUntil((async () => {
    const keys = await caches.keys()
    const valid = Object.values(CACHES)
    await Promise.all(keys.filter((k) => !valid.includes(k)).map((k) => caches.delete(k)))
  })())
})

キャッシュサイズの管理

async function trimCache(name, max) {
  const cache = await caches.open(name)
  const keys = await cache.keys()
  if (keys.length > max) {
    await cache.delete(keys[0])     // FIFO
    await trimCache(name, max)
  }
}

// 画像キャッシュは 50 件まで
e.waitUntil(trimCache("images", 50))

StorageManager で容量確認

const { quota, usage } = await navigator.storage.estimate()
console.log(`${(usage / 1024 / 1024).toFixed(1)} / ${(quota / 1024 / 1024).toFixed(1)} MB`)

// 永続化を要求
const persisted = await navigator.storage.persist()

Workbox(推奨)

Google のSW ライブラリ。戦略が組み込みで、precache の自動更新もしてくれる。

import { registerRoute } from "workbox-routing"
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies"
import { ExpirationPlugin } from "workbox-expiration"
import { precacheAndRoute } from "workbox-precaching"

precacheAndRoute(self.__WB_MANIFEST)        // ビルド時に注入

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "images",
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }),
    ],
  }),
)

registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new NetworkFirst({ cacheName: "api", networkTimeoutSeconds: 5 }),
)

registerRoute(
  ({ request }) => request.mode === "navigate",
  new NetworkFirst({ cacheName: "pages" }),
)

vite-plugin-pwa / next-pwa / serwist 経由で使うのが楽。

キャッシュの破棄

ユーザーから「キャッシュが古い」相談が来たら、SW から手動で:

// クライアント側
async function clearAllCaches() {
  const keys = await caches.keys()
  await Promise.all(keys.map((k) => caches.delete(k)))
  if (navigator.serviceWorker.controller) {
    const reg = await navigator.serviceWorker.getRegistration()
    await reg?.unregister()
  }
  location.reload()
}
HTML はキャッシュに注意

HTML をキャッシュすると古いバージョンが残り続ける地獄になりやすい。
✓ HTML は NetworkFirst + networkTimeoutSeconds 短め
✓ 静的アセットはファイル名にハッシュを入れて Cache First
✓ キャッシュキーはバージョン付きにする