第7章: コンポーネント入門(SFC / Props / Emits)

第7章: コンポーネント入門(SFC / Props / Emits)

概要

本章では、Vue.jsのコンポーネントシステムについて学びます。単一ファイルコンポーネント(SFC)の構造と利点、Propsを使用したデータの受け渡し、Emitsを使用したイベントの発信方法について理解を深めます。また、コンポーネントのライフサイクルと再利用可能なコンポーネントの設計についても学びます。

1. コンポーネントの基本概念

コンポーネントは、Vue.jsアプリケーションの基本的な構成要素です。コンポーネントを使用することで、UIを独立した再利用可能なパーツに分割できます。大規模なアプリケーションでは、コンポーネントベースの開発が必須となります。

コンポーネントの利点

2. 単一ファイルコンポーネント(SFC)

Vue.jsでは、単一ファイルコンポーネント(Single File Component, SFC)と呼ばれる特別なファイル形式を使用します。これは.vue拡張子を持つファイルで、テンプレート、スクリプト、スタイルを1つのファイルにカプセル化します。

SFCの基本構造

<template>
  <!-- HTMLテンプレート -->
  <div class="greeting">
    <h1>{{ message }}</h1>
    <button @click="greet">挨拶する</button>
  </div>
</template>

<script setup>
// JavaScriptコード
import { ref } from 'vue'

const message = ref('こんにちは、Vue!')

function greet() {
  alert('こんにちは!')
}
</script>

<style scoped>
/* CSSスタイル */
.greeting {
  text-align: center;
  padding: 20px;
}

h1 {
  color: #42b983;
}

button {
  background-color: #42b983;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}
</style>

SFCの利点

script setupの利点

Vue 3では、<script setup>構文を使用することで、より簡潔にComposition APIを記述できます。従来のsetup()関数に比べて以下の利点があります:

3. コンポーネントの登録と使用

グローバル登録

グローバル登録を使用すると、アプリケーション全体でコンポーネントを使用できます。main.jsファイルで行うことが一般的です。

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import GlobalComponent from './components/GlobalComponent.vue'

const app = createApp(App)

// グローバルコンポーネントの登録
app.component('GlobalComponent', GlobalComponent)

app.mount('#app')

ローカル登録(コンポーネントのインポート)

ローカル登録では、コンポーネントを使用する親コンポーネント内でインポートして使用します。これが推奨される方法です。

<script setup>
// コンポーネントのインポート
import ChildComponent from './components/ChildComponent.vue'
</script>

<template>
  <div>
    <h2>親コンポーネント</h2>
    <ChildComponent />
  </div>
</template>

<script setup>を使用する場合、インポートしたコンポーネントは自動的に登録され、テンプレートで使用できます。通常の<script>ブロックを使用する場合は、componentsオプションで明示的に登録する必要があります。

4. Props:親から子へのデータ伝達

Propsは、親コンポーネントから子コンポーネントにデータを渡す方法です。子コンポーネントはPropsを通じて親からのデータを受け取り、表示や処理に使用できます。

Propsの定義(子コンポーネント)

<!-- UserCard.vue (子コンポーネント) -->
<script setup>
// defineProps を使用してプロパティを定義
const props = defineProps({
  // 基本的な型チェック
  username: String,
  
  // 詳細な定義(型、必須フラグ、デフォルト値)
  email: {
    type: String,
    required: true
  },
  
  // デフォルト値を持つプロパティ
  role: {
    type: String,
    default: 'user'
  },
  
  // 配列のデフォルト値(ファクトリ関数が必要)
  tags: {
    type: Array,
    default: () => []
  },
  
  // バリデーション関数
  age: {
    type: Number,
    validator: (value) => value >= 0 && value < 120
  }
})

// props はテンプレート内で直接使用可能
// また、propsはreactiveなので、値の変更を監視できます

Propsの使用(親コンポーネント)

<!-- ParentComponent.vue (親コンポーネント) -->
<script setup>
import { ref } from 'vue'
import UserCard from './components/UserCard.vue'

const currentUser = ref({
  username: '山田太郎',
  email: 'taro@example.com',
  role: 'admin',
  tags: ['開発', 'マネージャー'],
  age: 32
})
</script>

<template>
  <div>
    <h2>ユーザー情報</h2>
    
    <!-- 静的な値を渡す -->
    <UserCard
      username="ゲスト"
      email="guest@example.com"
      :age="25"
    />
    
    <!-- 動的な値を渡す(v-bindを使用) -->
    <UserCard
      :username="currentUser.username"
      :email="currentUser.email"
      :role="currentUser.role"
      :tags="currentUser.tags"
      :age="currentUser.age"
    />
    
    <!-- スプレッド構文を使用して全てのプロパティを渡す -->
    <UserCard v-bind="currentUser" />
  </div>
</template>

Propsの命名規則

JavaScriptではキャメルケース(camelCase)、HTMLではケバブケース(kebab-case)を使用するのが一般的です。Vue.jsは自動的に変換を行います。

<!-- 子コンポーネント内 -->
<script setup>
defineProps({
  postTitle: String,  // camelCase
  authorName: String  // camelCase
})
</script>

<!-- 親コンポーネント内 -->
<template>
  <BlogPost
    post-title="Vue.jsの基本"  <!-- kebab-case -->
    author-name="山田太郎"  <!-- kebab-case -->
  />
</template>

Propsの型

Vueでは以下の型がサポートされています:

また、カスタムクラスやコンストラクタ関数も型として使用できます。

Propsの単方向データフロー

Propsは常に親から子への単方向のデータフローに従います。親のプロパティが更新されると、子のプロパティも更新されますが、その逆は起こりません。これにより、子コンポーネントが誤って親の状態を変更することを防ぎます。

子コンポーネント内でプロパティを変更しようとすると、Vueは警告を出します。プロパティを基にローカルの状態を作成したい場合は、以下の方法があります:

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

const props = defineProps({
  initialCount: {
    type: Number,
    default: 0
  }
})

// 1. プロパティを基にローカルデータを作成
const count = ref(props.initialCount)

// 2. 計算プロパティを使用して変換
const formattedCount = computed(() => `カウント: ${props.initialCount}`)
</script>

5. Emits:子から親へのイベント発信

Emitsは、子コンポーネントから親コンポーネントにイベントを発信する方法です。これにより、子は親に対して通知を行ったり、データを送信したりできます。

Emitsの定義(子コンポーネント)

<!-- SearchForm.vue (子コンポーネント) -->
<script setup>
import { ref } from 'vue'

// emitsの定義
const emit = defineEmits([
  'search',        // 基本的なイベント
  'reset',         // 引数のないイベント
  'update:query'   // v-modelのための更新イベント
])

// v-modelで使用する検索クエリ
const searchQuery = ref('')

// 検索イベントの発信
function handleSearch() {
  if (searchQuery.value.trim()) {
    // イベント名と引数を指定して発信
    emit('search', searchQuery.value)
  }
}

// リセットイベントの発信
function handleReset() {
  searchQuery.value = ''
  emit('reset')
}

// 入力値の変更時にv-model更新イベントを発信
function updateQuery(event) {
  const value = event.target.value
  searchQuery.value = value
  emit('update:query', value)
}
</script>

<template>
  <div class="search-form">
    <input
      :value="searchQuery"
      @input="updateQuery"
      placeholder="検索ワードを入力..."
      type="text"
    >
    <button @click="handleSearch">検索</button>
    <button @click="handleReset">リセット</button>
  </div>
</template>

Emitsのバリデーション

Propsと同様に、Emitsもオブジェクト構文を使用してバリデーションを追加できます。

<script setup>
const emit = defineEmits({
  // バリデーション関数
  search: (query) => {
    // 空の検索クエリを拒否
    if (!query.trim()) {
      console.warn('検索クエリが空です')
      return false
    }
    return true
  },
  
  // ペイロードがオブジェクトであることを確認
  'filter-change': (filters) => {
    return typeof filters === 'object' && filters !== null
  }
})
</script>

Emitsの使用(親コンポーネント)

<!-- ParentComponent.vue (親コンポーネント) -->
<script setup>
import { ref } from 'vue'
import SearchForm from './components/SearchForm.vue'

const searchQuery = ref('')
const searchResults = ref([])

// 子コンポーネントからのイベントを処理
function handleSearch(query) {
  console.log(`検索ワード: ${query}`)
  // 検索処理を実行...
  searchResults.value = [`${query}に関する結果1`, `${query}に関する結果2`]
}

function handleReset() {
  searchResults.value = []
  console.log('検索をリセットしました')
}
</script>

<template>
  <div>
    <h2>検索アプリ</h2>
    
    <!-- イベントリスナーを設定 -->
    <SearchForm
      @search="handleSearch"
      @reset="handleReset"
      @update:query="searchQuery = $event"
    />
    
    <!-- または、v-modelを使用(@update:queryと:queryをまとめたもの) -->
    <SearchForm
      v-model:query="searchQuery"
      @search="handleSearch"
      @reset="handleReset"
    />
    
    <!-- 現在の検索クエリを表示 -->
    <p v-if="searchQuery">現在の検索: {{ searchQuery }}</p>
    
    <!-- 検索結果を表示 -->
    <div v-if="searchResults.length">
      <h3>検索結果</h3>
      <ul>
        <li v-for="(result, index) in searchResults" :key="index">
          {{ result }}
        </li>
      </ul>
    </div>
  </div>
</template>

6. コンポーネントの通信パターン

Vue.jsでは、コンポーネント間の通信に以下のパターンが一般的に使用されます。

Props Down, Events Up(親子間通信)

最も基本的な通信パターンです。親から子にはpropsでデータを渡し、子から親にはイベント(emits)で通知します。

Props Down, Events Up パターン

v-modelを使用した双方向バインディング

フォーム要素やカスタムコンポーネントで双方向バインディングを実現するためのパターンです。内部的には、プロパティとイベントの組み合わせです。

<!-- CustomInput.vue (子コンポーネント) -->
<script setup>
// modelValueはv-modelのデフォルトプロパティ名
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function updateValue(event) {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    @input="updateValue"
    class="custom-input"
  >
</template>

<!-- 親コンポーネント -->
<template>
  <CustomInput v-model="searchText" />
  
  <!-- 上記は以下と同等 -->
  <CustomInput
    :modelValue="searchText"
    @update:modelValue="searchText = $event"
  />
</template>

複数のv-modelバインディング

Vue 3では、1つのコンポーネントに対して複数のv-modelバインディングを設定できます。

<!-- UserForm.vue (子コンポーネント) -->
<script setup>
const props = defineProps({
  firstName: String,
  lastName: String
})

const emit = defineEmits([
  'update:firstName',
  'update:lastName'
])

function updateFirstName(event) {
  emit('update:firstName', event.target.value)
}

function updateLastName(event) {
  emit('update:lastName', event.target.value)
}
</script>

<template>
  <div>
    <input :value="firstName" @input="updateFirstName" placeholder="名">
    <input :value="lastName" @input="updateLastName" placeholder="姓">
  </div>
</template>

<!-- 親コンポーネント -->
<template>
  <UserForm
    v-model:firstName="user.firstName"
    v-model:lastName="user.lastName"
  />
</template>

7. コンポーネントのライフサイクル

Vue.jsのコンポーネントには、作成から破棄までのライフサイクルがあります。各段階でフックを使用して、特定のタイミングでコードを実行できます。

ライフサイクルフック

<script setup>
import { onMounted, onUpdated, onUnmounted, onBeforeUnmount } from 'vue'

// コンポーネントがマウントされた(DOMに追加された)後に実行
onMounted(() => {
  console.log('コンポーネントがマウントされました')
  // 外部APIからデータを取得するなどの初期化処理
})

// コンポーネントが更新された後に実行
onUpdated(() => {
  console.log('コンポーネントが更新されました')
})

// コンポーネントが削除される直前に実行
onBeforeUnmount(() => {
  console.log('コンポーネントが間もなく削除されます')
  // クリーンアップ処理の準備
})

// コンポーネントが削除された後に実行
onUnmounted(() => {
  console.log('コンポーネントが削除されました')
  // イベントリスナーの削除やタイマーのクリアなど
})
</script>

その他のライフサイクルフック

8. スロット(Slots):コンポーネントへのコンテンツ配信

スロットを使用すると、親コンポーネントから子コンポーネントへHTMLコンテンツを渡すことができます。これにより、柔軟なコンポーネント設計が可能になります。

基本的なスロット

<!-- BaseCard.vue (子コンポーネント) -->
<template>
  <div class="card">
    <div class="card-header">
      <h3>{{ title }}</h3>
    </div>
    <div class="card-body">
      <!-- ここにスロットを配置 -->
      <slot>デフォルトコンテンツ</slot>
    </div>
  </div>
</template>

<script setup>
defineProps({
  title: {
    type: String,
    default: 'カード'
  }
})
</script>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.card-header {
  background-color: #f5f5f5;
  padding: 10px 15px;
  border-bottom: 1px solid #ddd;
}

.card-body {
  padding: 15px;
}
</style>

<!-- 親コンポーネント -->
<template>
  <BaseCard title="プロフィール">
    <!-- このコンテンツがスロットに挿入される -->
    <p>名前: 山田太郎</p>
    <p>職業: プログラマー</p>
  </BaseCard>
  
  <BaseCard title="通知">
    <p>新しいメッセージが3件あります。</p>
  </BaseCard>
  
  <!-- デフォルトコンテンツが表示される -->
  <BaseCard title="情報なし" />
</template>

名前付きスロット

複数のスロットを使用したい場合は、名前付きスロットを使用します。

<!-- BaseLayout.vue (子コンポーネント) -->
<template>
  <div class="container">
    <header class="header">
      <slot name="header">デフォルトヘッダー</slot>
    </header>
    
    <main class="content">
      <!-- デフォルトスロット(name="default"と同じ) -->
      <slot>デフォルトコンテンツ</slot>
    </main>
    
    <footer class="footer">
      <slot name="footer">デフォルトフッター</slot>
    </footer>
  </div>
</template>

<!-- 親コンポーネント -->
<template>
  <BaseLayout>
    <!-- v-slot:header または #header -->
    <template #header>
      <h1>ウェブサイトのタイトル</h1>
      <nav>
        <a href="#">ホーム</a>
        <a href="#">概要</a>
        <a href="#">お問い合わせ</a>
      </nav>
    </template>
    
    <!-- デフォルトスロット(v-slot:default) -->
    <p>メインコンテンツがここに入ります。</p>
    
    <!-- v-slot:footer または #footer -->
    <template #footer>
      <p>© 2023 私のウェブサイト</p>
    </template>
  </BaseLayout>
</template>

スコープ付きスロット

子コンポーネントのデータを親コンポーネントのスロットコンテンツで使用したい場合は、スコープ付きスロットを使用します。

<!-- ItemList.vue (子コンポーネント) -->
<script setup>
const props = defineProps({
  items: {
    type: Array,
    required: true
  }
})
</script>

<template>
  <ul class="item-list">
    <li v-for="(item, index) in items" :key="index">
      <!-- item と index を親コンポーネントに渡す -->
      <slot :item="item" :index="index">
        <!-- デフォルト表示 -->
        {{ item.text }}
      </slot>
    </li>
  </ul>
</template>

<!-- 親コンポーネント -->
<script setup>
import { ref } from 'vue'

const items = ref([
  { id: 1, text: '項目1', status: 'active' },
  { id: 2, text: '項目2', status: 'completed' },
  { id: 3, text: '項目3', status: 'active' }
])
</script>

<template>
  <ItemList :items="items">
    <!-- v-slot="slotProps" でスロットのプロパティを受け取る -->
    <template v-slot="{ item, index }">
      <div :class="{ completed: item.status === 'completed' }">
        {{ index + 1 }}. {{ item.text }}
        <span v-if="item.status === 'completed'">(完了)</span>
      </div>
    </template>
  </ItemList>
</template>

9. 再利用可能なコンポーネントの設計

効果的な再利用可能なコンポーネントを設計するためのベストプラクティスを紹介します。

単一責任の原則

コンポーネントは1つの責任を持つべきです。複数の役割を持つコンポーネントは、より小さなコンポーネントに分割することを検討しましょう。

明確なインターフェース

コンポーネントは明確なインターフェースを持つべきです。必要なpropsとemitsを明示的に定義し、適切な検証とドキュメンテーションを提供しましょう。

<script setup>
// 明確なインターフェースの例
const props = defineProps({
  // 必要なプロパティを明確に定義
  label: {
    type: String,
    required: true,
    default: '送信'
  },
  loading: {
    type: Boolean,
    default: false
  },
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
  }
})

// 発信するイベントを明確に定義
const emit = defineEmits({
  click: null,
  'focus': null
})
</script>

コンポーネントの構成

小さな単一目的のコンポーネントを組み合わせて、より複雑なUIを構築しましょう。

<!-- 小さなコンポーネントの組み合わせ例 -->
<template>
  <Card>
    <CardHeader>
      <h2>{{ title }}</h2>
    </CardHeader>
    
    <CardBody>
      <TextField
        v-model="username"
        label="ユーザー名"
        required
      />
      <PasswordField
        v-model="password"
        label="パスワード"
        required
      />
    </CardBody>
    
    <CardFooter>
      <Button
        variant="secondary"
        @click="cancel"
      >
        キャンセル
      </Button>
      <Button
        variant="primary"
        :loading="isSubmitting"
        @click="submit"
      >
        送信
      </Button>
    </CardFooter>
  </Card>
</template>

変更の容易性

再利用可能なコンポーネントは、さまざまな状況に対応できるよう、十分に柔軟であるべきです。propsやスロットを使用して、外観や振る舞いをカスタマイズできるようにしましょう。

プレゼンテーションとロジックの分離

コンポーネントは、プレゼンテーション(見た目)とロジック(振る舞い)を明確に分離すべきです。これにより、保守性とテスト容易性が向上します。

<!-- コンポーザブル関数でロジックを分離する例 -->
<script setup>
import { ref } from 'vue'
import { useForm } from './composables/useForm'

// ロジックをコンポーザブル関数に抽出
const { formData, errors, validate, handleSubmit } = useForm({
  initialData: {
    username: '',
    password: ''
  },
  validationRules: {
    username: (value) => !!value || 'ユーザー名は必須です',
    password: (value) => value.length >= 6 || 'パスワードは6文字以上必要です'
  },
  onSubmit: async (data) => {
    // フォーム送信処理
    console.log('送信データ:', data)
  }
})
</script>

<template>
  <!-- プレゼンテーション部分 -->
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label for="username">ユーザー名</label>
      <input
        id="username"
        v-model="formData.username"
        type="text"
        @blur="validate('username')"
      >
      <p v-if="errors.username" class="error">{{ errors.username }}</p>
    </div>
    
    <div class="form-group">
      <label for="password">パスワード</label>
      <input
        id="password"
        v-model="formData.password"
        type="password"
        @blur="validate('password')"
      >
      <p v-if="errors.password" class="error">{{ errors.password }}</p>
    </div>
    
    <button type="submit">送信</button>
  </form>
</template>

10. 実践的な例:再利用可能なフォームコンポーネント

これまで学んだ概念を組み合わせて、再利用可能なフォームコンポーネントを作成してみましょう。

BaseInput.vue

<!-- BaseInput.vue -->
<script setup>
// プロパティの定義
const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  },
  label: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: ''
  },
  type: {
    type: String,
    default: 'text',
    validator: (value) => [
      'text', 'password', 'email', 'number', 'tel', 'url'
    ].includes(value)
  },
  required: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  },
  error: {
    type: String,
    default: ''
  }
})

// イベントの定義
const emit = defineEmits([
  'update:modelValue',
  'blur',
  'focus',
  'input'
])

// 入力値の更新
function updateValue(event) {
  emit('update:modelValue', event.target.value)
}

// ブラーイベントの処理
function handleBlur(event) {
  emit('blur', event)
}

// フォーカスイベントの処理
function handleFocus(event) {
  emit('focus', event)
}
</script>

<template>
  <div class="form-control" :class="{ 'has-error': error }">
    <label v-if="label" :for="label">
      {{ label }}
      <span v-if="required" class="required">*</span>
    </label>
    
    <input
      :id="label"
      :value="modelValue"
      @input="updateValue"
      @blur="handleBlur"
      @focus="handleFocus"
      :type="type"
      :placeholder="placeholder"
      :required="required"
      :disabled="disabled"
      class="input"
    >
    
    <p v-if="error" class="error-message">{{ error }}</p>
  </div>
</template>

<style scoped>
.form-control {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

.required {
  color: #ff4c4c;
  margin-left: 0.25rem;
}

.input {
  width: 100%;
  padding: 0.5rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.has-error .input {
  border-color: #ff4c4c;
}

.error-message {
  margin-top: 0.25rem;
  color: #ff4c4c;
  font-size: 0.875rem;
}
</style>

BaseSelect.vue

<!-- BaseSelect.vue -->
<script setup>
// プロパティの定義
const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  },
  options: {
    type: Array,
    required: true
  },
  label: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: '選択してください'
  },
  required: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  },
  error: {
    type: String,
    default: ''
  },
  valueKey: {
    type: String,
    default: 'value'
  },
  labelKey: {
    type: String,
    default: 'label'
  }
})

// イベントの定義
const emit = defineEmits([
  'update:modelValue',
  'change'
])

// 値の更新
function updateValue(event) {
  emit('update:modelValue', event.target.value)
  emit('change', event.target.value)
}
</script>

<template>
  <div class="form-control" :class="{ 'has-error': error }">
    <label v-if="label" :for="label">
      {{ label }}
      <span v-if="required" class="required">*</span>
    </label>
    
    <select
      :id="label"
      :value="modelValue"
      @change="updateValue"
      :required="required"
      :disabled="disabled"
      class="select"
    >
      <option value="" disabled>{{ placeholder }}</option>
      
      <!-- オプションが単純な配列の場合 -->
      <option
        v-for="option in options"
        :key="typeof option === 'object' ? option[valueKey] : option"
        :value="typeof option === 'object' ? option[valueKey] : option"
      >
        {{ typeof option === 'object' ? option[labelKey] : option }}
      </option>
    </select>
    
    <p v-if="error" class="error-message">{{ error }}</p>
  </div>
</template>

<style scoped>
.form-control {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

.required {
  color: #ff4c4c;
  margin-left: 0.25rem;
}

.select {
  width: 100%;
  padding: 0.5rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background-color: white;
}

.has-error .select {
  border-color: #ff4c4c;
}

.error-message {
  margin-top: 0.25rem;
  color: #ff4c4c;
  font-size: 0.875rem;
}
</style>

BaseForm.vue

<!-- BaseForm.vue -->
<script setup>
// イベントの定義
const emit = defineEmits([
  'submit',
  'reset'
])

// フォーム送信処理
function handleSubmit(event) {
  emit('submit', event)
}

// リセット処理
function handleReset(event) {
  emit('reset', event)
}
</script>

<template>
  <form
    @submit.prevent="handleSubmit"
    @reset.prevent="handleReset"
    class="form"
  >
    <!-- スロットでフォーム要素を受け取る -->
    <slot></slot>
    
    <!-- フッタースロット -->
    <div class="form-footer">
      <slot name="footer">
        <button type="reset" class="btn btn-secondary">リセット</button>
        <button type="submit" class="btn btn-primary">送信</button>
      </slot>
    </div>
  </form>
</template>

<style scoped>
.form {
  max-width: 100%;
}

.form-footer {
  display: flex;
  justify-content: flex-end;
  gap: 1rem;
  margin-top: 1.5rem;
}

.btn {
  padding: 0.5rem 1rem;
  font-size: 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-primary {
  background-color: #42b983;
  color: white;
}

.btn-secondary {
  background-color: #f5f5f5;
  color: #333;
}
</style>

フォームコンポーネントの使用例

<!-- UserForm.vue (実装例) -->
<script setup>
import { ref, reactive } from 'vue'
import BaseForm from './components/BaseForm.vue'
import BaseInput from './components/BaseInput.vue'
import BaseSelect from './components/BaseSelect.vue'

// フォームデータ
const formData = reactive({
  name: '',
  email: '',
  role: '',
  password: ''
})

// バリデーションエラー
const errors = reactive({
  name: '',
  email: '',
  role: '',
  password: ''
})

// 役割オプション
const roleOptions = [
  { value: 'user', label: '一般ユーザー' },
  { value: 'editor', label: '編集者' },
  { value: 'admin', label: '管理者' }
]

// 送信中フラグ
const isSubmitting = ref(false)

// バリデーション関数
function validate() {
  let isValid = true
  
  // 名前の検証
  if (!formData.name.trim()) {
    errors.name = '名前を入力してください'
    isValid = false
  } else {
    errors.name = ''
  }
  
  // メールアドレスの検証
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!formData.email.trim()) {
    errors.email = 'メールアドレスを入力してください'
    isValid = false
  } else if (!emailRegex.test(formData.email)) {
    errors.email = '有効なメールアドレスを入力してください'
    isValid = false
  } else {
    errors.email = ''
  }
  
  // 役割の検証
  if (!formData.role) {
    errors.role = '役割を選択してください'
    isValid = false
  } else {
    errors.role = ''
  }
  
  // パスワードの検証
  if (!formData.password) {
    errors.password = 'パスワードを入力してください'
    isValid = false
  } else if (formData.password.length < 6) {
    errors.password = 'パスワードは6文字以上にしてください'
    isValid = false
  } else {
    errors.password = ''
  }
  
  return isValid
}

// フォーム送信
async function handleSubmit() {
  if (!validate()) {
    return
  }
  
  isSubmitting.value = true
  
  try {
    // APIリクエストをシミュレート
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    console.log('送信データ:', formData)
    alert('ユーザーが正常に登録されました!')
    
    // フォームをリセット
    Object.keys(formData).forEach(key => {
      formData[key] = ''
    })
  } catch (error) {
    console.error('エラー:', error)
  } finally {
    isSubmitting.value = false
  }
}

// フォームリセット
function handleReset() {
  // フォームデータをリセット
  Object.keys(formData).forEach(key => {
    formData[key] = ''
  })
  
  // エラーをリセット
  Object.keys(errors).forEach(key => {
    errors[key] = ''
  })
}
</script>

<template>
  <div class="user-form-container">
    <h2>ユーザー登録</h2>
    
    <BaseForm
      @submit="handleSubmit"
      @reset="handleReset"
    >
      <BaseInput
        v-model="formData.name"
        label="名前"
        placeholder="山田太郎"
        required
        :error="errors.name"
      />
      
      <BaseInput
        v-model="formData.email"
        label="メールアドレス"
        type="email"
        placeholder="example@example.com"
        required
        :error="errors.email"
      />
      
      <BaseSelect
        v-model="formData.role"
        :options="roleOptions"
        label="役割"
        required
        :error="errors.role"
      />
      
      <BaseInput
        v-model="formData.password"
        label="パスワード"
        type="password"
        required
        :error="errors.password"
      />
      
      <template #footer>
        <button type="reset" class="btn btn-secondary">
          クリア
        </button>
        <button
          type="submit"
          class="btn btn-primary"
          :disabled="isSubmitting"
        >
          {{ isSubmitting ? '送信中...' : '登録' }}
        </button>
      </template>
    </BaseForm>
  </div>
</template>

<style scoped>
.user-form-container {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h2 {
  margin-top: 0;
  margin-bottom: 1.5rem;
  color: #333;
}

.btn {
  padding: 0.5rem 1rem;
  font-size: 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.btn-primary {
  background-color: #42b983;
  color: white;
}

.btn-primary:disabled {
  background-color: #8ece8e;
  cursor: not-allowed;
}

.btn-secondary {
  background-color: #f5f5f5;
  color: #333;
}
</style>

11. まとめ

本章では、Vue.jsのコンポーネントシステムについて学びました。単一ファイルコンポーネント(SFC)の構造と利点、Propsを使用したデータの受け渡し、Emitsを使用したイベントの発信方法について理解を深めました。また、スロットを使用したコンテンツの配信、コンポーネントのライフサイクル、そして再利用可能なコンポーネントの設計についても学びました。

これらの概念を組み合わせることで、保守性が高く、再利用可能なコンポーネントを作成できます。実践的な例として、フォームコンポーネントを実装し、実際のアプリケーション開発でどのようにコンポーネントを組み合わせるかを学びました。

次の章では、Vue Routerを使用した画面遷移について学びます。

目次に戻る