外部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へスケールさせていきましょう。