こんにちは、かつコーチです。
前回は、子コンポーネントから親コンポーネントへイベントを伝える emit を解説しました。
今回は、Props・emitと並んでコンポーネント設計の要となる「スロット(slot)」を扱います。
スロットを使いこなせるようになると、「見た目の枠組みは共通、中身だけ差し替えたい」というコンポーネントを、驚くほどシンプルに作れるようになります。
スロットとは?
PropsとSlotの役割の違い
Propsは、文字列や数値、オブジェクトといった「データ」を親から子に渡す仕組みでした。
一方 スロット(slot) は、HTMLの要素やコンポーネントそのもの(テンプレートの中身) を親から子に渡す仕組みです。
たとえば「枠線付きのカード」というデザインは共通でも、中に表示する内容がテキストだったり、画像だったり、ボタン付きのフォームだったりと、ケースによって変わることがあります。
このように「入れ物(枠組み)は共通、中身は状況によって変えたい」という場面で、スロットが真価を発揮します。
なぜスロットが必要なのか
スロットがない場合、中身が違うだけの似たコンポーネントを何個も作ることになりがちです。
<!-- スロットがない場合、中身ごとにコンポーネントを量産しがちになる -->
<TextCard />
<ImageCard />
<FormCard />
スロットを使えば、「カードの枠組み」を1つのコンポーネントにまとめ、中身だけを呼び出し側から自由に差し込めるようになります。
コンポーネントの再利用性を高めるうえで欠かせない考え方です。
スロットの基本の書き方
子コンポーネントでslotを配置する
子コンポーネント(CardBox.vue を想定)に <slot> タグを配置します。
<script setup lang="ts">
// このコンポーネント自体は中身のデータを持たない
</script>
<template>
<div class="card-box">
<slot></slot>
</div>
</template>
<style scoped>
.card-box {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
}
</style>
親コンポーネントで中身を差し込む
親コンポーネント側では、子コンポーネントのタグの間に中身を書くだけで、その内容が <slot> の位置に差し込まれます。
<script setup lang="ts">
import CardBox from './CardBox.vue'
</script>
<template>
<CardBox>
<h2>お知らせ</h2>
<p>メンテナンスは8月13日に実施予定です。</p>
</CardBox>
<CardBox>
<img src="/thumbnail.jpg" alt="サムネイル画像" />
</CardBox>
</template>
同じ CardBox コンポーネントでありながら、1つ目にはテキスト、2つ目には画像と、まったく違う中身を差し込めています。
<div class="card-box"> という共通の枠組みだけをコンポーネント側で管理し、中身は呼び出し側が自由に決められる、という分業ができました。
名前付きスロットで複数の差し込み口を作る
header・footerを分けて配置する
1つのコンポーネントに複数の差し込み口を用意したい場合は「名前付きスロット」を使います。
<template>
<div class="card-box">
<header class="card-box__header">
<slot name="header"></slot>
</header>
<main class="card-box__body">
<slot></slot>
</main>
<footer class="card-box__footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
親コンポーネント側では v-slot: (省略形は #)を使って、どの差し込み口に何を入れるか指定します。
<template>
<CardBox>
<template #header>
<h2>お知らせ</h2>
</template>
<p>メンテナンスは8月13日に実施予定です。</p>
<template #footer>
<button>詳細を見る</button>
</template>
</CardBox>
</template>
名前を付けていない <slot></slot> は「デフォルトスロット」と呼ばれ、<template #◯◯> で囲まれなかった中身がそのまま差し込まれます。
つまずきやすいポイント:スロット名の指定漏れ
デフォルトスロットに流れてしまう
私が実際にハマったのが、名前付きスロットの指定を忘れて、想定と違う場所に中身が表示されてしまったケースです。
❌ Before:template #headerを書き忘れる
<template>
<CardBox>
<h2>お知らせ</h2>
<!-- template #headerで囲むのを忘れると、headerスロットではなくデフォルトスロットに入ってしまう -->
<p>メンテナンスは8月13日に実施予定です。</p>
</CardBox>
</template>
このコードでは、<h2> も <p> もどちらも <template #header> で囲んでいないため、両方とも名前なしの「デフォルトスロット」に流れ込んでしまいます。
その結果、本来ヘッダー部分に表示したかった見出しが、本文と同じ場所に表示されてしまい、「なぜレイアウトが崩れるのか」としばらくCSSを疑っていました。
原因はCSSではなく、スロットの指定漏れだったというオチです。
✅ After:template #headerで明示的に指定する
<template>
<CardBox>
<template #header>
<h2>お知らせ</h2>
</template>
<p>メンテナンスは8月13日に実施予定です。</p>
</CardBox>
</template>
<h2> を <template #header> で明示的に囲むことで、意図通り header という名前付きスロットに差し込まれるようになります。
名前付きスロットを使うコンポーネントを呼び出すときは、「どの中身をどのスロット名に入れるか」を1つずつ確認する癖をつけると、このようなレイアウト崩れを防げます。
スコープ付きスロットで子のデータを親に渡す
応用として、子コンポーネントが持つデータを、スロット経由で親側のテンプレートに渡す「スコープ付きスロット」という仕組みもあります。
<!-- 子コンポーネント側 -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item"></slot>
</li>
</ul>
</template>
<!-- 親コンポーネント側 -->
<template>
<ItemList>
<template #default="{ item }">
<span>{{ item.name }}</span>
</template>
</ItemList>
</template>
<slot :item="item"> のようにスロットに値を渡し、親側では #default="{ item }" として受け取ることで、子のデータを使いながら見た目だけを親が自由にカスタマイズできます。
一覧表示のようにデータと表示方法を分離したいコンポーネントで特に威力を発揮する書き方です。
まとめ
この記事のポイント
- スロットは「データ」ではなく「テンプレートの中身」を親から子に渡す仕組み
- 枠組みは共通、中身だけ変えたいコンポーネントの再利用性を大きく高められる
- 複数の差し込み口が必要な場合は、名前付きスロット(
#headerなど)を使う template #◯◯の指定漏れは、意図しないスロットに中身が流れ込む原因になる- スコープ付きスロットを使うと、子のデータを使いながら表示方法だけ親が決められる
次に読むべき記事
次回は、Props・emit・スロットを踏まえたうえで、コンポーネントの階層が深くなったときに起きる「Props drilling」を、provide / inject で解消する方法を解説します。
→ 次の記事:provide/injectでProps drillingを解消する