T-CREATOR

Vue.js のスロット(slot)で柔軟なコンポーネント設計

Vue.js のスロット(slot)で柔軟なコンポーネント設計

Vue.js のスロット機能について、初心者でも理解できるように基礎から応用まで段階的に解説します。

Vue.js を学び始めた頃、コンポーネントの再利用性に悩んだ経験はありませんか?「同じような見た目のコンポーネントなのに、中身が少し違うだけで別々に作らなければいけない」そんな状況を打破するのが、今回紹介するスロット機能です。

スロットを使いこなすことで、あなたの Vue.js 開発は劇的に変わります。硬直的なコンポーネントから、柔軟で再利用可能なコンポーネントへ。この記事を読むことで、コンポーネント設計の新しい世界が開けることでしょう。

スロットとは何か

スロットの基本概念

スロットは、Vue.js のコンポーネントシステムにおいて、親コンポーネントから子コンポーネントにコンテンツを渡すための仕組みです。HTML のテンプレート部分を動的に差し替えることができるため、同じコンポーネントでも様々な内容を表示できます。

vue<!-- 子コンポーネント(MyCard.vue) -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">デフォルトヘッダー</slot>
    </div>
    <div class="card-body">
      <slot>デフォルトコンテンツ</slot>
    </div>
  </div>
</template>

このように、<slot>タグを配置することで、親コンポーネントから任意のコンテンツを受け取ることができます。

従来のコンポーネント設計との違い

従来の props を使った設計では、以下のような制限がありました:

vue<!-- 従来のprops設計 -->
<template>
  <div class="card">
    <h3>{{ title }}</h3>
    <p>{{ content }}</p>
    <button>{{ buttonText }}</button>
  </div>
</template>

<script>
export default {
  props: {
    title: String,
    content: String,
    buttonText: String,
  },
};
</script>

この方法では、テキストコンテンツしか渡せず、HTML タグや複雑な構造を含むコンテンツを渡すことができませんでした。

スロットが解決する課題

スロットは以下の課題を解決します:

  1. 柔軟性の向上: HTML タグやコンポーネントを含む任意のコンテンツを渡せる
  2. 再利用性の向上: 同じコンポーネントで様々な用途に使用できる
  3. 保守性の向上: コンテンツの変更が親コンポーネントで完結する
  4. デザインの一貫性: レイアウト構造を統一しながら、コンテンツを自由に変更できる

基本的なスロットの使い方

デフォルトスロットの実装

最も基本的なスロットは、名前のないデフォルトスロットです。

vue<!-- 子コンポーネント(SimpleCard.vue) -->
<template>
  <div class="simple-card">
    <slot>ここにコンテンツが入ります</slot>
  </div>
</template>

<style scoped>
.simple-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
  margin: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>

このコンポーネントは、親から渡されたコンテンツを表示し、何も渡されない場合はデフォルトメッセージを表示します。

スロットコンテンツの挿入方法

親コンポーネントでは、子コンポーネントのタグ内にコンテンツを記述します:

vue<!-- 親コンポーネント -->
<template>
  <div>
    <SimpleCard>
      <h2>カスタムタイトル</h2>
      <p>
        これは親コンポーネントから渡されたコンテンツです。
      </p>
      <button @click="handleClick">アクションボタン</button>
    </SimpleCard>

    <SimpleCard>
      <!-- 何も渡さない場合、デフォルトコンテンツが表示される -->
    </SimpleCard>
  </div>
</template>

<script>
import SimpleCard from './SimpleCard.vue';

export default {
  components: {
    SimpleCard,
  },
  methods: {
    handleClick() {
      console.log('ボタンがクリックされました');
    },
  },
};
</script>

基本的なコンポーネント例

実際のプロジェクトでよく使われる例として、アラートコンポーネントを作ってみましょう:

vue<!-- Alert.vue -->
<template>
  <div :class="['alert', `alert-${type}`]" v-if="show">
    <slot>アラートメッセージ</slot>
    <button
      v-if="dismissible"
      @click="close"
      class="alert-close"
    >
      ×
    </button>
  </div>
</template>

<script>
export default {
  props: {
    type: {
      type: String,
      default: 'info',
      validator: (value) =>
        ['success', 'warning', 'error', 'info'].includes(
          value
        ),
    },
    show: {
      type: Boolean,
      default: true,
    },
    dismissible: {
      type: Boolean,
      default: false,
    },
  },
  methods: {
    close() {
      this.$emit('close');
    },
  },
};
</script>

<style scoped>
.alert {
  padding: 12px 16px;
  border-radius: 4px;
  margin: 8px 0;
  position: relative;
}

.alert-success {
  background-color: #d4edda;
  border: 1px solid #c3e6cb;
  color: #155724;
}
.alert-warning {
  background-color: #fff3cd;
  border: 1px solid #ffeaa7;
  color: #856404;
}
.alert-error {
  background-color: #f8d7da;
  border: 1px solid #f5c6cb;
  color: #721c24;
}
.alert-info {
  background-color: #d1ecf1;
  border: 1px solid #bee5eb;
  color: #0c5460;
}

.alert-close {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  opacity: 0.7;
}

.alert-close:hover {
  opacity: 1;
}
</style>

名前付きスロットで複数コンテンツを管理

複数スロットの定義方法

より複雑なコンポーネントでは、複数のスロットを使い分ける必要があります。名前付きスロットを使うことで、複数のコンテンツエリアを管理できます。

vue<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">
        <h3>デフォルトタイトル</h3>
      </slot>
    </div>

    <div class="card-body">
      <slot>デフォルトコンテンツ</slot>
    </div>

    <div class="card-footer">
      <slot name="footer">
        <button class="btn btn-primary">
          デフォルトボタン
        </button>
      </slot>
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.card-header {
  background-color: #f8f9fa;
  padding: 16px;
  border-bottom: 1px solid #ddd;
}

.card-body {
  padding: 16px;
}

.card-footer {
  background-color: #f8f9fa;
  padding: 16px;
  border-top: 1px solid #ddd;
}

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-primary {
  background-color: #007bff;
  color: white;
}

.btn-primary:hover {
  background-color: #0056b3;
}
</style>

スロット名の指定と使用

親コンポーネントでは、v-slotディレクティブ(または#省略記法)を使って、どのスロットにコンテンツを渡すかを指定します:

vue<!-- 親コンポーネント -->
<template>
  <div>
    <!-- 完全なカスタマイズ -->
    <Card>
      <template v-slot:header>
        <h2>カスタムヘッダー</h2>
        <p class="text-muted">サブタイトル</p>
      </template>

      <p>メインコンテンツがここに入ります。</p>
      <ul>
        <li>リストアイテム1</li>
        <li>リストアイテム2</li>
      </ul>

      <template #footer>
        <button class="btn btn-success">保存</button>
        <button class="btn btn-secondary">
          キャンセル
        </button>
      </template>
    </Card>

    <!-- 部分的なカスタマイズ -->
    <Card>
      <template #header>
        <h3>シンプルなヘッダー</h3>
      </template>

      デフォルトのボタンが使われるフッター
    </Card>
  </div>
</template>

<script>
import Card from './Card.vue';

export default {
  components: {
    Card,
  },
};
</script>

<style scoped>
.text-muted {
  color: #6c757d;
  font-size: 0.9em;
}

.btn {
  margin-right: 8px;
}

.btn-success {
  background-color: #28a745;
  color: white;
}

.btn-secondary {
  background-color: #6c757d;
  color: white;
}
</style>

実践的な UI コンポーネント例

モーダルダイアログコンポーネントを作成してみましょう:

vue<!-- Modal.vue -->
<template>
  <Teleport to="body">
    <div
      v-if="show"
      class="modal-overlay"
      @click="handleOverlayClick"
    >
      <div class="modal-container" @click.stop>
        <div class="modal-header">
          <slot name="header">
            <h3>モーダルタイトル</h3>
          </slot>
          <button
            v-if="closeable"
            @click="close"
            class="modal-close"
          >
            ×
          </button>
        </div>

        <div class="modal-body">
          <slot>モーダルコンテンツ</slot>
        </div>

        <div class="modal-footer">
          <slot name="footer">
            <button
              @click="close"
              class="btn btn-secondary"
            >
              閉じる
            </button>
          </slot>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script>
export default {
  props: {
    show: {
      type: Boolean,
      default: false,
    },
    closeable: {
      type: Boolean,
      default: true,
    },
    closeOnOverlay: {
      type: Boolean,
      default: true,
    },
  },
  emits: ['close'],
  methods: {
    close() {
      this.$emit('close');
    },
    handleOverlayClick() {
      if (this.closeOnOverlay) {
        this.close();
      }
    },
  },
};
</script>

<style scoped>
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-container {
  background: white;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  max-height: 80vh;
  overflow: hidden;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.modal-header {
  padding: 16px;
  border-bottom: 1px solid #ddd;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.modal-body {
  padding: 16px;
  overflow-y: auto;
}

.modal-footer {
  padding: 16px;
  border-top: 1px solid #ddd;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

.modal-close {
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
  padding: 0;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}

.modal-close:hover {
  background-color: #f0f0f0;
}
</style>

スコープ付きスロットでデータを渡す

スコープ付きスロットの概念

スコープ付きスロットは、子コンポーネントから親コンポーネントにデータを渡すことができる仕組みです。これにより、親コンポーネントは子コンポーネントのデータを使って、カスタムな表示ロジックを実装できます。

vue<!-- DataList.vue -->
<template>
  <div class="data-list">
    <div
      v-for="(item, index) in items"
      :key="index"
      class="list-item"
    >
      <slot
        :item="item"
        :index="index"
        :isEven="index % 2 === 0"
      >
        <div class="default-item">
          {{ item.name || item }}
        </div>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true,
    },
  },
};
</script>

<style scoped>
.data-list {
  border: 1px solid #ddd;
  border-radius: 4px;
}

.list-item {
  padding: 12px;
  border-bottom: 1px solid #eee;
}

.list-item:last-child {
  border-bottom: none;
}

.default-item {
  color: #333;
}
</style>

親子間でのデータ共有

親コンポーネントでは、v-slotディレクティブでスロットプロパティを受け取ります:

vue<!-- 親コンポーネント -->
<template>
  <div>
    <h2>ユーザーリスト</h2>

    <DataList :items="users">
      <template v-slot="{ item, index, isEven }">
        <div :class="['user-item', { even: isEven }]">
          <div class="user-info">
            <h4>{{ item.name }}</h4>
            <p>{{ item.email }}</p>
          </div>
          <div class="user-actions">
            <button
              @click="editUser(item)"
              class="btn btn-sm btn-primary"
            >
              編集
            </button>
            <button
              @click="deleteUser(index)"
              class="btn btn-sm btn-danger"
            >
              削除
            </button>
          </div>
        </div>
      </template>
    </DataList>

    <h2>シンプルリスト</h2>
    <DataList :items="simpleItems">
      <!-- デフォルトスロットを使用 -->
    </DataList>
  </div>
</template>

<script>
import DataList from './DataList.vue';

export default {
  components: {
    DataList,
  },
  data() {
    return {
      users: [
        { name: '田中太郎', email: 'tanaka@example.com' },
        { name: '佐藤花子', email: 'sato@example.com' },
        { name: '鈴木一郎', email: 'suzuki@example.com' },
      ],
      simpleItems: ['アイテム1', 'アイテム2', 'アイテム3'],
    };
  },
  methods: {
    editUser(user) {
      console.log('編集:', user);
    },
    deleteUser(index) {
      this.users.splice(index, 1);
    },
  },
};
</script>

<style scoped>
.user-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 0;
}

.user-item.even {
  background-color: #f8f9fa;
}

.user-info h4 {
  margin: 0 0 4px 0;
  color: #333;
}

.user-info p {
  margin: 0;
  color: #666;
  font-size: 0.9em;
}

.user-actions {
  display: flex;
  gap: 8px;
}

.btn-sm {
  padding: 4px 8px;
  font-size: 0.8em;
}

.btn-danger {
  background-color: #dc3545;
  color: white;
}

.btn-danger:hover {
  background-color: #c82333;
}
</style>

動的コンテンツの実装

より実践的な例として、データテーブルコンポーネントを作成してみましょう:

vue<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <thead>
        <tr>
          <th v-for="column in columns" :key="column.key">
            {{ column.label }}
          </th>
          <th v-if="hasActions">アクション</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(row, index) in data" :key="index">
          <td v-for="column in columns" :key="column.key">
            <slot
              :name="`cell-${column.key}`"
              :value="row[column.key]"
              :row="row"
              :index="index"
            >
              {{
                formatValue(row[column.key], column.type)
              }}
            </slot>
          </td>
          <td v-if="hasActions" class="actions">
            <slot name="actions" :row="row" :index="index">
              <button
                @click="editRow(row, index)"
                class="btn btn-sm btn-primary"
              >
                編集
              </button>
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  props: {
    data: {
      type: Array,
      required: true,
    },
    columns: {
      type: Array,
      required: true,
    },
    hasActions: {
      type: Boolean,
      default: true,
    },
  },
  methods: {
    formatValue(value, type) {
      if (type === 'date') {
        return new Date(value).toLocaleDateString('ja-JP');
      }
      if (type === 'currency') {
        return `¥${value.toLocaleString()}`;
      }
      if (type === 'boolean') {
        return value ? 'はい' : 'いいえ';
      }
      return value;
    },
    editRow(row, index) {
      this.$emit('edit', { row, index });
    },
  },
};
</script>

<style scoped>
.data-table {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
  border: 1px solid #ddd;
}

th,
td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

th {
  background-color: #f8f9fa;
  font-weight: 600;
}

.actions {
  white-space: nowrap;
}
</style>

スロットの応用パターン

レイアウトコンポーネントの設計

スロットを活用して、柔軟なレイアウトコンポーネントを作成できます:

vue<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="layout-header">
      <slot name="header">
        <h1>デフォルトヘッダー</h1>
      </slot>
    </header>

    <div class="layout-container">
      <aside class="layout-sidebar" v-if="$slots.sidebar">
        <slot name="sidebar"></slot>
      </aside>

      <main class="layout-main">
        <slot></slot>
      </main>
    </div>

    <footer class="layout-footer">
      <slot name="footer">
        <p>&copy; 2024 マイアプリ</p>
      </slot>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.layout-header {
  background-color: #333;
  color: white;
  padding: 16px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.layout-container {
  flex: 1;
  display: flex;
}

.layout-sidebar {
  width: 250px;
  background-color: #f8f9fa;
  border-right: 1px solid #ddd;
  padding: 16px;
}

.layout-main {
  flex: 1;
  padding: 16px;
}

.layout-footer {
  background-color: #f8f9fa;
  border-top: 1px solid #ddd;
  padding: 16px;
  text-align: center;
}
</style>

再利用可能な UI パーツの作成

スロットを使った再利用可能な UI パーツの例:

vue<!-- Tabs.vue -->
<template>
  <div class="tabs">
    <div class="tabs-header">
      <button
        v-for="tab in tabs"
        :key="tab.name"
        @click="activeTab = tab.name"
        :class="[
          'tab-button',
          { active: activeTab === tab.name },
        ]"
      >
        <slot :name="`tab-${tab.name}`" :tab="tab">
          {{ tab.label }}
        </slot>
      </button>
    </div>

    <div class="tabs-content">
      <div
        v-for="tab in tabs"
        :key="tab.name"
        v-show="activeTab === tab.name"
        class="tab-panel"
      >
        <slot :name="`panel-${tab.name}`" :tab="tab">
          {{ tab.content }}
        </slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    tabs: {
      type: Array,
      required: true,
    },
    defaultTab: {
      type: String,
      default: null,
    },
  },
  data() {
    return {
      activeTab: this.defaultTab || this.tabs[0]?.name,
    };
  },
};
</script>

<style scoped>
.tabs {
  border: 1px solid #ddd;
  border-radius: 4px;
}

.tabs-header {
  display: flex;
  border-bottom: 1px solid #ddd;
  background-color: #f8f9fa;
}

.tab-button {
  padding: 12px 16px;
  border: none;
  background: none;
  cursor: pointer;
  border-bottom: 2px solid transparent;
}

.tab-button.active {
  border-bottom-color: #007bff;
  background-color: white;
}

.tab-button:hover:not(.active) {
  background-color: #e9ecef;
}

.tabs-content {
  padding: 16px;
}

.tab-panel {
  min-height: 100px;
}
</style>

条件付きスロットの活用

条件に応じてスロットの表示を制御するパターン:

vue<!-- ConditionalSlot.vue -->
<template>
  <div class="conditional-slot">
    <!-- ローディング状態 -->
    <div v-if="loading" class="loading">
      <slot name="loading">
        <div class="spinner">読み込み中...</div>
      </slot>
    </div>

    <!-- エラー状態 -->
    <div v-else-if="error" class="error">
      <slot name="error" :error="error">
        <div class="error-message">
          <h3>エラーが発生しました</h3>
          <p>{{ error.message }}</p>
          <button @click="retry" class="btn btn-primary">
            再試行
          </button>
        </div>
      </slot>
    </div>

    <!-- 空の状態 -->
    <div v-else-if="isEmpty" class="empty">
      <slot name="empty">
        <div class="empty-message">
          <p>データがありません</p>
        </div>
      </slot>
    </div>

    <!-- 正常な状態 -->
    <div v-else class="content">
      <slot :data="data"></slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    loading: {
      type: Boolean,
      default: false,
    },
    error: {
      type: Object,
      default: null,
    },
    data: {
      type: Array,
      default: () => [],
    },
  },
  computed: {
    isEmpty() {
      return (
        !this.loading &&
        !this.error &&
        this.data.length === 0
      );
    },
  },
  methods: {
    retry() {
      this.$emit('retry');
    },
  },
};
</script>

<style scoped>
.conditional-slot {
  min-height: 200px;
}

.loading,
.error,
.empty,
.content {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}

.spinner {
  color: #666;
}

.error-message {
  text-align: center;
  color: #721c24;
}

.empty-message {
  text-align: center;
  color: #666;
}
</style>

まとめ

スロットを使った柔軟なコンポーネント設計のメリットと実践的な活用方法

Vue.js のスロット機能は、コンポーネント設計を根本的に変える強力なツールです。今回学んだ内容を振り返ると、以下のような大きな変化を実感できるはずです。

従来の硬直的な設計から、柔軟で再利用可能な設計へ

スロットを使うことで、同じコンポーネントでも様々な用途に活用できるようになります。カードコンポーネント一つで、商品紹介、ユーザープロフィール、設定項目など、あらゆる場面に対応できるのです。

開発効率の劇的な向上

新しい UI パーツを作るたびに、一からコンポーネントを作成する必要がなくなります。既存のスロット対応コンポーネントを組み合わせることで、短時間で高品質な UI を構築できます。

保守性の向上

コンテンツの変更が親コンポーネントで完結するため、子コンポーネントの修正が不要になります。これにより、バグの発生リスクが大幅に削減されます。

チーム開発での活用

スロットを使ったコンポーネント設計は、デザイナーとエンジニアの協業をスムーズにします。デザイナーはレイアウト構造を理解しやすく、エンジニアは実装の自由度を保てます。

実際のプロジェクトでは、今回紹介したパターンを組み合わせて使用することをお勧めします。例えば、レイアウトコンポーネント内に条件付きスロットを配置し、その中で名前付きスロットを使うといった具合です。

スロット機能をマスターすることで、あなたの Vue.js 開発は確実に次のレベルに到達します。柔軟で保守性の高いコンポーネント設計を実現し、ユーザー体験の向上に貢献できることでしょう。

関連リンク