1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vue 3のKeepAliveとTeleportを学ぶ|タブ・カウンター・モーダルを作ってみる

1
Last updated at Posted at 2026-08-12

前回はshallowRef()とDynamic Componentを使って、CompACompBCompCを切り替える形を試しました。
今回はその続きとして、切り替えたコンポーネントの状態を残す<KeepAlive>と、詳細表示を画面の前面へ出す<Teleport>を、サンプルプログラムで整理します。

フロント側の画面づくりを覚えて、画像解析結果の一覧・詳細・検索条件のようなUIにもつなげていきたいです。

まだ途中段階なので、勘違いなどあれば教えていただけるとうれしいです。

今日やったこと

Dynamic Componentを<KeepAlive>で囲み、カウンターや入力内容を残したままタブ風に切り替える形を考えました。

今回作る画面の流れは、次のイメージです。

CompAにはカウンター、CompBにはメモ欄、CompCには選択肢を置きます。コンポーネントを切り替えてから戻ったときに、それぞれの状態が残っているかを確認するためです。

ファイル構成は、次のようにしました。

src/
├─ App.vue
└─ components/
   ├─ CompA.vue
   ├─ CompB.vue
   ├─ CompC.vue
   └─ DetailModal.vue
キャラクター 「値が残るかどうかを見るなら、カウンター・入力欄・選択欄があると違いを確認しやすそうだね。」

つまずいたところ

<KeepAlive>の中にコメントを書くと、expects exactly one child componentというエラーが出ました。

最初は、次のように<KeepAlive>の中へ説明コメントを書いていました。

<KeepAlive>
  <!-- CompAからopen-modalイベントが来たらモーダルを開く -->
  <component :is="currentComponent" @open-modal="openModal" />
</KeepAlive>

すると、Viteで次のエラーが出ました。

[plugin:vite:vue] <KeepAlive> expects exactly one child component.

<KeepAlive>は、キャッシュしたいコンポーネントを直接の子として1つだけ受け取る必要があります。開発時にはHTMLコメントも子要素として扱われる場合があるため、コメントと<component>の2つが入っていると判断されたようです。 blog.csdn

そのため、コメントは<KeepAlive>の外へ移動します。

<!-- Dynamic Componentを直接囲み、切り替え前の状態をキャッシュする -->
<!-- CompAからopen-modalイベントが来たらモーダルを開く -->
<KeepAlive>
  <component :is="currentComponent" @open-modal="openModal" />
</KeepAlive>

<KeepAlive>の中には、コメントや<div>を入れず、<component>だけを置くようにします。

<!-- OK:直接の子がcomponentだけになっている -->
<KeepAlive>
  <component :is="currentComponent" />
</KeepAlive>

<div>を挟むと、<KeepAlive>が直接受け取るものはdivになります。今回キャッシュしたいのはDynamic Componentなので、<component>を直接置く形がよさそうです。 stackoverflow

キャラクター KeepAliveの中は、状態を残したいコンポーネント専用の場所なんだね。説明コメントは外へ置こう。」

今の理解(メモ)

今の理解では、<KeepAlive>は「コンポーネントの状態を残すもの」、<Teleport>は「モーダルの表示位置を移すもの」です。

通常、Dynamic Componentを切り替えると、表示されなくなったコンポーネントはアンマウントされます。再び表示すると新しく作り直されるため、カウンターや入力値は初期化されます。

<KeepAlive>で囲むと、非表示になったコンポーネントはキャッシュされます。再び表示したときには、前回の状態を持ったまま戻れます。Vue.js公式ドキュメント vuejs

ゲームの装備一覧にたとえると、<KeepAlive>は別の画面を見たあとでも、前に選んでいた装備や項目を覚えておく仕組みに少し近そうです。ただし、ブラウザを閉じたあとまで保存する機能ではなく、画面上でコンポーネントをキャッシュする仕組みだと区別しておきたいです。

image.png
ここをクリックすると動作を確かめられます

App.vue

App.vueでは、コンポーネントの切り替え、状態の保持、モーダルの開閉を管理します。

<KeepAlive>の中にはコメントを入れず、Dynamic Componentだけを置く点が今回のポイントです。

<script setup>
import { ref, shallowRef } from 'vue'

// タブとして切り替えるコンポーネントを読み込む
import CompA from './components/CompA.vue'
import CompB from './components/CompB.vue'
import CompC from './components/CompC.vue'

// Teleportを使うモーダルコンポーネントを読み込む
import DetailModal from './components/DetailModal.vue'

// 最初に表示するコンポーネントを設定する
// コンポーネントの内部ではなく、表示対象の入れ替えを追跡したいのでshallowRefを使う
const currentComponent = shallowRef(CompA)

// モーダルを開いているかどうかを持つ
const isModalOpen = ref(false)

const changeComponent = (component) => {
  // 押したボタンに対応するコンポーネントを表示対象にする
  currentComponent.value = component
}

const openModal = () => {
  // 子コンポーネントからのイベントを受けてモーダルを開く
  isModalOpen.value = true
}

const closeModal = () => {
  // モーダルを閉じる
  isModalOpen.value = false
}
</script>

<template>
  <main class="app">
    <h1>KeepAliveとTeleportのサンプル</h1>

    <nav class="tab-list">
      <!-- 各ボタンで表示するコンポーネントを切り替える -->
      <button @click="changeComponent(CompA)">compA</button>
      <button @click="changeComponent(CompB)">compB</button>
      <button @click="changeComponent(CompC)">compC</button>
    </nav>

    <!-- Dynamic Componentを直接囲み、切り替え前の状態をキャッシュする -->
    <!-- KeepAliveの中にはcomponent以外を入れない -->
    <KeepAlive>
      <component :is="currentComponent" @open-modal="openModal" />
    </KeepAlive>

    <!-- モーダルの表示状態と閉じる処理を渡す -->
    <DetailModal :is-open="isModalOpen" @close="closeModal" />
  </main>
</template>

<style scoped>
.app {
  /* 画面全体の幅を抑え、中央へ配置する */
  max-width: 720px;
  margin: 40px auto;
  padding: 24px;
}

.tab-list {
  /* タブボタンを横方向に並べる */
  display: flex;
  gap: 8px;
  margin-bottom: 24px;
}
</style>

shallowRef()には、現在表示するコンポーネントそのものを入れます。CompAからCompBへ切り替えるように、値を丸ごと入れ替える用途なので、コンポーネント内部まで深くリアクティブ化しないshallowRef()を使っています。Vue.js公式ドキュメント

CompA.vue

CompA.vueには、状態保持を確認するためのカウンターと、モーダルを開くボタンを置きます。

<script setup>
import { ref } from 'vue'

// 親へモーダルを開いてほしいと伝えるイベントを定義する
const emit = defineEmits(['open-modal'])

// このコンポーネント内で使うカウンターの状態
const count = ref(0)

const increment = () => {
  // ボタンを押すたびにカウントを1増やす
  count.value++
}

const openDetail = () => {
  // 親コンポーネントへイベントを送り、モーダルを開いてもらう
  emit('open-modal')
}
</script>

<template>
  <section class="panel">
    <h2>compA</h2>

    <!-- 現在のカウントを表示する -->
    <p>カウント: {{ count }}</p>

    <!-- CompBやCompCへ移動して戻ったとき、KeepAliveがあれば値が残る -->
    <button @click="increment">1増やす</button>

    <!-- 親コンポーネントにモーダル表示を依頼する -->
    <button @click="openDetail">詳細を見る</button>
  </section>
</template>

<style scoped>
.panel {
  /* コンポーネントの表示領域を分かりやすくする */
  padding: 20px;
  border: 1px solid #d0d7de;
  border-radius: 8px;
}
</style>

CompB.vue

CompB.vueには、入力内容が残るか確認するためのメモ欄を置きます。

<script setup>
import { ref } from 'vue'

// 入力欄の内容を保持する
const memo = ref('')
</script>

<template>
  <section class="panel">
    <h2>compB</h2>

    <label>
      メモ
      <!-- 入力内容をmemoへ反映する -->
      <input v-model="memo" placeholder="ここに入力します" />
    </label>

    <!-- 入力中の値を表示する -->
    <p>入力内容: {{ memo }}</p>
  </section>
</template>

<style scoped>
.panel {
  /* CompAと同じ表示スタイルにする */
  padding: 20px;
  border: 1px solid #d0d7de;
  border-radius: 8px;
}
</style>

CompC.vue

CompC.vueには、選択状態が残るか確認するためのセレクトボックスを置きます。

<script setup>
import { ref } from 'vue'

// 現在選択している表示モードを保持する
const selectedMode = ref('一覧')
</script>

<template>
  <section class="panel">
    <h2>compC</h2>

    <label>
      表示モード
      <!-- 選択した値をselectedModeへ反映する -->
      <select v-model="selectedMode">
        <option>一覧</option>
        <option>詳細</option>
        <option>設定</option>
      </select>
    </label>

    <!-- 選択中の値を画面へ表示する -->
    <p>選択中: {{ selectedMode }}</p>
  </section>
</template>

<style scoped>
.panel {
  /* CompAと同じ表示スタイルにする */
  padding: 20px;
  border: 1px solid #d0d7de;
  border-radius: 8px;
}
</style>

DetailModal.vue

モーダルは<Teleport to="body">を使い、body直下へ表示します。
モーダルを開く・閉じる状態は親コンポーネントのisModalOpenで管理し、Teleportはモーダルの表示位置を移すために使います。

<script setup>
// 親から受け取るモーダルの表示状態を定義する
defineProps({
  isOpen: {
    type: Boolean,
    required: true
  }
})

// 親へ閉じる操作を伝えるイベントを定義する
const emit = defineEmits(['close'])

const closeModal = () => {
  // 親へcloseイベントを送り、モーダルを閉じてもらう
  emit('close')
}
</script>

<template>
  <!-- モーダル部分をbody直下へ表示する -->
  <Teleport to="body">
    <!-- isOpenがtrueのときだけモーダルを描画する -->
    <div v-if="isOpen" class="modal-backdrop" @click.self="closeModal">
      <section class="modal" role="dialog" aria-modal="true">
        <h2>画像解析結果の詳細</h2>
        <p>
          この場所には、選択した画像の判定結果や撮影日時などを
          表示できそうです。
        </p>

        <!-- モーダルを閉じる -->
        <button @click="closeModal">閉じる</button>
      </section>
    </div>
  </Teleport>
</template>

<style scoped>
.modal-backdrop {
  /* 画面全体を覆う半透明の背景 */
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 24px;
  background: rgb(0 0 0 / 45%);
  z-index: 1000;
}

.modal {
  /* モーダル本体の見た目 */
  width: min(100%, 480px);
  padding: 24px;
  border-radius: 12px;
  background: #fff;
}
</style>

@click.self="closeModal"は、背景部分をクリックしたときだけモーダルを閉じる書き方です。モーダル本体をクリックしても、背景をクリックしたことにはなりません。

<Teleport>を使うと、モーダルを親要素のoverflowや重なり順の影響から切り離し、画面の前面へ出しやすくなります。Vue.js公式ドキュメント vuejs

次にやりたいこと

まずはサンプルを動かし、<KeepAlive>あり・なしの違いを確認したいです。

  • CompAでカウントを増やしてからCompBへ切り替える
    再びCompAへ戻ったとき、カウントが残るか確認したいです
  • CompBへメモを入力してから別のコンポーネントへ移動する
    入力途中の内容が残るかを確認したいです
  • KeepAliveを一度外して動かす
    カウンター・メモ・選択値が初期化される違いを比べたいです
  • onActivated()onDeactivated()を追加する
    キャッシュされたコンポーネントが表示・非表示になるタイミングを確認したいです
  • モーダルへ選択したデータを渡す
    画像解析結果の一覧から詳細を確認する形へ近づけたいです
キャラクター 「KeepAliveを外した版も用意すると、状態を残す仕組みがより見えやすくなりそう。」

今日の整理

今回のサンプルでは、画面の状態保持とモーダルの表示位置を別々の仕組みで扱いました。

用語 今の理解での役割 サンプルでの利用イメージ 次に確認したいこと
shallowRef() 現在表示するコンポーネントを持つ currentComponent ref()との使い分け
Dynamic Component 表示する画面の部品を切り替える CompACompBCompC タブUIへの応用
<KeepAlive> 非表示のコンポーネントをキャッシュする カウンター・入力値の保持 includeexcludemax
onActivated() キャッシュから表示されたときに呼ばれる タイマーやデータ取得の再開 onMounted()との違い
onDeactivated() 非表示となりキャッシュへ入るときに呼ばれる タイマーや監視処理の停止 onUnmounted()との違い
<Teleport> テンプレートの一部を別のDOM位置へ出す モーダルをbody直下へ表示 通知・ツールチップへの応用
v-if モーダルを表示・非表示にする isModalOpen アニメーションとの組み合わせ

おわりに

今回つまずいた<KeepAlive> expects exactly one child componentは、<KeepAlive>の中にコメントを書いていたことが原因でした。<KeepAlive>の内側には、キャッシュしたいDynamic Componentを1つだけ直接置く、と覚えておこうと思います。

<KeepAlive>を使うと、タブを切り替えても入力内容やカウンターを残せそうです。さらに<Teleport>を組み合わせると、一覧画面の上へ詳細モーダルを表示できそうなので、画像解析結果を見やすく扱う画面にも少しずつつなげていきたいです。

学習中の内容なので、勘違いや理解不足もあると思います。もし気になる点があれば教えてもらえるとうれしいです。

 注意事項
 本ブログに掲載している内容は、私個人の見解であり、
 所属する組織の立場や戦略、意見を代表するものではありません。
 あくまでエンジニアとしての経験や考えを発信していますのでご了承ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?