Vue の KeepAlive とは、表示から外れたコンポーネントを破棄せずにメモリ上へ取っておき、次に表示するときにそのまま戻してくれる組み込みコンポーネントです。 タブや画面を切り替えても、入力途中の文字・スクロール位置・取得済みのデータが残ります。どれを取っておくかは include・exclude、いくつまで取っておくかは max で決めます。

この記事では、Vue 3.5.43 の <script setup> でコードを動かし、onMounted・onActivated・onDeactivated・onUnmounted がどの順に呼ばれるかをログで確かめました。 include・exclude・max の早見表、max を超えたときにどのインスタンスが破棄されるか、「include が効かない」ときに多いコンポーネント名の不一致の再現、Vue Router の RouterView と v-slot で組み合わせる書き方まで、すべて実行結果つきで載せています。

確認環境: Linux(x64)/Node.js 22.22.2/Vue 3.5.43/Vue Router 5.3.1/Vite 8.3.2+@vitejs/plugin-vue 6.0.9/Vitest 5.0.3+jsdom 30.1.1+@vue/test-utils 2.5.1。単一ファイルコンポーネント(.vue)を Vite でコンパイルし、jsdom 上でボタンのクリックや入力を再現してログを取っています。発火順のログ(:max="2" の例)は、Vite でビルドしたものを Chromium 141(Playwright 1.56.0)で開いてボタンを押した場合も同じ内容でした。

KeepAliveの早見表(props・フック・症状別)

propsの早見表

props 役割 書ける値 書き方の例 省略したとき
include 名前が一致したものだけ取っておく カンマ区切りの文字列・正規表現・配列 include="PageA,PageB" すべて取っておく
exclude 名前が一致したものは取っておかない 同上 :exclude="/^Temp/" 除外なし
max 取っておく数の上限 数値(文字列の数字も可) :max="10" 上限なし
  • 照合に使うのはコンポーネントの名前です(ルート名やファイルのパスではありません)。
  • include と exclude の両方に同じ名前があるときは、exclude が優先されました(後述のログ)。
  • max を超えると、最後に表示されてから最も時間がたっているものが破棄されます。

フックの早見表

フック 呼ばれるとき KeepAlive の中での回数 主な用途
setup(<script setup> の本体) インスタンスを作るとき 取ってある間は1回だけ 状態の用意
onMounted 初めて DOM に入ったとき 取ってある間は1回だけ 初回だけの準備
onActivated 初回表示と、戻ってきたとき 表示のたび 再取得、タイマー再開
onDeactivated 別の表示に切り替わったとき、親が破棄されるとき 外れるたび タイマー停止、途中保存
onUnmounted キャッシュから外されたとき、親が破棄されたとき 最後に1回 後片付け

症状から探す早見表

症状 多い理由 見直すところ
include を書いたら何も残らなくなった 名前が一致していない ファイル名と同じ名前を書く。index.vue は defineOptions で名前を付ける
2つ目以降の名前だけ効かない カンマのあとに空白がある "PageA,PageB" と詰めて書くか、配列で渡す
RouterView を囲んだのに残らない KeepAlive の中に RouterView を直接置いている v-slot="{ Component }" で受けて component :is を囲む
ルート名を include に書いても効かない 照合はコンポーネント名 ルート名ではなくコンポーネント名を書く
戻ったときに onMounted の処理が動かない 取ってある間は呼ばれない 表示のたびの処理は onActivated へ
/items/1 と /items/2 で入力が共有される 同じインスタンスを使い回している :key="route.fullPath" を付ける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue KeepAliveの使い方|include・exclude・max設定」

KeepAliveの基本|切り替えても入力が残る

KeepAlive は、動的コンポーネント(component :is)や v-if で切り替える1つのコンポーネントを囲むだけで使えます。まず、この記事を通して使う3つのタブのコードです。KeepAlive は組み込みなので import は要りません。

<!-- App.vue -->
<script setup>
import { shallowRef } from 'vue'
import PageA from './PageA.vue'
import PageB from './PageB.vue'
import PageC from './PageC.vue'

// 設定を外から差し替えて試せるように props で受け取る
defineProps({
  include: [String, RegExp, Array],
  exclude: [String, RegExp, Array],
  max: [String, Number],
})
const tabs = { PageA, PageB, PageC }
const current = shallowRef(PageA)
</script>

<template>
  <nav>
    <button v-for="(comp, name) in tabs" :key="name" :data-tab="name" @click="current = comp">
      {{ name }}
    </button>
  </nav>
  <KeepAlive :include="include" :exclude="exclude" :max="max">
    <component :is="current" />
  </KeepAlive>
</template>
<!-- PageA.vue(PageB.vue・PageC.vue は「PageA」の文字だけを置き換えた同じ内容) -->
<script setup>
import { ref, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue'
const text = ref('')
console.log('PageA: setup')
onMounted(() => console.log('PageA: onMounted'))
onActivated(() => console.log('PageA: onActivated'))
onDeactivated(() => console.log('PageA: onDeactivated'))
onUnmounted(() => console.log('PageA: onUnmounted'))
</script>

<template>
  <section>
    <h2>PageA</h2>
    <input v-model="text" placeholder="メモ" />
  </section>
</template>

PageA の入力欄に「買い物リスト」と入れ、PageB に切り替えてから PageA に戻ったときの入力値を、KeepAlive で囲まない場合と囲んだ場合で比べました。

KeepAlive なし: 戻ったあとの入力値 = ""
KeepAlive あり: 戻ったあとの入力値 = "買い物リスト"

囲まないと切り替えのたびにコンポーネントが作り直されるので、ref の値は初期値に戻ります。囲むとインスタンスごと残るので、入力値もそのままです。

手元で同じログを取る手順

ブラウザーでは、createApp(App).mount('#app') で表示してボタンを押すと、コンソールにログが出ます。この記事では、設定を変えながらクリックを自動で再現するために次のテストコードを使いました。

npm i vue@3.5.43
npm i -D vite @vitejs/plugin-vue vitest jsdom @vue/test-utils
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom' } })
// demo.test.js
import { test } from 'vitest'
import { mount } from '@vue/test-utils'
import App from './App.vue'

const lines = []
console.log = (m) => lines.push(m)

async function run(title, props, order) {
  lines.length = 0
  const wrapper = mount(App, { props })
  for (const name of order) {
    lines.push(`--- ${name} を表示`)
    await wrapper.find(`[data-tab="${name}"]`).trigger('click')
  }
  lines.push('--- 親をアンマウント')
  wrapper.unmount()
  process.stdout.write(`\n### ${title}\n${lines.join('\n')}\n`)
}

test('KeepAlive の発火順', async () => {
  await run('設定なし', {}, ['PageB', 'PageA'])
  await run('include="PageA"', { include: 'PageA' }, ['PageB', 'PageA', 'PageB'])
  await run(':max="2" A→B→C→A', { max: 2 }, ['PageB', 'PageC', 'PageA'])
})

npx vitest run で実行すると、以降の節に載せているログが出力されます。run の2番目の引数が KeepAlive に渡す設定、3番目が押すタブの順番です。

フックの発火順をログで確認(onMounted・onActivated・onDeactivated・onUnmounted)

KeepAliveで囲まないとき

比較のために、KeepAlive を外して component :is だけにした場合のログです(PageA → PageB → PageA の順に表示)。

PageA: setup
PageA: onMounted
--- PageB を表示
PageB: setup
PageA: onUnmounted
PageB: onMounted
--- PageA を表示
PageA: setup
PageB: onUnmounted
PageA: onMounted
--- 親をアンマウント
PageA: onUnmounted

切り替えのたびに setup と onMounted が動き、外れた側は onUnmounted で破棄されています。

KeepAliveで囲んだとき

同じ操作を KeepAlive で囲んで行うと、次のようになります。

PageA: setup
PageA: onMounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
PageB: onActivated
--- PageA を表示
PageB: onDeactivated
PageA: onActivated
--- 親をアンマウント
PageA: onDeactivated
PageB: onUnmounted
PageA: onUnmounted

ログから読み取れることを表にまとめます。

場面 呼ばれた順 ポイント
初回表示 setup → onMounted → onActivated onActivated は初回にも呼ばれる
別のタブへ(相手は初めて) 相手の setup → 自分の onDeactivated → 相手の onMounted → 相手の onActivated 自分の onUnmounted は呼ばれない
キャッシュ済みのタブへ戻る 外れる側の onDeactivated → 戻る側の onActivated setup と onMounted は動かない
親ごと破棄 表示中のものは onDeactivated → 全員の onUnmounted 最後は必ず onUnmounted で終わる

「表示のたびにやりたい処理」を onMounted に書いていると、戻ってきたときに動きません。 一覧の再取得やタイマーの再開のように、表示のたびに必要な処理は onActivated に置きます。初回表示でも onActivated が呼ばれるので、onMounted と両方に同じ処理を書く必要はありません。

親子がいるときの順番

取っておかれるコンポーネントの中に子コンポーネントがある場合も、onActivated・onDeactivated は子にも届きます。親 Parent.vue の中に子 Child.vue を1つ置き、それぞれに4つのフックのログを入れて、Parent → 別のタブ → Parent の順に表示しました。

  Child: onMounted
Parent: onMounted
  Child: onActivated
Parent: onActivated
--- PageB を表示
  Child: onDeactivated
Parent: onDeactivated
--- Parent を表示
  Child: onActivated
Parent: onActivated
--- アンマウント
  Child: onDeactivated
Parent: onDeactivated
  Child: onUnmounted
Parent: onUnmounted

どのフックも「子が先、親があと」の順でした。 子コンポーネント側でタイマーや購読を持っている場合も、子の中に onActivated・onDeactivated を書けばそのまま働きます。

include・excludeの使い方

書き方は3通り

書き方 例 向いている場面
カンマ区切りの文字列 include="PageA,PageB" 数が少なく固定のとき
正規表現(v-bind が必要) :include="/^Page[AB]$/" 名前に規則があるとき
配列(v-bind が必要) :include="['PageA', 'PageB']" 一覧を変数で持ちたいとき、あとから増減させたいとき

正規表現と配列は JavaScript の値なので、:include のようにコロン付き(v-bind)で渡します。 コロンなしの include="/^Page/" は、ただの文字列として扱われます。

includeに書いたものだけ残る

include="PageA" を指定して、PageA → PageB → PageA → PageB の順に表示したログです。

PageA: setup
PageA: onMounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
--- PageA を表示
PageB: onUnmounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
--- 親をアンマウント
PageA: onUnmounted
PageB: onUnmounted

PageA は onDeactivated と onActivated で出入りし、PageB は表示のたびに setup から作り直されています。 もう1つ大事なのは、対象外の PageB では onActivated と onDeactivated が一度も呼ばれていないことです。ログに onActivated が出るかどうかで、取っておかれているかどうかを見分けられます。

excludeに書いたものだけ作り直す

exclude="PageA" は逆の動きです。

PageA: setup
PageA: onMounted
--- PageB を表示
PageB: setup
PageA: onUnmounted
PageB: onMounted
PageB: onActivated
--- PageA を表示
PageA: setup
PageB: onDeactivated
PageA: onMounted
--- PageB を表示
PageA: onUnmounted
PageB: onActivated
--- 親をアンマウント
PageB: onDeactivated
PageB: onUnmounted

PageA は毎回作り直され、PageB はキャッシュから戻っています。「基本は全部残したいが、この画面だけは毎回まっさらにしたい」(例:新規登録フォーム)というときは exclude が短く書けます。

カンマのあとに空白を入れると2つ目が一致しない

文字列で複数書くとき、読みやすさのために "PageA, PageB" と空白を入れたくなります。実行すると、PageB は対象になりませんでした。

### include="PageA, PageB"(空白入り)
PageA: setup
PageA: onMounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
--- PageA を表示
PageB: onUnmounted
PageA: onActivated

PageB に onActivated が出ておらず、切り替えで onUnmounted になっています。カンマで区切ったあとの「空白+PageB」という文字列と名前を比べるため、一致しないという結果です。次のどちらかにすると意図どおりに動きます。

<!-- 空白を入れずに詰めて書く -->
<KeepAlive include="PageA,PageB">
  <component :is="current" />
</KeepAlive>

<!-- 配列で渡す(空白を気にしなくてよい) -->
<KeepAlive :include="['PageA', 'PageB']">
  <component :is="current" />
</KeepAlive>

includeとexcludeを両方書いたとき

:include="['PageA', 'PageB']" と :exclude="['PageA']" を同時に指定したログです。

PageA: setup
PageA: onMounted
--- PageB を表示
PageB: setup
PageA: onUnmounted
PageB: onMounted
PageB: onActivated
--- PageA を表示
PageA: setup
PageB: onDeactivated
PageA: onMounted

PageA は include にも入っていますが、取っておかれませんでした。両方に名前があるときは exclude が優先です。設定を読みやすく保つために、ふだんはどちらか片方だけを使うのがおすすめです。

maxの使い方|超えたときに破棄されるインスタンス

max は取っておく数の上限です。Vue 公式ガイドには、max を指定すると LRU キャッシュ(Least Recently Used:最後に使われてから最も時間がたったものから手放す方式)のように動くと書かれています。実際にどれが破棄されるかを、:max="2" で確かめました。

順番に3つ開いたとき

PageA → PageB → PageC → PageA の順です。

PageA: setup
PageA: onMounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
PageB: onActivated
--- PageC を表示
PageC: setup
PageA: onUnmounted
PageB: onDeactivated
PageC: onMounted
PageC: onActivated
--- PageA を表示
PageA: setup
PageB: onUnmounted
PageC: onDeactivated
PageA: onMounted
PageA: onActivated

3つ目の PageC を開いた時点で PageA に onUnmounted が出ています。上限2を超えたので、いちばん前に見た PageA が手放されました。その後 PageA に戻ると setup からやり直しになり、今度は PageB が破棄されています。

途中で見直したものは残る

次は PageA → PageB → PageA → PageC → PageB の順です。PageC を開く直前に PageA を見直しています。

--- PageA を表示
PageB: onDeactivated
PageA: onActivated
--- PageC を表示
PageC: setup
PageB: onUnmounted
PageA: onDeactivated
PageC: onMounted
PageC: onActivated
--- PageB を表示
PageB: setup
PageA: onUnmounted
PageC: onDeactivated
PageB: onMounted
PageB: onActivated

今度は PageC を開いたときに破棄されたのは PageB でした。作られた順ではなく、「最後に表示された時刻」がいちばん前のものが手放されることが、2つのログの違いからわかります。

操作の順 PageC を開いたときに破棄されたもの 理由
A → B → C PageA 最後に表示されたのがいちばん前
A → B → A → C PageB A を見直したので、B がいちばん前になった

maxの決め方

状況 目安
タブが数個で固定 max なしでよい
一覧から詳細を次々に開く(key で分けている) 直近で行き来する数に合わせて 5〜10 程度から
画面ごとのデータが大きい(長い一覧、画像、グラフ) 小さめにして、戻ったときは onActivated で取り直す
直前の1画面だけ戻れればよい :max="2"(今の画面+1つ前)

:max="1" にすると、表示中の1つしか持てないので、切り替えのたびに作り直しになります(PageA → PageB → PageA で毎回 setup が出ることをログで確認しました)。「1つ前に戻れる」ようにしたいときは 2 以上にします。

includeが効かないときに見る「コンポーネント名」

include・exclude はコンポーネントの名前と照合されます。Vue 公式ガイドによると、バージョン 3.2.34 以降、<script setup> を使った単一ファイルコンポーネントはファイル名から名前が自動的に推測されます。この「ファイル名から」という点が、効かないときの手がかりになります。

推測された名前を表示してみる

import したコンポーネントの __name(コンパイル時にファイル名から入る名前)と name(自分で付けた名前)を出力しました。

import PageA from './PageA.vue'
import UserIndex from './pages/user/index.vue'
import UserNamed from './pages/user/Named.vue' // 中で defineOptions({ name: 'UserList' })

console.log('PageA.__name = ' + PageA.__name + ' / name = ' + PageA.name)
console.log('user/index.vue __name = ' + UserIndex.__name + ' / name = ' + UserIndex.name)
console.log('Named.vue(defineOptions) __name = ' + UserNamed.__name + ' / name = ' + UserNamed.name)
PageA.__name = PageA / name = undefined
user/index.vue __name = index / name = undefined
Named.vue(defineOptions) __name = Named / name = UserList
  • PageA.vue → PageA(拡張子を除いたファイル名)
  • pages/user/index.vue → index(フォルダー名は入らない)
  • defineOptions({ name: 'UserList' }) を書くと、name に UserList が入る

再現:index.vue に include="UserList" と書いても残らない

pages/user/index.vue を会員一覧の画面として、include="UserList" を指定したときのログです。

<!-- pages/user/index.vue -->
<script setup>
import { onMounted, onActivated } from 'vue'
onMounted(() => console.log('user/index.vue: onMounted'))
onActivated(() => console.log('user/index.vue: onActivated'))
</script>
<template><p>会員一覧</p></template>
### index.vue に include="UserList"
user/index.vue: onMounted
--- PageB を表示
--- 戻る
user/index.vue: onMounted

onActivated が一度も出ず、戻ったときに onMounted がもう一度出ています。取っておかれていません。推測された名前は index なので、include="index" に変えると残ります。

### index.vue に include="index"
user/index.vue: onMounted
user/index.vue: onActivated
--- PageB を表示
--- 戻る
user/index.vue: onActivated

ただ、index.vue はフォルダーごとにいくつもできるので、index という名前では区別がつきません。defineOptions で名前を付けるのが確実です。

直し方:defineOptionsで名前を付ける

<!-- pages/user/Named.vue -->
<script setup>
import { onMounted, onActivated } from 'vue'
defineOptions({ name: 'UserList' })
onMounted(() => console.log('UserList: onMounted'))
onActivated(() => console.log('UserList: onActivated'))
</script>
<template><p>会員一覧</p></template>
### defineOptions name=UserList に include="UserList"
UserList: onMounted
UserList: onActivated
--- PageB を表示
--- 戻る
UserList: onActivated

onMounted は1回だけで、戻ったときは onActivated だけが呼ばれています。defineOptions で付けた名前が、ファイル名からの推測より優先されます。

名前の一致は「完全一致・大文字小文字も区別」

PageA.vue に対して、表記を変えた名前を include に書いた結果です。

include に書いた値 取っておかれたか 補足
PageA 残った ファイル名と同じ
page-a 残らなかった ケバブケース(ハイフン区切り)には読み替えられない
pagea 残らなかった 大文字小文字が違う
PageA, PageB の PageB 残らなかった カンマのあとの空白

テンプレートでは PageA も page-a も同じコンポーネントを指しますが、include・exclude の照合は文字列の完全一致です。ファイル名(または defineOptions の name)と同じ表記で書きます。

名前のないコンポーネントはincludeに一致しない

defineComponent({ setup() { ... } }) のように name を持たないオブジェクトを、include="NoName" を指定した KeepAlive に入れたログです。

### nameなしオブジェクト include="NoName"
NoName: onMounted
--- PageB を表示
--- 戻る
NoName: onMounted

名前がないので何を書いても一致せず、毎回作り直しになりました。JavaScript のオブジェクトとして書くコンポーネントには name: 'NoName' を付けておきます。なお、include も exclude も指定しない KeepAlive は、名前の有無に関係なくすべてを取っておきます。

名前まわりのチェックリスト

確認すること 見方
ファイル名と include の表記が同じか 大文字小文字、ハイフンの有無まで合わせる
index.vue になっていないか defineOptions({ name: '...' }) を足す
文字列の区切りに空白がないか 詰めて書くか配列にする
正規表現・配列にコロンを付けたか :include="..."
取っておかれているか そのコンポーネントの onActivated にログを入れて、出るかどうかを見る

includeをあとから変える|外した名前はその場で破棄される

include に配列の ref を渡すと、あとから対象を増減できます。配列から名前を外すと、取ってあったインスタンスはその時点で破棄されます。 レンダー関数で KeepAlive を組み立てて確かめました(テンプレートで :include="include" と書いた場合と同じ指定です)。

import { test } from 'vitest'
import { mount } from '@vue/test-utils'
import { h, KeepAlive, nextTick, ref, shallowRef } from 'vue'
import PageA from './PageA.vue'
import PageB from './PageB.vue'

test('include から外す', async () => {
  const lines = []
  console.log = (m) => lines.push(m)

  const include = ref(['PageA', 'PageB'])
  const current = shallowRef(PageA)
  mount({
    render: () => h(KeepAlive, { include: include.value }, () => h(current.value)),
  })
  await nextTick()
  lines.push('--- PageB を表示')
  current.value = PageB
  await nextTick()
  lines.push('--- include から PageA を外す')
  include.value = ['PageB']
  await nextTick()
  lines.push('--- PageA を表示')
  current.value = PageA
  await nextTick()
  process.stdout.write(lines.join('\n') + '\n')
})
PageA: setup
PageA: onMounted
PageA: onActivated
--- PageB を表示
PageB: setup
PageA: onDeactivated
PageB: onMounted
PageB: onActivated
--- include から PageA を外す
PageA: onUnmounted
--- PageA を表示
PageA: setup
PageB: onDeactivated
PageA: onMounted

include から外した直後に PageA: onUnmounted が出ています。この性質を使うと、「一覧に戻るときは残す、ログアウトや保存完了のときはまっさらにする」といった切り替えができます。対象の名前を配列で持ち、まっさらにしたいタイミングで配列から外し、次の描画のあとで戻す、という流れです。

Vue RouterのRouterViewと組み合わせる(v-slot)

画面遷移で状態を残したいときは、RouterView の v-slot で受け取ったコンポーネントを KeepAlive で囲みます。Vue Router 公式ガイド「RouterView slot」に載っている形です。

<!-- RouterApp.vue -->
<script setup>
defineProps({ include: String })
</script>

<template>
  <RouterView v-slot="{ Component }">
    <KeepAlive :include="include">
      <component :is="Component" />
    </KeepAlive>
  </RouterView>
</template>

v-slot="{ Component }" で「今のルートに対応するコンポーネント」を受け取り、それを component :is で描画して、外側を KeepAlive で囲んでいます。

ルート名ではなくコンポーネント名を書く

ルートの name を home にして、include="home"(ルート名)と include="PageA"(コンポーネント名)を比べました。

// router.test.js
import { test } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import RouterApp from './RouterApp.vue'
import PageA from './PageA.vue'
import PageB from './PageB.vue'

test('RouterView と KeepAlive', async () => {
  const lines = []
  console.log = (m) => lines.push(m)
  const router = createRouter({
    history: createMemoryHistory(),
    routes: [
      { path: '/', name: 'home', component: PageA },
      { path: '/b', name: 'detail', component: PageB },
    ],
  })
  for (const include of ['home', 'PageA']) {
    await router.push('/')
    await router.isReady()
    lines.length = 0
    const w = mount(RouterApp, { props: { include }, global: { plugins: [router] } })
    await flushPromises()
    await w.find('input').setValue('下書き')
    lines.push('--- /b へ移動')
    await router.push('/b')
    await flushPromises()
    lines.push('--- / へ戻る')
    await router.push('/')
    await flushPromises()
    lines.push(`入力値 = "${w.find('input').element.value}"`)
    w.unmount()
    process.stdout.write(`\n### include="${include}"\n${lines.join('\n')}\n`)
  }
})
### include="home"
PageA: setup
PageA: onMounted
--- /b へ移動
PageB: setup
PageA: onUnmounted
PageB: onMounted
--- / へ戻る
PageA: setup
PageB: onUnmounted
PageA: onMounted
入力値 = ""
PageA: onUnmounted

### include="PageA"
PageA: setup
PageA: onMounted
PageA: onActivated
--- /b へ移動
PageB: setup
PageA: onDeactivated
PageB: onMounted
--- / へ戻る
PageB: onUnmounted
PageA: onActivated
入力値 = "下書き"
PageA: onDeactivated
PageA: onUnmounted

ルート名 home では取っておかれず、コンポーネント名 PageA で入力値が残りました。 ルート定義の name とコンポーネントの名前は別物です。ルートで使う画面コンポーネントには defineOptions({ name: '...' }) で名前を付けておくと、include に書く値がはっきりします。

KeepAliveの中にRouterViewを直接置くと警告が出る

次の書き方は、警告が出て取っておかれません。

<!-- 動かない書き方 -->
<template>
  <KeepAlive>
    <RouterView />
  </KeepAlive>
</template>
[VUE_ROUTER_R0060] <router-view> can no longer be used directly inside <keep-alive>.
├▶ fix: Wrap the slot's resolved component with <keep-alive> instead of nesting <router-view> in it:
PageA: setup
PageA: onMounted
PageA: onActivated
--- /b へ移動
PageB: setup
PageA: onUnmounted
PageB: onMounted
--- / へ戻る
PageA: setup
PageB: onUnmounted
PageA: onMounted
入力値 = ""

警告文の案内どおり、RouterView を外側にして v-slot で受け、中の component :is を KeepAlive で囲む形に直すと残るようになります。

同じルートのパラメーター違いを分けて残す(key)

/items/:id のように同じコンポーネントを使うルートでは、/items/1 から /items/2 へ移っても同じインスタンスが使い回されます。 key を付けない場合と、:key="route.fullPath" を付けた場合を比べました。

<!-- key を付けて、URL ごとに別のインスタンスとして残す -->
<template>
  <RouterView v-slot="{ Component, route }">
    <KeepAlive>
      <component :is="Component" :key="route.fullPath" />
    </KeepAlive>
  </RouterView>
</template>
### key なし
PageA: setup
PageA: onMounted
PageA: onActivated
--- /items/2 へ
入力値 = "1番の下書き"
--- /items/1 へ
入力値 = "1番の下書き"

### :key="route.fullPath" あり
PageA: setup
PageA: onMounted
PageA: onActivated
--- /items/2 へ
PageA: setup
PageA: onDeactivated
PageA: onMounted
PageA: onActivated
入力値 = ""
--- /items/1 へ
PageA: onDeactivated
PageA: onActivated
入力値 = "1番の下書き"

key なしでは /items/2 に移っても setup が動かず、1番で入力した文字がそのまま見えています。key を付けると URL ごとに別のインスタンスになり、2番は空、1番に戻ると下書きが残っています。 URL の数だけインスタンスが増えるので、この書き方では max を合わせて指定しておくと数を抑えられます。

Transitionと一緒に使うときの順番

画面の切り替えにアニメーションを付ける場合、Vue Router 公式ガイドでは Transition を外側、KeepAlive を内側にする形が示されています。

<template>
  <RouterView v-slot="{ Component }">
    <Transition>
      <KeepAlive>
        <component :is="Component" />
      </KeepAlive>
    </Transition>
  </RouterView>
</template>

keyでキャッシュを分ける(Routerなしの場合)

key による分け方は Router がなくても同じです。同じ PageA を key だけ変えて表示すると、key ごとに別のインスタンスとして取っておかれます。

const id = ref(1)
const wrapper = mount({
  render: () => h(KeepAlive, null, () => h(PageA, { key: id.value })),
})
PageA: setup
PageA: onMounted
PageA: onActivated
--- key=2
PageA: setup
PageA: onDeactivated
PageA: onMounted
PageA: onActivated
入力値(key=2) = ""
--- key=1
PageA: onDeactivated
PageA: onActivated
入力値(key=1) = "1番のメモ"

key を付けず props だけを変えた場合は、setup は動かず、入力値は「1番のメモ」のままでした。「同じコンポーネントだが中身は別物」というときは key で分ける、と覚えておくと判断しやすくなります。

onActivated・onDeactivatedの使い方|タイマーを止めて再開する

KeepAlive の中では、表示から外れても onUnmounted が呼ばれません。onMounted で始めたタイマーは、見えていない間も動き続けます。 表示中だけ動かしたい処理は、onActivated で始めて onDeactivated で止めます。

<!-- Clock.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

const seconds = ref(0)
let timer = null

onActivated(() => {
  console.log('Clock: onActivated → タイマー開始')
  timer = setInterval(() => seconds.value++, 1000)
})
onDeactivated(() => {
  console.log('Clock: onDeactivated → タイマー停止')
  clearInterval(timer)
})
</script>

<template>
  <p>経過 {{ seconds }} 秒</p>
</template>

3秒表示 → 別のタブで5秒 → 戻って2秒、という操作をテスト用の時計で進めた結果です。

Clock: onActivated → タイマー開始
3秒後: 経過 3 秒
Clock: onDeactivated → タイマー停止
Clock: onActivated → タイマー開始
別タブで5秒待って戻った直後: 経過 3 秒
さらに2秒後: 経過 5 秒
Clock: onDeactivated → タイマー停止

別のタブにいた5秒間はカウントが進まず、戻ってから続きの 3 → 5 と数えています。seconds の値はインスタンスごと残っているので、止めて再開するだけで続きから動きます。親が破棄されるときにも onDeactivated が呼ばれるため(最後の行)、この書き方ならタイマーの止め忘れも起きません。

フックごとの置き場所

やりたいこと 置き場所
初回だけの準備(外部ライブラリの初期化など) onMounted
表示のたびに最新データを取り直す onActivated
タイマー・ポーリング・addEventListener の開始 onActivated
上の停止・解除 onDeactivated
スクロール位置を覚える/戻す onDeactivated で保存、onActivated で復元
インスタンスが破棄されるときの後片付け onUnmounted

KeepAliveの中に置けるのは1つだけ

KeepAlive の直下に同時に描画できるコンポーネントは1つです。2つ並べると、次の警告が出て、どちらも取っておかれずに両方が描画されました。

<!-- 警告が出る書き方 -->
<KeepAlive>
  <PageA />
  <PageB />
</KeepAlive>
[Vue warn]: Template compilation error: <KeepAlive> expects exactly one child component.
[Vue warn]: KeepAlive should contain exactly one component child.

v-if と v-else で「どちらか一方だけ」が描画される形なら問題ありません。

<KeepAlive>
  <PageA v-if="showA" />
  <PageB v-else />
</KeepAlive>
PageA: setup
PageA: onMounted
PageA: onActivated
--- showA = false
PageB: setup
PageA: onDeactivated
PageB: onMounted
PageB: onActivated
--- showA = true
PageB: onDeactivated
PageA: onActivated

component :is のときと同じ順でフックが呼ばれ、戻ったときに setup は動いていません。

KeepAliveを使うかどうかの判断表

場面 KeepAlive 理由
入力途中のフォームをタブで行き来する 使う 入力値をそのまま残せる
一覧 → 詳細 → 一覧で、スクロール位置や絞り込み条件を残したい 使う(一覧だけ include) 一覧の再取得と再描画を省ける
表示のたびに最新の内容を見せたい画面 使わない、または onActivated で取り直す 取ってあるデータは前回表示時点のもの
新規登録フォームなど、毎回まっさらで始めたい exclude に入れる 前回の入力が残らないようにする
画面数が多く、どれも大きなデータを持つ max を付ける メモリの使用量を抑える
状態を別のタブや再読み込み後にも残したい ストア(Pinia など)や URL に持たせる KeepAlive が残すのはページを開いている間のインスタンス

よくある質問

Q. KeepAliveの中でonMountedは毎回呼ばれますか? A. 取っておかれている間は初回の1回だけです。戻ってきたときに呼ばれるのは onActivated です。onActivated は初回表示でも onMounted の直後に呼ばれるので、表示のたびに行う処理は onActivated にまとめられます。

Q. includeに名前を書いたのに、切り替えるたびに作り直されます。 A. 名前が一致していないことが多いです。<script setup> ではファイル名が名前になるため、index.vue は index になります。defineOptions({ name: 'UserList' }) で名前を付け、include にも同じ表記で書いてください。文字列で複数書くときは、カンマのあとに空白を入れずに詰めます。

Q. maxを超えたとき、破棄されるのはどれですか? A. 最後に表示されてからいちばん時間がたっているものです。:max="2" で A → B → C と開くと A が、A → B → A → C と開くと B が破棄されました。破棄されたインスタンスでは onUnmounted が呼ばれます。

Q. 取ってあるインスタンスを自分のタイミングで捨てられますか? A. include を配列の ref で渡し、捨てたいタイミングでその名前を配列から外すと、その場で onUnmounted が呼ばれて破棄されます。もう一度取っておきたいときは、配列に名前を戻します。

まとめ

  • KeepAlive は、表示から外れたコンポーネントを破棄せずに取っておく組み込みコンポーネント。入力値などの状態がそのまま残ります。
  • 発火順は「初回:setup → onMounted → onActivated」「切り替え:onDeactivated → onActivated」。戻ったときに setup と onMounted は動きません。
  • include・exclude はコンポーネント名と完全一致で照合。両方に書くと exclude が優先、文字列のカンマのあとの空白は一致しない原因になります。
  • max を超えると、最後に表示されてからいちばん時間がたったものが破棄され、onUnmounted が呼ばれます。
  • 「include が効かない」ときは名前を確認。index.vue は index と推測されるので、defineOptions({ name }) で名前を付けます。
  • Vue Router では RouterView の v-slot="{ Component }" で受けて KeepAlive で囲む。include に書くのはルート名ではなくコンポーネント名です。
  • 表示中だけ動かす処理は onActivated で始めて onDeactivated で止める。同じコンポーネントを別々に残すときは key で分けます。

参考・出典

  • Vue.js 公式ガイド「KeepAlive」(ja.vuejs.org/guide/built-ins/keep-alive)
  • Vue Router 公式ガイド「RouterView slot」(router.vuejs.org/guide/advanced/router-view-slot.html)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue KeepAliveの使い方|include・exclude・max設定」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?