【Vue.js】外部APIの結果をキャッシュしてAPIコール回数を削減する方法

【Vue.js】外部APIの結果をキャッシュしてAPIコール回数を削減する方法 Vue.js

外部APIの呼び出しが多いと、描画性能の低下やレート制限、コスト増の原因になります。Vue 3では、Composableで「メモリ/LocalStorageのキャッシュ」「TTL(有効期限)」「キー設計」を共通化しておくと、どのコンポーネントからでも簡単に再利用できます。ここでは、即時表示+期限付きキャッシュを実現する実装例と、無効化や再検証の設計ポイントを紹介します。

方針と採用パターン

まずはフロント側で扱いやすい以下のパターンを組み合わせます。

メモリキャッシュ(Map):同一ページ内や短時間の再利用に最速で応答。
LocalStorage:タブをまたいだ継続・リロードに強い。JSON+TTLで管理。
TTL:データ鮮度の上限。期限切れなら再取得。
キー設計:エンドポイント+クエリ+ユーザーID等をハッシュ化して衝突を回避。

Composable:useApiCache(メモリ+LocalStorage+TTL)

// src/composables/useApiCache.ts
import { ref } from 'vue'

// メモリキャッシュ(ページ滞在中の最速ヒット)
const memory = new Map<string, any>()

type Fetcher<T> = () => Promise<T>

interface Options {
  ttl?: number;           // ms: 有効期限(例: 60_000 = 60秒)
  key?: string;           // キャッシュキー(指定なければ自動)
  storage?: 'memory' | 'local' | 'both';
  revalidate?: boolean;   // trueで期限内でも背後で再取得(SWR風)
}

function buildKey(url: string, params?: Record<string, any>, key?: string) {
  if (key) return key
  const q = params ? JSON.stringify(params) : ''
  return `api:${url}:${q}`
}

function readLocal(key: string) {
  try {
    const raw = localStorage.getItem(key)
    if (!raw) return null
    const parsed = JSON.parse(raw)
    if (parsed.exp && Date.now() > parsed.exp) {
      localStorage.removeItem(key)
      return null
    }
    return parsed.value
  } catch { return null }
}

function writeLocal(key: string, value: any, ttl?: number) {
  try {
    const exp = ttl ? Date.now() + ttl : undefined
    localStorage.setItem(key, JSON.stringify({ value, exp }))
  } catch { /* storage満杯などは無視 */ }
}

export function useApiCache<T>(url: string, fetcher: Fetcher<T>, params?: Record<string, any>, opts: Options = {}) {
  const { ttl = 60_000, storage = 'both', revalidate = false } = opts
  const key = buildKey(url, params, opts.key)
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<unknown | null>(null)

  // 1) メモリ or LocalStorage から即時読み込み
  const mem = memory.get(key)
  if (mem !== undefined) {
    data.value = mem
  } else if (storage !== 'memory') {
    const loc = readLocal(key)
    if (loc !== null) {
      data.value = loc
      memory.set(key, loc)
    }
  }

  // 2) 期限判定(LocalStorageの期限はreadLocal内でチェック済み)
  const isExpired = () => {
    if (storage === 'local' || storage === 'both') {
      // LocalStorage側で期限切れならnullが返るため、ここではメモリに残っているかを緩く判定
      return data.value === null
    }
    // メモリのみ運用時はTTL管理を簡略化(都度再取得でもOK)
    return true
  }

  // 3) 取得関数
  async function get() {
    loading.value = true
    error.value = null
    try {
      const fresh = await fetcher()
      data.value = fresh
      memory.set(key, fresh)
      if (storage !== 'memory') writeLocal(key, fresh, ttl)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 4) 初回起動:期限切れなら取得。revalidate=trueなら背後で再取得
  if (data.value === null || isExpired()) {
    void get()
  } else if (revalidate) {
    // 表示はキャッシュのまま、背後で更新
    void get()
  }

  // 5) 明示的な無効化・再取得API
  function invalidate() {
    memory.delete(key)
    if (storage !== 'memory') localStorage.removeItem(key)
  }
  async function refresh() {
    invalidate(); await get()
  }

  return { data, loading, error, refresh, invalidate, key }
}

使用例:ユーザー一覧APIをキャッシュして表示

<template>
  <button @click="refresh" :disabled="loading">再読み込み</button>
  <p v-if="loading">読み込み中...</p>
  <p v-else-if="error">エラーが発生しました</p>
  <ul v-else>
    <li v-for="u in data" :key="u.id">{{ u.name }}</li>
  </ul>
</template>

<script setup lang="ts">
import { useApiCache } from '@/composables/useApiCache'

type User = { id: number; name: string }
const url = 'https://jsonplaceholder.typicode.com/users'

async function fetchUsers(): Promise<User[]> {
  const res = await fetch(url)
  if (!res.ok) throw new Error('fetch failed')
  return res.json()
}

// 期限60秒・SWR再検証オン(表示は即キャッシュ、背後で更新)
const { data, loading, error, refresh } = useApiCache<User[]>(
  url,
  fetchUsers,
  undefined,
  { ttl: 60_000, storage: 'both', revalidate: true }
)
</script>

キャッシュ無効化の基本設計

更新系操作(POST/PUT/DELETE)後は、関連キーを無効化してから再取得します。

import { useApiCache } from '@/composables/useApiCache'
const { invalidate, refresh } = useApiCache('/api/items', () => fetch('/api/items').then(r => r.json()))

async function createItem(payload: any) {
  await fetch('/api/items', { method: 'POST', body: JSON.stringify(payload) })
  invalidate()
  await refresh()
}

HTTPの再検証(ETag / If-None-Match)を使う

APIがETag対応なら、レスポンスヘッダのETagを保存し、次回はIf-None-Matchで再検証すると、変更がなければ304で軽量化できます。

// ETag付きfetcher例
async function fetchWithETag(url: string, etagKey: string) {
  const etag = localStorage.getItem(etagKey) || ''
  const res = await fetch(url, { headers: etag ? { 'If-None-Match': etag } : {} })
  if (res.status === 304) {
    // 変更なし:既存キャッシュをそのまま利用
    return null
  }
  const data = await res.json()
  const newTag = res.headers.get('ETag')
  if (newTag) localStorage.setItem(etagKey, newTag)
  return data
}

キー設計のコツ

キャッシュキーは「URL+ソート済みクエリ+ユーザー識別子」を含め、JSON.stringifyの順序依存を避けるため、キー生成時にクエリをキーソートします。権限やロールによって返却内容が変わるAPIはユーザーIDも含めます。

プリフェッチで体感速度を改善

ユーザーがメニューを開いた・マウスオーバーしたタイミングで次画面のAPIを先読みしておくと、ページ遷移後の体感が向上します。

function onMenuHover() {
  // 既にキャッシュされている場合は何もしない(useApiCache内で判定可能)
  // 未取得なら背後でget()(= revalidate)を走らせる構成にしておく
}

Service WorkerやIndexedDBの検討ポイント

大量データや長期保存が必要なら、Service Worker(Workbox)でネットワーク戦略(NetworkFirst/CacheFirst)を組み合わせたり、IndexedDBを使うと堅牢です。まずはメモリ+LocalStorageの軽量実装で効果を確認し、要件に応じて段階的に拡張しましょう。

運用・トラブル対策

キャッシュ破棄のUI(更新ボタン)を提供し、致命的な不整合を回避します。LocalStorageは容量制限があるため、キー削除や名前空間(app:api:* )で管理し、不要データを定期清掃すると安定します。

まとめ

Vue 3では、Composableでキャッシュを共通化するだけでAPIコール数を大幅に削減できます。メモリ+LocalStorage+TTLにより「即時表示」と「鮮度維持」を両立し、更新時は無効化・再取得、可能ならETag再検証を併用すると安定します。まずは軽量な実装から導入し、必要に応じてService WorkerやIndexedDBへスケールさせていきましょう。