オフライン対応

ネット切断時の振る舞いを設計する。「読む」はキャッシュで、「書く」は Background Sync で後送が定石。 IndexedDB でローカル DB を持つとアプリ的な体験になる。

オフライン UX の設計

  1. 初回オンライン時に必須リソースをキャッシュ(HTML / CSS / JS / 画像)
  2. オフラインを検知して通知(バナー等)
  3. オフライン時はキャッシュ済みデータを表示
  4. 「書き込み」はローカル保存 → オンライン復帰時に同期
  5. 同期できないデータは明示的に表示(「未送信」バッジ等)

オフライン検知

// 一発判定
if (navigator.onLine) { /* online */ }

// イベント
window.addEventListener("online", () => {
  showToast("オンラインに復帰しました")
  syncPending()
})
window.addEventListener("offline", () => {
  showToast("オフラインモード")
})
navigator.onLine の限界

「ネットワークインターフェイスが繋がっているか」しか分からない。Wi-Fi に繋がっているがインターネットに出れない状況は検知できない。
実際の通信可否は fetch で確認する。

実通信での確認

async function isOnline() {
  if (!navigator.onLine) return false
  try {
    await fetch("/ping", { method: "HEAD", cache: "no-store" })
    return true
  } catch {
    return false
  }
}

オフラインフォールバックページ

ナビゲーション時にネットも cache も無い場合のフォールバック HTML:

// sw.js
const FALLBACK = "/offline.html"

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open("v1").then((c) => c.add(FALLBACK)))
})

self.addEventListener("fetch", (e) => {
  if (e.request.mode !== "navigate") return
  e.respondWith((async () => {
    try {
      return await fetch(e.request)
    } catch {
      const cache = await caches.open("v1")
      return (await cache.match(e.request)) || (await cache.match(FALLBACK))!
    }
  })())
})

画像のフォールバック

async function imageFallback(req) {
  try { return await fetch(req) }
  catch {
    return caches.match("/offline-image.svg") ||
      new Response('<svg xmlns="http://www.w3.org/2000/svg" />', {
        headers: { "Content-Type": "image/svg+xml" },
      })
  }
}

IndexedDB でローカルストレージ

本格的なオフラインアプリはIndexedDBを使う。生 API は冗長なので idb ラッパが定番。

npm install idb
import { openDB } from "idb"

const db = await openDB("myapp", 1, {
  upgrade(db) {
    const store = db.createObjectStore("notes", { keyPath: "id" })
    store.createIndex("by-updated", "updatedAt")
  },
})

// 書き込み
await db.put("notes", { id: "1", title: "メモ", body: "...", updatedAt: Date.now() })

// 読み込み
const note = await db.get("notes", "1")
const all = await db.getAll("notes")

// インデックス検索
const recent = await db.getAllFromIndex("notes", "by-updated")

書き込みのオフライン対応

オフラインで作成・編集したものをキューに溜め、オンライン復帰時に送る:

async function saveNote(note) {
  // ローカル DB に即保存
  await db.put("notes", note)

  // 送信キューにも追加
  await db.add("outbox", { type: "save_note", note, ts: Date.now() })

  // オンラインなら即試す
  if (navigator.onLine) flushOutbox()
}

async function flushOutbox() {
  const items = await db.getAll("outbox")
  for (const item of items) {
    try {
      await fetch("/api/notes", {
        method: "POST",
        body: JSON.stringify(item.note),
      })
      await db.delete("outbox", item.id)
    } catch (e) {
      // また次回
      break
    }
  }
}

window.addEventListener("online", flushOutbox)

Background Sync(推奨)

SW のBackground Syncを使うと、オンラインに復帰した時点で自動的に処理を走らせられる。 ユーザーがタブを閉じていてもOK。Chromium のみ。

// メイン側
async function queueSync() {
  const reg = await navigator.serviceWorker.ready
  if ("sync" in reg) {
    await reg.sync.register("flush-outbox")
  } else {
    flushOutbox()    // フォールバック
  }
}

// SW 側
self.addEventListener("sync", (e) => {
  if (e.tag === "flush-outbox") {
    e.waitUntil(flushOutboxInSW())
  }
})

楽観的更新

UI 上はすぐ反映、サーバ送信は裏で。失敗時のリトライ失敗時のロールバックを考える。

同期競合の扱い

ローカルファースト DB(PowerSync / WatermelonDB / RxDB)

本格的なオフラインアプリでは専用の同期エンジンを使うと楽。 サーバとの差分同期、競合解決、CRDT を提供。

UI の作り方

テスト

本格オフラインアプリのチェック

✓ 最初の表示が完全にキャッシュから可能
✓ オフライン時に有意義なコンテンツが見える
✓ ユーザーがオフラインだと分かる UI
✓ 書き込みがローカル保存 + 後送される
✓ 同期エラーが見える / 再試行できる
✓ オンライン復帰時に Background Sync で自動同期