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宣言から始め、段階的に動的管理とメモリ監視を導入し、アプリケーションに適したキャッシュ戦略を構築することをお勧めします。
オンラインツール推薦
- Vue SFC Playground — オンラインでKeepAliveコンポーネントの動作とライフサイクルをテスト
- Chrome DevTools Memory — キャッシュコンポーネントのメモリ使用量とリークを分析
- Vue DevTools — KeepAliveキャッシュインスタンスの一覧と状態を確認
- JSONフォーマッター — ルート設定とキャッシュ戦略のJSONをフォーマット
- ハッシュエンコードツール — キャッシュKeyのハッシュ値を生成して衝突を回避
ブラウザローカルツールを無料で試す →