【Vue】スロット(slot)で再利用可能なコンポーネントを作る

JavaScript

こんにちは、かつコーチです。

前回は、子コンポーネントから親コンポーネントへイベントを伝える 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を解消する

タイトルとURLをコピーしました