SPAのService Workerとオフラインキャッシュ戦略

Service Workerは「オフライン対応のためのAPI」ではありません

SPA(Single Page Application)を学び始めたとき、多くの解説で「Service Worker=オフライン対応」と説明されます。確かにオフライン表示は可能になりますが、それは本質ではありません。

Service Workerの本当の役割は、ブラウザとサーバの間に“もう一つのサーバ”を作ることです。

もう少し具体的に言うと、HTTP通信をJavaScriptで制御できるようにする仕組みです。つまりService Workerはフロントエンドの機能ではなく、ネットワーク層の機能です。

これを理解すると、SPAにおけるキャッシュ戦略の意味が大きく変わります。単なる「高速化」ではなく、「通信の設計」になります。

SPAが抱える問題とキャッシュの必要性

SPAの通信はページではなくリソース単位になる

従来のWeb(MPA)では、ブラウザはページ単位で通信していました。

  • HTMLを取得
  • CSSを取得
  • JSを取得
  • ページ遷移で再取得

一方SPAではページ遷移がありません。その代わり、次のような通信が増えます。

  • APIリクエスト
  • JSON取得
  • 画像取得
  • 遅延ロードのJS

つまりSPAは「1回の大きな通信」から「大量の小さな通信」へ変わります。ここで問題が発生します。

回線が遅い環境では、毎回サーバへ取りに行くと体感速度が大きく落ちます。特にスマートフォン環境では顕著です。

この通信制御を行うのがService Workerです。

Service Workerの動作原理

ブラウザの外で動くJavaScript

Service Workerは通常のJavaScriptと違い、ページに紐付きません。ウィンドウが閉じられても存在し続けます。

登録は次のように行います。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
}

この時点でブラウザは、sw.jsを「通信の代理人」として扱います。

重要なのはここからです。Service Workerはfetchイベントをフックできます。

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request))
})

つまり、ブラウザの全HTTPリクエストを横取りできます

ここがキャッシュ戦略の核心です。

キャッシュストレージという別のキャッシュ

ブラウザキャッシュとの違い

HTTPには元々キャッシュがあります。Cache-ControlやETagです。しかしSPAではそれだけでは不十分です。

理由は、HTTPキャッシュはサーバ主導だからです。

Service Workerが使うのはCache Storage APIです。

const cache = await caches.open('app-cache')
await cache.put(request, response)

このキャッシュは

  • 有効期限を自由に決められる
  • APIレスポンスも保存できる
  • プログラムで削除できる

つまり、アプリケーションレベルのキャッシュです。

SPAで使われる5つのキャッシュ戦略

Cache First

まずキャッシュを確認し、無ければ通信します。

特徴:

  • 非常に高速
  • オフラインで動く
  • 更新が遅れる

主にJS/CSS/画像に向きます。

Network First

まず通信し、失敗したらキャッシュを使います。

特徴:

  • 常に最新
  • オフライン対応可能
  • 初回が遅い

APIレスポンスに向きます。

Stale While Revalidate

キャッシュを即返し、裏で通信して更新します。

event.respondWith(
  caches.match(req).then(cacheRes => {
    const fetchRes = fetch(req).then(res => {
      cache.put(req, res.clone())
      return res
    })
    return cacheRes || fetchRes
  })
)

体感速度が最も良い方式で、多くのPWAが採用しています。

Network Only

常に通信。キャッシュしません。認証系APIで使います。

Cache Only

キャッシュのみ。オフライン画面などに使います。

オフライン対応の本当の作り方

よくある失敗は「オフラインページだけ作る」ことです。それではSPAは壊れます。

SPAが動くために必要なのは次です。

  • index.html
  • JavaScriptバンドル
  • ルーティングJS

つまり「画面」ではなくアプリケーション本体をキャッシュしなければなりません。

installイベントでプリキャッシュを行います。

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('app').then(cache => {
      return cache.addAll([
        '/',
        '/index.html',
        '/main.js',
        '/styles.css'
      ])
    })
  )
})

これで回線が無くてもSPAは起動します。

よく起きる事故と注意点

更新されない問題

Service Worker最大の罠は「更新されない」です。

ブラウザは安全のため、古いService Workerを残します。その結果

  • 新しいJSをデプロイ
  • ユーザは古いJSを実行
  • API仕様がズレる
  • 画面が壊れる

という事故が現実に起きます。

対策としてactivateイベントで古いキャッシュを削除します。

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys.map(key => {
          if (key !== 'app-v2') return caches.delete(key)
        })
      )
    )
  )
})

ログイン情報をキャッシュしてしまう危険

APIレスポンスを何でもキャッシュすると、別ユーザにデータが見える可能性があります。特に

  • /me
  • /profile
  • /cart

などのエンドポイントはキャッシュしてはいけません。

SPAとService Workerの関係

SPAの本質は「画面をダウンロードするアプリケーション」です。つまり一度取得したリソースをどれだけ再利用できるかが体験を決めます。

Service Workerは単なるPWA機能ではありません。SPAのネットワークアーキテクチャそのものです。

SPAが速いかどうかは、JavaScriptの軽さよりも、キャッシュ設計で決まることが多いです。レンダリングの最適化より先に、通信の最適化を考える方が体感速度は改善します。

最終的に重要なのは「オフラインにすること」ではありません。ユーザにネットワークの存在を意識させないことです。

Service Workerはそのための仕組みです。通信を隠蔽することで、Webは初めてアプリケーションの振る舞いに近づきます。