コンポーネント分割

コンポーネント化は Vue の最も中核となる設計思想です。

この章では、ページを再利用可能な小さなコンポーネントに分割し、props と emit を使ってコンポーネント間通信を実装する方法を学びます。


なぜコンポーネントを分割するのか?

機能が増えるにつれて、1つのファイルがどんどん長くなり、数百行のコードが1つの .vue ファイルに詰め込まれて、保守が困難になります。

コンポーネント分割の目的:各コンポーネントは1つのことだけを行い、組み合わせることで複雑な機能を実現する。

どの粒度が適切か?

良い経験則は次のとおりです:コードブロックが独自のデータ、独自の動作を持ち、他の場所で再利用できる場合、それは独立したコンポーネントになるべきです。

ページ領域コンポーネントに分割すべきか理由
ナビゲーションバーであるすべてのページで必要、独立した機能
記事カードであるリスト内の各記事が同じ構造
カテゴリ絞り込みボタングループである独自の状態とインタラクションを持つ
単純なラッパーコンテナ否レイアウトのためだけで、独立した動作がない

親から子へ:defineProps

親コンポーネントが子コンポーネントにデータを渡すには、propsを使用します。

これは「関数の引数」と考えることができます——親コンポーネントが子コンポーネントを呼び出すときに、データを引数として渡します。

例

<!-- ファイルパス:src/components/BlogCard.vue -->
<script setup>
// このコンポーネントが受け取る props を宣言
// これはコンパイルマクロなので、インポート不要で使用可能
const props = defineProps({
title: String, // 記事タイトル、文字列型
summary: String, // 記事の要約
date: String, // 公開日
category: String // カテゴリラベル
})

// 配列形式で書くこともできます(型チェックなし):
// const props = defineProps(['title', 'summary', 'date', 'category'])
</script>

<template>
  <div class="card">
    <span class="tag">{{ category }}</span>
    <h3>{{ title }}</h3>
    <p>{{ summary }}</p>
    <span class="date">{{ date }}</span>
  </div>
</template>

<style scoped>
.card {
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.tag {
  display: inline-block;
  padding: 2px 10px;
  background: #E8EEF5;
  color: #427DB8;
  border-radius: 12px;
  font-size: 12px;
}
.card h3 { margin: 10px 0 8px; font-size: 18px; }
.card p { color: #666; font-size: 14px; line-height: 1.6; }
.date { font-size: 12px; color: #999; }
</style>

親コンポーネントが BlogCard を使用するとき、コロンで属性をバインドしてデータを渡します:

例

<!-- ファイルパス:src/views/HomeView.vue -->
<script setup>
import { ref } from 'vue'
import BlogCard from '../components/BlogCard.vue'

const articles = ref([
{ id: 1, title: 'Vue3 入門', summary: 'ゼロからVue3を学ぶ', date: '2024-05-10', category: 'Vue' },
{ id: 2, title: 'Promise 詳解', summary: '非同期を理解する', date: '2024-05-08', category: 'JavaScript' },
])
</script>

<template>
  <div class="article-grid">
    <!-- 親コンポーネントは :属性名="値" で子コンポーネントにデータを渡す -->
    <BlogCard
     v-for="article in articles"
     :key="article.id"
     :title="article.title"
     :summary="article.summary"
     :date="article.date"
     :category="article.category"
   />
  </div>
</template>

props は単方向データフロー:親 → 子のみで、子コンポーネントは props の値を変更できません。子コンポーネントが変更する必要がある場合は、emit で親コンポーネントに通知して変更してもらいます。


子から親へ:defineEmits

子コンポーネントが親コンポーネントに何かが起こったことを通知するには(例:「クリックされた」)、emitイベントを送信します。

これは「コールバック関数」と考えることができます——子コンポーネントがイベントをトリガーし、親コンポーネントがリッスンして応答します。

例

<!-- ファイルパス:src/components/CategoryFilter.vue -->
<script setup>
const props = defineProps({
categories: Array, // 全カテゴリのリスト
activeCategory: String // 現在選択中のカテゴリ
})

// 送信するイベントを宣言
const emit = defineEmits(['update-category'])

function selectCategory(cat) {
// イベントをトリガーし、選択したカテゴリを親コンポーネントに渡す
  emit('update-category', cat)
}
</script>

<template>
  <div class="filter-bar">
    <button
     v-for="cat in categories"
     :key="cat"
     :class="{ active: activeCategory === cat }"
     @click="selectCategory(cat)"
   >
      {{ cat }}
    </button>
  </div>
</template>

親コンポーネントは@イベント名="処理関数"で子コンポーネントのイベントをリッスンします:

例

<!-- ファイルパス:src/views/HomeView.vue -->
<script setup>
import { ref, computed } from 'vue'
import CategoryFilter from '../components/CategoryFilter.vue'

const activeCategory = ref('すべて')

const categories = ['すべて', 'Vue', 'JavaScript', 'CSS']

// 子コンポーネントから送られた update-category イベントに応答
function handleCategoryChange(cat) {
  activeCategory.value = cat
}
</script>

<template>
  <!-- @update-category="handleCategoryChange" -->
  <CategoryFilter
   :categories="categories"
   :active-category="activeCategory"
   @update-category="handleCategoryChange"
 />
</template>

完全なデータフロー

親コンポーネントはprops で下方向にデータを渡し、子コンポーネントはemit で上方向にイベントを送信します。

データは常に親コンポーネントが「所有」して変更し、子コンポーネントは表示と通知だけを担当します。


実践:3つのコンポーネントに分割する

ブログプロジェクトには3つのコンポーネントが必要です。ディレクトリ構造は次のとおりです:

src/
├── components/
│   ├── NavBar.vue          # 顶部导航栏
│   ├── BlogCard.vue        # 单篇文章卡片
│   └── CategoryFilter.vue  # 分类筛选按钮组
├── views/
│   └── HomeView.vue        # 首页(父组件)
└── App.vue

NavBar.vue — ナビゲーションバー

例

<!-- ファイルパス:src/components/NavBar.vue -->
<script setup>
// ナビゲーションバーは現時点では表示のみ。今後、検索とダークモード切り替えを追加予定
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">サンプル Blog</a>
    <nav>
      <a href="/">ホーム</a>
      <a href="#">について</a>
    </nav>
  </header>
</template>

<style scoped>
.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 40px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.logo {
  font-size: 22px;
  font-weight: bold;
  color: #427DB8;
  text-decoration: none;
}

nav a {
  margin-left: 24px;
  text-decoration: none;
  color: #333;
  font-size: 15px;
}

nav a:hover {
  color: #427DB8;
}
</style>

HomeView.vue — 全コンポーネントの統合

例

<!-- ファイルパス:src/views/HomeView.vue -->
<script setup>
import { ref, computed } from 'vue'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

// 記事データ
const articles = ref([
{ id: 1, title: 'Vue3 入門完全ガイド', summary: 'ゼロからVue3 Composition APIを学ぶ', date: '2024-05-10', category: 'Vue' },
{ id: 2, title: 'JS 非同期プログラミング詳解', summary: 'Promise と async/await を理解する', date: '2024-05-08', category: 'JavaScript' },
{ id: 3, title: 'CSS Grid レイアウト実践', summary: 'Grid でレスポンシブレイアウトを実装', date: '2024-05-05', category: 'CSS' },
{ id: 4, title: 'Vue3 リアクティブの原理', summary: 'ref と reactive を深く理解する', date: '2024-05-03', category: 'Vue' },
{ id: 5, title: 'Flexbox 完全ガイド', summary: '1記事でフレックスレイアウトをマスター', date: '2024-05-01', category: 'CSS' },
])

// すべてのカテゴリを抽出(重複排除)
const categories = computed(() => {
  const cats = articles.value.map(a => a.category)
return ['すべて', ...new Set(cats)]
})

// 現在選択中のカテゴリ
const activeCategory = ref('すべて')

// カテゴリに基づいて記事をフィルタリング
const filteredArticles = computed(() => {
if (activeCategory.value === 'すべて') return articles.value
  return articles.value.filter(a => a.category === activeCategory.value)
})

// 子コンポーネントから送られたカテゴリ切り替えイベントを処理
function handleCategoryChange(cat) {
  activeCategory.value = cat
}
</script>

<template>
  <div class="home">
    <h2 class="section-title">最新記事</h2>

    <!-- カテゴリ絞り込みコンポーネント -->
    <CategoryFilter
     :categories="categories"
     :active-category="activeCategory"
     @update-category="handleCategoryChange"
   />

    <!-- 空の状態 -->
    <p v-if="filteredArticles.length === 0" class="empty-tip">
このカテゴリにはまだ記事がありません
    </p>

    <!-- 記事カードリスト -->
    <div v-else class="article-grid">
      <BlogCard
       v-for="article in filteredArticles"
       :key="article.id"
       :title="article.title"
       :summary="article.summary"
       :date="article.date"
       :category="article.category"
     />
    </div>
  </div>
</template>

<style scoped>
.home { max-width: 960px; margin: 0 auto; padding: 20px; }
.section-title { font-size: 24px; margin-bottom: 20px; }
.article-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin-top: 20px;
}
.empty-tip { text-align: center; color: #999; padding: 60px 0; }
</style>

章のまとめ

この章では、コンポーネント化の3つの中核知識を習得しました:なぜコンポーネントを分割するのか、親から子へは props(defineProps)、子から親へは emit(defineEmits)です。

ブログプロジェクトの3つのコンポーネント NavBar、BlogCard、CategoryFilter が揃いました。コード構造は第1章のゴチャゴチャした状態よりずっと明確になりました。

その他の拡張