- Vue.js
Vue KeepAliveの使い方|include・exclude・max設定
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期連続の黒字決算。