Vue 3 KeepAliveコンポーネントキャッシュ:ルートレベルキャッシュとメモリ最適化の5つのコア戦略

前端工程

コンポーネントキャッシュのペインポイント:なぜKeepAliveは難しいのか

2026年、Vue 3の<KeepAlive>はフロントエンドルートキャッシュの最適解であり続けていますが、ルート切り替え時の状態喪失、メモリリーク、include/exclude設定の混乱、キャッシュデータの更新不可という4つのペインポイントが多くの開発者を悩ませています。KeepAliveの本来の目的はコンポーネントインスタンスをキャッシュして再レンダリングを回避することですが、本番環境でのキャッシュ管理は想像以上に複雑です。

ペインポイント 具体的な症状 影響
ルート切り替え時の状態喪失 一覧ページから詳細ページに遷移して戻ると、スクロール位置とフィルタ条件がリセット ユーザー体験が最悪
KeepAliveメモリリーク キャッシュコンポーネントが正しく破棄されず、クロージャ参照が解放されない ページが徐々に重くなる
include/exclude設定の混乱 動的ルートでコンポーネント名マッチングが失敗、キャッシュリストの保守が困難 キャッシュが実質無効
キャッシュコンポーネントのデータ未更新 キャッシュコンポーネントが古いデータを再利用、新しいAPIレスポンスが反映されない データ不整合

核心的な見解:KeepAliveは単なる<router-view>のラッパーではなく、ルートmeta宣言、動的キャッシュリスト管理、LRU削除戦略、データリフレッシュ機構、メモリリーク防止までの全チェーンエンジニアリングソリューションです。


コア概念一覧

概念 説明
KeepAlive Vue 3内蔵コンポーネント、動的コンポーネントインスタンスを破棄せずキャッシュ
include/exclude コンポーネント名でマッチングし、キャッシュ対象を制御
max 最大キャッシュインスタンス数、超過時はLRU戦略で最も古いインスタンスを削除
activated/deactivated キャッシュコンポーネントの活性化/非活性化時のライフサイクルフック
ルートmeta ルート設定のカスタムフィールド、キャッシュ要否を宣言
キャッシュKey KeepAlive内部でキャッシュインスタンスを識別する一意キー、デフォルトはコンポーネントname
コンポーネントインスタンス KeepAliveでキャッシュされたVNodeと関連するリアクティブ状態+DOM
メモリ管理 キャッシュコンポーネントのメモリ使用量を監視・クリーンアップし、リークを防止

5つの課題を深掘り分析

課題1:キャッシュコンポーネントのメモリリーク

KeepAliveでキャッシュされたコンポーネントは多数のクロージャ参照を保持します:リアクティブデータ、イベントリスナー、タイマー、DOM参照。ルートの頻繁な切り替えでキャッシュインスタンスが蓄積し、メモリ使用量が増大し続けます。ローエンドデバイスでは、10個のキャッシュコンポーネントで50MB以上を占有する可能性があります。

課題2:ルートレベルのキャッシュ制御

ルートごとにキャッシュ要件は異なります:一覧ページはスクロール位置とフィルタ状態を保持する必要があり、詳細ページは通常キャッシュ不要です。ハードコードされたincludeリストではなく、ルートmetaで宣言的にキャッシュ動作を制御するには?

課題3:キャッシュデータの期限切れ

キャッシュコンポーネントのactivatedフックが発火した時、データは既に期限切れの可能性があります(例:一覧ページのデータがバックエンドで更新済み)。活性化時にデータをインテリジェントにリフレッシュしつつ、ユーザーの操作状態を保持するには?

課題4:動的include管理

動的ルートや権限ルートにより、コンポーネント名が実行時に決定されます。静的なinclude設定では全シナリオをカバーできず、実行時の動的キャッシュリスト管理が必要です。

課題5:キャッシュKeyの衝突

同名コンポーネントが異なるルートで独立したキャッシュを必要とする場合(例:複数タブで同じ一覧コンポーネントを使用)、デフォルトのコンポーネントnameをキャッシュKeyとすると衝突が発生します。


戦略1:基本的なKeepAliveとルートmeta設定

// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'

interface RouteMeta {
  keepAlive?: boolean
  cacheKey?: string
}

const routes: RouteRecordRaw[] = [
  {
    path: '/users',
    name: 'UserList',
    component: () => import('@/views/UserList.vue'),
    meta: { keepAlive: true, cacheKey: 'UserList' } as RouteMeta
  },
  {
    path: '/users/:id',
    name: 'UserDetail',
    component: () => import('@/views/UserDetail.vue'),
    meta: { keepAlive: false } as RouteMeta
  },
  {
    path: '/settings',
    name: 'Settings',
    component: () => import('@/views/Settings.vue'),
    meta: { keepAlive: true, cacheKey: 'Settings' } as RouteMeta
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

// src/App.vue
import { computed, type Component } from 'vue'
import { useRouter } from 'vue-router'
import Layout from '@/layouts/Layout.vue'

const router = useRouter()

const cacheComponents = computed<string[]>(() => {
  return router.getRoutes()
    .filter(route => route.meta.keepAlive)
    .map(route => (route.meta.cacheKey as string) || route.name as string)
})
<!-- src/App.vue template -->
<template>
  <Layout>
    <RouterView v-slot="{ Component, route }">
      <KeepAlive :include="cacheComponents">
        <component :is="Component" :key="route.meta.cacheKey || route.name" />
      </KeepAlive>
    </RouterView>
  </Layout>
</template>

重要ポイント:ルートmetaでkeepAliveを宣言してキャッシュ動作を制御し、cacheKeyで同名コンポーネントの衝突を解決。<RouterView>v-slotで現在のコンポーネントインスタンスを取得し、:keyでキャッシュKeyの一意性を確保。


戦略2:動的include/exclude管理

// src/composables/useKeepAliveStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useKeepAliveStore = defineStore('keepAlive', () => {
  const includeList = ref<Set<string>>(new Set())
  const excludeList = ref<Set<string>>(new Set())

  const includeArray = computed(() => Array.from(includeList.value))
  const excludeArray = computed(() => Array.from(excludeList.value))

  function addInclude(name: string): void {
    includeList.value.add(name)
    excludeList.value.delete(name)
  }

  function removeInclude(name: string): void {
    includeList.value.delete(name)
  }

  function addExclude(name: string): void {
    excludeList.value.add(name)
    includeList.value.delete(name)
  }

  function removeExclude(name: string): void {
    excludeList.value.delete(name)
  }

  function clearCache(name: string): void {
    removeInclude(name)
    includeList.value.add(name)
  }

  function resetAll(): void {
    includeList.value.clear()
    excludeList.value.clear()
  }

  return {
    includeList,
    excludeList,
    includeArray,
    excludeArray,
    addInclude,
    removeInclude,
    addExclude,
    removeExclude,
    clearCache,
    resetAll
  }
})

// src/composables/useRouteKeepAlive.ts
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useKeepAliveStore } from './useKeepAliveStore'

export function useRouteKeepAlive(): void {
  const route = useRoute()
  const keepAliveStore = useKeepAliveStore()

  watch(
    () => route.name,
    (newName, oldName) => {
      if (oldName && route.matched.some(r => r.meta.keepAlive)) {
        const oldRoute = route.matched.find(r => r.name === oldName)
        if (oldRoute?.meta.keepAlive) {
          keepAliveStore.addInclude(oldRoute.meta.cacheKey as string || oldName as string)
        }
      }

      if (newName) {
        const newRoute = route.matched.find(r => r.name === newName)
        if (newRoute?.meta.keepAlive === false) {
          keepAliveStore.addExclude(newName as string)
        }
      }
    },
    { immediate: true }
  )
}
<!-- 動的KeepAliveラッパー -->
<template>
  <RouterView v-slot="{ Component, route }">
    <KeepAlive :include="keepAliveStore.includeArray" :exclude="keepAliveStore.excludeArray">
      <component :is="Component" :key="route.meta.cacheKey || route.name" />
    </KeepAlive>
  </RouterView>
</template>

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

const keepAliveStore = useKeepAliveStore()
useRouteKeepAlive()
</script>

戦略3:maxキャッシュ上限とLRU削除

// src/composables/useKeepAliveLru.ts
import { ref, watch, onUnmounted, type Ref } from 'vue'
import { useRoute } from 'vue-router'

interface CacheEntry {
  name: string
  lastAccessed: number
  memoryEstimate: number
}

export function useKeepAliveLru(maxCache: number = 10) {
  const route = useRoute()
  const keepAliveStore = useKeepAliveStore()
  const cacheEntries = ref<CacheEntry[]>([])
  const currentCacheSize = ref(0)

  function touchEntry(name: string): void {
    const existing = cacheEntries.value.find(e => e.name === name)
    if (existing) {
      existing.lastAccessed = Date.now()
    } else {
      cacheEntries.value.push({
        name,
        lastAccessed: Date.now(),
        memoryEstimate: 0
      })
    }
  }

  function evictOldest(): string | null {
    if (cacheEntries.value.length === 0) return null
    const sorted = [...cacheEntries.value].sort((a, b) => a.lastAccessed - b.lastAccessed)
    const oldest = sorted[0]
    cacheEntries.value = cacheEntries.value.filter(e => e.name !== oldest.name)
    keepAliveStore.removeInclude(oldest.name)
    return oldest.name
  }

  function enforceLimit(): void {
    while (cacheEntries.value.length >= maxCache) {
      evictOldest()
    }
  }

  watch(
    () => route.name,
    (name) => {
      if (!name) return
      const routeConfig = route.matched.find(r => r.name === name)
      if (!routeConfig?.meta.keepAlive) return

      const cacheKey = (routeConfig.meta.cacheKey as string) || (name as string)
      touchEntry(cacheKey)
      keepAliveStore.addInclude(cacheKey)
      enforceLimit()
      currentCacheSize.value = cacheEntries.value.length
    }
  )

  function getCacheStats(): { size: number; max: number; entries: CacheEntry[] } {
    return {
      size: cacheEntries.value.length,
      max: maxCache,
      entries: [...cacheEntries.value]
    }
  }

  function clearAll(): void {
    cacheEntries.value.forEach(e => keepAliveStore.removeInclude(e.name))
    cacheEntries.value = []
    currentCacheSize.value = 0
  }

  onUnmounted(() => clearAll())

  return {
    cacheEntries,
    currentCacheSize,
    getCacheStats,
    clearAll,
    evictOldest
  }
}

LRU削除の原理:KeepAliveの組み込みmaxプロパティはLRU削除を実装していますが、ルートのセマンティクスを認識できません。カスタムLRU戦略では、削除前にクリーンアップロジック(タイマーのクリア、ネットワークリクエストの中止など)を実行でき、「ゾンビキャッシュ」を防げます。


戦略4:キャッシュコンポーネントのデータリフレッシュ戦略

// src/composables/useCacheRefresh.ts
import { onActivated, ref, type Ref } from 'vue'

interface RefreshOptions {
  staleTime?: number
  refreshOnActivate?: boolean
  preserveScroll?: boolean
}

export function useCacheRefresh<T>(
  fetchFn: () => Promise<T>,
  options: RefreshOptions = {}
) {
  const {
    staleTime = 5 * 60 * 1000,
    refreshOnActivate = true,
    preserveScroll = true
  } = options

  const data: Ref<T | null> = ref(null)
  const loading = ref(false)
  const lastFetchedAt = ref(0)
  const scrollPosition = ref({ x: 0, y: 0 })

  async function fetchData(forceRefresh = false): Promise<void> {
    const isStale = Date.now() - lastFetchedAt.value > staleTime
    if (!forceRefresh && !isStale && data.value !== null) return

    loading.value = true
    try {
      data.value = await fetchFn()
      lastFetchedAt.value = Date.now()
    } finally {
      loading.value = false
    }
  }

  function saveScrollPosition(): void {
    if (preserveScroll) {
      scrollPosition.value = {
        x: window.scrollX,
        y: window.scrollY
      }
    }
  }

  function restoreScrollPosition(): void {
    if (preserveScroll) {
      window.scrollTo(scrollPosition.value.x, scrollPosition.value.y)
    }
  }

  onActivated(() => {
    if (refreshOnActivate) {
      fetchData()
    }
    restoreScrollPosition()
  })

  return {
    data,
    loading,
    lastFetchedAt,
    scrollPosition,
    fetchData,
    saveScrollPosition,
    restoreScrollPosition
  }
}

// キャッシュコンポーネントでの使用
// src/views/UserList.vue
import { useCacheRefresh } from '@/composables/useCacheRefresh'
import { fetchUserList } from '@/api/user'

const { data: users, loading, fetchData, saveScrollPosition } = useCacheRefresh(
  () => fetchUserList({ page: 1, size: 20 }),
  { staleTime: 3 * 60 * 1000, preserveScroll: true }
)

import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave(() => {
  saveScrollPosition()
})

戦略5:メモリリーク検出と修正

// src/composables/useKeepAliveMonitor.ts
import { onMounted, onUnmounted, ref } from 'vue'

interface MemorySnapshot {
  timestamp: number
  usedJSHeapSize: number
  totalJSHeapSize: number
  cacheCount: number
}

export function useKeepAliveMonitor(intervalMs: number = 10000) {
  const snapshots = ref<MemorySnapshot[]>([])
  const isLeaking = ref(false)
  let timer: ReturnType<typeof setInterval> | null = null

  function takeSnapshot(): MemorySnapshot | null {
    const performance = window.performance as any
    if (!performance?.memory) return null

    const keepAliveStore = useKeepAliveStore()
    return {
      timestamp: Date.now(),
      usedJSHeapSize: performance.memory.usedJSHeapSize,
      totalJSHeapSize: performance.memory.totalJSHeapSize,
      cacheCount: keepAliveStore.includeArray.length
    }
  }

  function detectLeak(): void {
    if (snapshots.value.length < 3) return
    const recent = snapshots.value.slice(-5)
    const heapGrowth = recent[recent.length - 1].usedJSHeapSize - recent[0].usedJSHeapSize
    const avgGrowthRate = heapGrowth / recent.length
    isLeaking.value = avgGrowthRate > 1024 * 1024
  }

  onMounted(() => {
    timer = setInterval(() => {
      const snapshot = takeSnapshot()
      if (snapshot) {
        snapshots.value.push(snapshot)
        if (snapshots.value.length > 50) {
          snapshots.value = snapshots.value.slice(-30)
        }
        detectLeak()
      }
    }, intervalMs)
  })

  onUnmounted(() => {
    if (timer) clearInterval(timer)
  })

  return { snapshots, isLeaking, takeSnapshot }
}

// キャッシュコンポーネントのリーク修正パターン
// src/composables/useSafeCacheComponent.ts
import { onDeactivated, onUnmounted } from 'vue'

export function useSafeCacheComponent() {
  const timers: ReturnType<typeof setTimeout>[] = []
  const intervals: ReturnType<typeof setInterval>[] = []
  const eventListeners: Array<{ target: EventTarget; event: string; handler: EventListener }> = []

  function safeSetTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
    const id = setTimeout(fn, ms)
    timers.push(id)
    return id
  }

  function safeSetInterval(fn: () => void, ms: number): ReturnType<typeof setInterval> {
    const id = setInterval(fn, ms)
    intervals.push(id)
    return id
  }

  function safeAddEventListener(
    target: EventTarget,
    event: string,
    handler: EventListener
  ): void {
    target.addEventListener(event, handler)
    eventListeners.push({ target, event, handler })
  }

  function cleanup(): void {
    timers.forEach(id => clearTimeout(id))
    intervals.forEach(id => clearInterval(id))
    eventListeners.forEach(({ target, event, handler }) => {
      target.removeEventListener(event, handler)
    })
    timers.length = 0
    intervals.length = 0
    eventListeners.length = 0
  }

  onDeactivated(() => cleanup())
  onUnmounted(() => cleanup())

  return { safeSetTimeout, safeSetInterval, safeAddEventListener, cleanup }
}

よくある落とし穴

シナリオ ❌ 誤ったアプローチ ✅ 正しいアプローチ
キャッシュKey ❌ デフォルトのコンポーネントnameを使用、複数ルートで同じコンポーネントを再利用すると衝突 ✅ ルートmeta.cacheKeyでカスタム一意キャッシュ識別子を設定
データリフレッシュ ❌ activatedで無条件に再フェッチ、ユーザーの操作状態を消失 ✅ staleTime戦略を使用、データ期限切れ時のみリフレッシュ
メモリクリーンアップ ❌ onUnmountedでのみタイマーとイベントリスナーをクリーンアップ ✅ onDeactivatedでもクリーンアップし、キャッシュ中のリークを防止
include管理 ❌ include文字列配列をハードコード、動的な追加/削除が不可 ✅ Piniaストアで動的管理、ルート切り替え時に自動更新
max設定 ❌ max値を大きくしすぎるか未設定、キャッシュが無制限に増加 ✅ デバイスメモリに基づき合理的な上限を設定(5-15推奨)、LRU削除と併用

エラートラブルシューティング

エラーメッセージ 原因 解決策
Component is not cached include/excludeのコンポーネント名が実際のコンポーネントnameと不一致 defineComponent({ name: 'xxx' })がinclude値と一致するか確認
Maximum call stack exceeded KeepAliveのネスト使用または循環依存 KeepAliveのネストを避け、ルート設定の循環参照を確認
activated hook not firing コンポーネントがKeepAliveでラップされていない、またはincludeにマッチしない ルートmeta.keepAliveとincludeリストにコンポーネントが含まれているか確認
Cache key collision 異なるルートで同名コンポーネントを使用、キャッシュKeyの衝突 route.meta.cacheKeyでルートごとにキャッシュインスタンスを区別
Memory usage keeps growing キャッシュコンポーネントに未クリーンアップのクロージャ参照が存在 onDeactivatedでタイマー、イベントリスナー、AbortControllerをクリーンアップ
Stale data after navigation キャッシュコンポーネントがactivatedで期限切れデータをリフレッシュしない staleTime機構を実装、activatedでデータの鮮度をチェック
include regex not matching includeが正規表現を使用しているがコンポーネント名がマッチしない 正規表現がコンポーネントnameと完全にマッチするか確認、大文字小文字に注意
KeepAlive max not working maxプロパティ値が大きすぎる、またはコンポーネントにname属性がない 合理的なmax値(5-15)を設定、コンポーネントにnameプロパティがあることを確認
Scroll position lost キャッシュコンポーネントの再活性化時にスクロール位置がリセット onDeactivatedでscrollYを保存、onActivatedで復元
Vuex/Pinia state out of sync キャッシュコンポーネントのローカル状態がstoreと同期していない activatedでstoreから最新状態を同期、またはstoreToRefsでリアクティブ性を維持

高度な最適化のヒント

1. デバイスメモリに応じたアダプティブキャッシュ上限

import { computed } from 'vue'

function getAdaptiveMaxCache(): number {
  const navigator = window.navigator as any
  const deviceMemory = navigator.deviceMemory || 4
  if (deviceMemory <= 2) return 3
  if (deviceMemory <= 4) return 5
  if (deviceMemory <= 8) return 10
  return 15
}

2. キャッシュのプレウォーミングとプリロード

import { onMounted } from 'vue'

function useCachePreload(routeNames: string[]): void {
  onMounted(() => {
    const router = useRouter()
    routeNames.forEach(name => {
      const route = router.resolve({ name })
      const component = route.matched[0]?.components?.default
      if (typeof component === 'function') {
        (component as () => Promise<any>)()
      }
    })
  })
}

3. キャッシュコンポーネントのパフォーマンスマーキング

import { onActivated, onDeactivated } from 'vue'

function useCachePerformanceMark(name: string): void {
  onActivated(() => {
    performance.mark(`${name}-activated-start`)
    requestAnimationFrame(() => {
      performance.mark(`${name}-activated-end`)
      performance.measure(`${name}-activated`, `${name}-activated-start`, `${name}-activated-end`)
    })
  })

  onDeactivated(() => {
    performance.mark(`${name}-deactivated`)
  })
}

4. キャッシュスナップショットとリストア

import { onDeactivated, onActivated, ref } from 'vue'

function useCacheSnapshot<T extends Record<string, any>>(initialState: T) {
  const snapshot = ref<T | null>(null)

  onDeactivated(() => {
    snapshot.value = { ...initialState }
  })

  onActivated(() => {
    if (snapshot.value) {
      Object.assign(initialState, snapshot.value)
    }
  })

  return { snapshot }
}

比較分析

次元 KeepAlive 手動状態管理 Pinia永続化 sessionStorage
キャッシュ粒度 コンポーネントインスタンスレベル 手動フィールド選択 Storeフィールドレベル 文字列キー・バリュー
DOM保持 完全なDOMを保持 保持しない 保持しない 保持しない
ライフサイクル activated/deactivated 手動管理 storeアクション 手動読み書き
メモリ使用量 高(DOM + リアクティブ含む) 非常に低い
データ型 任意(関数参照含む) 任意 シリアライズ可能な値 文字列のみ
クロスページ ルートレベルのみ クロスルート可能 グローバル共有 同一オリジンページ
リフレッシュ後も保持 はい(メモリ内) いいえ はい(永続化) はい
適用シナリオ フォーム/一覧状態の保持 精細なフィールドキャッシュ グローバル状態の永続化 単純なデータ一時保存

まとめと展望

KeepAliveコンポーネントキャッシュは単純なトグル設定ではなく、ルート宣言、動的リスト管理、LRU削除、データリフレッシュ、メモリリーク防止までの全チェーンエンジニアリングです。5つのコア戦略——ルートmeta宣言型キャッシュ、動的include/exclude管理、max上限とLRU削除、staleTimeデータリフレッシュ、メモリリーク検出/修正——がプロダクションレベルのKeepAliveソリューションの完全なクロージャーを形成します。

2026年、Vue Vapor Modeの進展に伴い、KeepAliveのキャッシュ機構はVNodeレベルからDOMレベルへ移行する可能性がありますが、キャッシュ管理のエンジニアリングの考え方は変わりません。ルートmeta宣言から始め、段階的に動的管理とメモリ監視を導入し、アプリケーションに適したキャッシュ戦略を構築することをお勧めします。


オンラインツール推薦

ブラウザローカルツールを無料で試す →

#Vue3 KeepAlive#组件缓存#路由缓存#性能优化#include exclude#2026#前端工程