Vue 3 のコンポーネント間通信:props と emits の完全ガイド
コンポーネント間の通信は、Vueの中核となる仕組みです。親コンポーネントはpropsを介して子コンポーネントにデータを渡し、子コンポーネントはemitsを使用してイベントをトリガーし、親コンポーネントに通知します。双方向のデータフローを理解することは、優れたVueアプリケーションを構築するための鍵となります。
Vue 3の<script setup>は、definePropsとdefineEmitsという2つのコンパイル時マクロを提供しており、これらはインポートせずに使用可能で、TypeScriptの型推論を完全にサポートしています。
1. 学習内容
- props の完全な構文:型、必須フィールド、デフォルト値、バリデータ
- props を宣言する複数の方法(ランタイム / 型 / 簡略化)
- イベント宣言および検証を発行する
- TypeScriptの型推論 (
defineProps<T>()) - プロップスのパススルー(inheritAttrs / useAttrs)
- 一方向データフローの原則
- よくある5つの間違いとベストプラクティス
2. Eコマースのショッピングカートにおける「双方向バインディング」の落とし穴
(1) 課題:子コンポーネントがプロパティを変更すると、親コンポーネントがエラーをスローしてしまう
アリスは、数量を入力できるカートを実装しました:
VUE
<!-- CartItem.vue Child component -->
<template>
<input :value="quantity" @input="quantity = $event.target.value">
</template>
<script setup>
// ❌ The "Flip" Version:Edit directly prop
const props = defineProps({ quantity: Number })
function update() {
props.quantity++ // ❌ Vue Warning:Avoid making direct changes prop
}
</script>
Vue でエラーが発生します:
[Vue warn] Set operation on key "quantity" failed: target is readonly.
1時間デバッグを行った結果、チャーリーは次のことを発見した:
「アリス、数量を変更しても親コンポーネントのカート合計が更新されないんだ。Vueはプロパティを変更しているのに、親コンポーネントがそれを認識していないみたい。」
(2) Vueの一方向データフロー + emit の解決策
VUE
<!-- CartItem.vue Child component -->
<template>
<div>
<input :value="quantity" @input="$emit('update:quantity', +$event.target.value)">
</div>
</template>
<script setup>
const props = defineProps({ quantity: Number })
const emit = defineEmits(['update:quantity'])
</script>
VUE
<!-- Cart.vue Parent Component -->
<template>
<CartItem
v-for="item in items"
:key="item.id"
:quantity="item.quantity"
@update:quantity="(val) => item.quantity = val"
/>
</template>
これで変更が正しく反映されるようになりました:子ノードが変更を送信 → 親ノードが受信 → 親ノードがデータを更新 → 子ノードへ再送信。
(3) 収益
データフローを修正した後:
- 閲覧警告:1日あたり100回 → 0回
- データの整合性:親コンポーネントとのリアルタイム同期
- デバッグのしやすさ:データの流れが一方向であるため、トレースが容易です
- テスト可能性:コンポーネントは、props や emit について個別にテストすることができます
3. props の完全な構文
(1) 7種類のプロップに関する宣言
VUE
<script setup>
defineProps({
// 1. Basic Types
name: String,
age: Number,
active: Boolean,
// 2. Complex Types
user: Object,
items: Array,
// 3. Multiple Types(Any)
id: [String, Number],
// 4. Required
title: { type: String, required: true },
// 5. Default value
pageSize: { type: Number, default: 20 },
// 6. Custom Validation
email: {
type: String,
validator: (val) => val.includes('@')
},
// 7. Function Default Values(The default value must be a function.)|
createdAt: {
type: Date,
default: () => new Date()
}
})
</script>
(2) 詳細な例
VUE
<script setup>
defineProps({
// String
title: String,
// Numbers + Default value + Verification
pageSize: {
type: Number,
default: 20,
validator: (val) => val > 0 && val <= 100
},
// Boolean(Note:The default value for falsy Values should be in function form)
isVip: {
type: Boolean,
default: false
},
// Array Default Values(It must be returned as a function)
tags: {
type: Array,
default: () => ['vue', 'javascript']
},
// Object Default Values(It must be returned as a function)
user: {
type: Object,
default: () => ({ name: 'Guest', age: 0 })
},
// Required + Custom Validation
email: {
type: String,
required: true,
validator: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
}
})
</script>
(3) 6つのデフォルト値に関するルール
| ルール | 例 |
|---|---|
| 基本型 | default: 20 |
| 配列/オブジェクト | default: () => [] |
| null | default: null |
| 未定義 | デフォルトは不要 |
| 関数リファレンス | default: fn は使用せず、() => fn() を使用してください |
| Symbol / BigInt | ファクトリ関数が返す値 |
4. TypeScript の強タイププロップ(Vue 3 での使用を推奨)
(1) プリミティブ型の宣言
VUE
<script setup lang="ts">
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
defineProps<{
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}>()
</script>
(2) 必須 vs. 任意 (?)
TS
defineProps<{
// Required(Default)
user: User
pageSize: number
// Optional (add ?)
variant?: 'primary' | 'secondary'
showIcon?: boolean
}>()
(3) withDefaults はデフォルト値を提供します
VUE
<script setup lang="ts">
interface Props {
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
showEmail: true
})
</script>
(4) 実行時宣言と型宣言の比較
| 次元 | 実行時間 defineProps({}) |
タイプ defineProps<T>() |
|---|---|---|
| 型推論 | 弱 | 強(IDEの自動補完) |
| デフォルト値 | 直接のデフォルト値 | withDefaults ラッパー |
| 検証 | バリデータ関数 | TypeScriptのコンパイル時チェック |
| おすすめ | 小規模プロジェクト/純粋なJS | 大規模プロジェクト/TSプロジェクト |
5. emit の完全な構文
(1) イベント文の5つの種類
VUE
<script setup>
// 1. A Brief Statement
const emit = defineEmits(['click', 'submit', 'cancel'])
// 2. Parameter Validation
const emit = defineEmits({
addToCart: (productId) => typeof productId === 'number',
remove: (id) => typeof id === 'string'
})
// 3. TypeScript Type
const emit = defineEmits<{
'add-to-cart': [productId: number]
'remove-item': [id: string, reason: string]
'update': [id: number, data: object]
}>()
</script>
(2) 5つのトリガー手法
VUE
<script setup>
const emit = defineEmits(['click', 'submit', 'add-to-cart'])
// 1. Simple Trigger
emit('click')
// 2. Passing Parameters
emit('add-to-cart', 123)
// 3. Pass multiple parameters
emit('submit', { name: 'Alice' }, 2026)
// 4. Conditional Trigger
if (isValid) emit('submit')
// 5. Functional Triggering
function handleAdd() {
emit('add-to-cart', props.product.id)
}
</script>
(3) 親コンポーネントのリスニング
VUE
<template>
<ChildComponent
@click="handleClick"
@add-to-cart="handleAdd"
@submit="handleSubmit"
/>
</template>
<script setup>
function handleClick() { /* No parameters */ }
function handleAdd(productId) { /* 1 parameter */ }
function handleSubmit(data, year) { /* Multiple parameters */ }
</script>
(4) emit の型検証
VUE
<script setup>
const emit = defineEmits({
// ✅ Verification:id It must be number
remove: (id) => typeof id === 'number' || 'Validation failed',
// ❌ Throws Error: When parameters do not match
// remove: 'invalid', // Wrong: It must be a function
})
</script>
6. データの一方向フローの原則
(1) 基本原則
graph LR
A[Parent Component data] -->|props| B[Child component]
B -->|emit| A
style A fill:#42b883
style B fill:#42b883
- データは親から子へしか流れない(プロパティは下方向に流れる)
- イベントは子から親へとしか伝播しない(上方向へのエミッション)
- 子コンポーネントは props を直接変更することはできません
(2) 5つの例外
VUE
<script setup>
const props = defineProps({ user: Object })
// ❌ Wrong: Edit prop directly
props.user.name = 'Bob' // Vue Warning!
// ✅ Exception 1:prop When it is a reference type,Can be replaced with a new object
// The parent component passes a reference,Child components cannot change their references,But it's possible emit Let Father Change It
// ✅ Exception 2:prop Initial values can be saved locally
const localUser = ref({ ...props.user })
// Then make the changes localUser(Does not affect the parent)
// ✅ Exception 3: Use computed Derivative
const userName = computed(() => props.user.name)
// ✅ Exception 4: Use v-model (Syntax Sugar)
// <Child v-model="value" /> equivalent to :value + @update:value
// ✅ Exception 5:provide/inject Across levels
// Parent provide, Child inject (Learned in Phase 2.5)
</script>
(3) 5つのアンチパターン
JS
// ❌ Anti-pattern 1:Edit directly prop
props.user.name = 'Bob'
// ❌ Anti-pattern 2:Use in child components watch Edit prop
watch(() => props.value, (val) => { props.value = val * 2 })
// ❌ Anti-pattern 3: Two-way bound ref passthrough (Not recommended)
const localRef = ref(props.value)
watch(localRef, (val) => emit('update', val))
// ❌ Anti-pattern 4:Usage v-model Does not follow the naming convention
// v-model Expectations update:xxx Event,Custom event names must be consistent
emit('change', val) // ❌ Parent Component @change Will not be triggered
emit('update:value', val) // ✅ v-model Supporting
// ❌ Anti-pattern 5:Managing the parent component's data directly within a child component
emit('update', { ...props.user, name: 'Bob' }) // Wrong: Should only pass ID, Parent updates itselff
7. プロパティのパススルー:inheritAttrs と useAttrs
(1) デフォルトの動作
VUE
<!-- Parent.vue -->
<template>
<ChildComponent class="parent-class" :title="title" />
</template>
<!-- ChildComponent.vue - By default, messages are received automatically. class="parent-class" -->
<template>
<div> <!-- Automatically available class="parent-class" -->
<h3>{{ title }}</h3>
</div>
</template>
(2) 自動パススルーを無効にする
VUE
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<!-- class It will not be automatically applied to the root element -->
<div class="my-class">
<h3>Title</h3>
</div>
</template>
(3) useAttrsの明示的な使用
VUE
<script setup>
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>
<template>
<div>
<input v-bind="attrs" />
<!-- Pass the value from the parent component class/style/Manually bind other properties to input -->
</div>
</template>
(4) 5つのユースケース
| シナリオ | 方法 |
|---|---|
| デフォルト | 何もする必要はありません。クラスはルートに自動的に適用されます |
| カスタムルート要素 | useAttrs() 明示的なバインディング |
| ルート以外の要素へ展開 | inheritAttrs: false + v-bind="$attrs" |
| 複数のコンポーネント(フラグメント) | inheritAttrs: false および明示的なバインディングを使用する必要がある |
| パフォーマンスの最適化 | 不要なDOM属性を回避するためにパススルーを無効にする |
8. 完全な例:Eコマースの商品カードの props/emits
(1) ▶ サンプル:. ProductCard.vue—props/emits の完全な記述
VUE
<!-- src/components/ProductCard.vue -->
<template>
<div :class="['product-card', { 'out-of-stock': !inStock }]">
<img :src="product.image" :alt="product.name">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<span v-if="lowStock" class="badge">Only {{ product.stock }} left</span>
<button :disabled="!inStock" @click="handleAddToCart">
{{ inStock ? 'Add to Cart' : 'Out of Stock' }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Product {
id: number
name: string
price: number
stock: number
image: string
}
// props Complete Definition
const props = defineProps({
product: {
type: Object as () => Product,
required: true,
validator: (val: Product) => val.id && val.name
},
showStockBadge: { type: Boolean, default: true }
})
// emits Complete Definition
const emit = defineEmits({
'add-to-cart': (productId: number) => typeof productId === 'number',
'quick-view': (productId: number) => typeof productId === 'number',
'toggle-favorite': (productId: number, isFavorite: boolean) =>
typeof productId === 'number' && typeof isFavorite === 'boolean'
})
// Computed Properties
const inStock = computed(() => props.product.stock > 0)
const lowStock = computed(() => props.product.stock > 0 && props.product.stock < 10)
// Event Handling
function handleAddToCart() {
if (inStock.value) {
emit('add-to-cart', props.product.id)
}
}
</script>
<style scoped>
.product-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
transition: all 0.2s;
}
.product-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.out-of-stock { opacity: 0.6; }
.badge { background: #f59e0b; color: white; padding: 2px 8px; border-radius: 4px; }
.price { color: #42b883; font-weight: bold; }
</style>
(2) ▶ サンプル:. ProductCard を使用する親コンポーネント
VUE
<!-- src/views/ProductListView.vue -->
<template>
<div class="grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
:show-stock-badge="true"
@add-to-cart="handleAddToCart"
@quick-view="handleQuickView"
@toggle-favorite="handleToggleFavorite"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProductCard from '@/components/ProductCard.vue'
import { useCart } from '@/composables/useCart'
const products = ref([
{ id: 1, name: 'iPhone', price: 999, stock: 50, image: 'iphone.jpg' },
{ id: 2, name: 'MacBook', price: 2499, stock: 0, image: 'macbook.jpg' }
])
const { addToCart } = useCart()
function handleAddToCart(productId) {
addToCart(productId)
}
function handleQuickView(productId) {
console.log('Quick view:', productId)
}
function handleToggleFavorite(productId, isFavorite) {
console.log('Toggle:', productId, isFavorite)
}
</script>
(3) ▶ サンプル:. v-model による双方向バインディング
VUE
<!-- Child component CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<!-- Using Parent Components v-model -->
<template>
<CustomInput v-model="searchQuery" />
<p>Query: {{ searchQuery }}</p>
</template>
<script setup>
import { ref } from 'vue'
import CustomInput from '@/components/CustomInput.vue'
const searchQuery = ref('')
</script>
(4) ▶ サンプル:. useAttrs のパススルー
VUE
<!-- CustomInput.vue - Multi-element components -->
<template>
<!-- Multiple elements must be used with inheritAttrs: false -->
<label>{{ label }}</label>
<input v-bind="$attrs" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>
<script setup>
defineOptions({ inheritAttrs: false })
defineProps({
modelValue: String,
label: String
})
defineEmits(['update:modelValue'])
</script>
<!-- Parent Component -->
<CustomInput v-model="name" label="Name" placeholder="Enter name..." class="input-field" />
<!-- Placeholder and class will be auto-applied to input (No label) -->
(5) ▶ サンプル:. よくある5つの間違いに関するクイックリファレンス
| エラー | 症状 | 解決策 |
|---|---|---|
| プロパティを直接変更 | Vueの警告 | emit を使用して親が変更できるようにする |
| 配列/オブジェクト:propsには参照としてのデフォルト値が設定される | 複数のコンポーネントで共有される | default: () => [] ファクトリ |
| v-model のイベント名の不一致 | 親要素にイベントが伝わりません | update:xxx を使用してください |
| バリデータは文字列を返す | Vueはエラーを発生させない | true/falseを返す |
| 複数のコンポーネントへのパススルー | Vueの警告 | inheritAttrs: false |
(6) ▶ サンプル:. props の 5 つの主要な検証シナリオ
| シナリオ | 検証 |
|---|---|
| メールアドレス | val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) |
| URL | val => /^https?:\/\//.test(val) |
| 列挙値 | val => ['a', 'b', 'c'].includes(val) |
| 範囲 | val => val >= 0 && val <= 100 |
| カスタムオブジェクト | val => val.id && val.name |
❓ よくある質問
Q プロップを直接変更することはできますか?
A いいえ。Vueの一方向データフローの原則に従い、プロップは読み取り専用です。データを変更するには、子コンポーネントがイベントを発行し、親コンポーネントがその変更を行う必要があります。プロップを直接変更すると、Vueから警告が表示されます。
Q 複雑なデータ(ネストされたオブジェクト)をどのように渡せばよいですか?
A 参照を直接渡してください。Vueが自動的にディープバインディングを処理します。オブジェクト内のプロパティを変更すると、親コンポーネントはその変更を検知します(ディープバインディング)。ただし、子コンポーネントはそれを直接変更してはいけません。代わりに、
emit を使用して、親コンポーネントにオブジェクト全体またはプロパティを更新させる必要があります。Q
defineProps オプションと props オプションの違いは何ですか?A
<script setup> では defineProps() マクロを使用します(インポートは不要)。Options API では props: {} オプションを使用します。これら2つは同等ですが、setup + defineProps の使用をお勧めします。Q
emit は子コンポーネント内で非同期に呼び出すことはできますか?A はい。
emit は同期関数呼び出しですが、setTimeout や Promise: setTimeout(() => emit('done'), 1000) でラップすることができます。Q
v-modelの本質は何ですか?A これは
propsおよびemitのための構文糖です。<Child v-model="x" />は:modelValue="x" @update:modelValue="x = $event"と同等です。詳しい説明については、このレッスンの例3を参照してください。Q 子コンポーネントのプロパティの変更を監視するにはどうすればよいですか?
A
watch(() => props.xxx, (newVal, oldVal) => {...}) を使用してください。監視処理内でプロパティを変更しないように注意してください。そうしないと、無限ループに陥ってしまいます。Q
useAttrs はどのような場合に使用すべきですか?A 以下の 3 つのケースです:(1) マルチルートコンポーネント(フラグメント)で必要とされる場合;(2) 非ルート要素に属性を渡したい場合;(3) 属性がバインドされる場所を明示的に制御したい場合。通常のシングルルートコンポーネントでは必要ありません。
Q プロパティのバリデーションに失敗した場合はどうなりますか?
A 開発環境では、Vue は警告を表示します。本番環境では、エラーは黙って処理されます。バリデータは
false(エラー)または文字列(エラーメッセージ)を返すことを推奨します。📖 まとめ
- プロップス:親から子へ渡す場合は
defineProps()を、子から親へ渡す場合はdefineEmits()を使用してください - プロップの7種類:基本 / 複合 / 複数タイプ / 必須 / デフォルト / バリデーション / ファクトリ
- TypeScript では、厳密な型付けには
defineProps<T>()を、デフォルト値付きにはwithDefaults()を使用します - 5種類のトリガー + 型の検証
- 一方向データフローの原則:データは下方向に流れ、イベントは上方向に流れる
- 5つのアンチパターン:propsの直接変更 /
watch経由での props の変更 / 双方向参照によるデータ漏洩 / v-model のイベント名の不一致 / 子要素による親要素のデータ管理 - プロパティのパススルーにおける
inheritAttrs/useAttrs v-modelは、propsおよびemitの構文糖です。
📝 練習問題
(1) 基礎問題(難易度:⭐)
簡単なButtonコンポーネントを実装します:
- props:
text(文字列),variant(文字列, 'primary'/'success'/'danger'),disabled(ブール値) - 3つのバリエーションは、3つのCSSクラスに対応しています
clickイベントを発行する(パラメータなし)- 親コンポーネント内の3つのバリエーションをテストする
(2) 上級問題(難易度:⭐⭐)
双方向バインドされた Input コンポーネントを実装する:
- 子コンポーネント CustomInput.vue: props:
modelValue,label v-modelという構文糖衣の使用- emit
update:modelValue - 親コンポーネント:ユーザー入力をリアルタイムで表示します
(3) チャレンジ問題(難易度:⭐⭐⭐)
包括的な「商品カード+お気に入り」システムを導入する:
- ProductCard.vue:すべてのプロパティ(product/showStockBadge/maxStock)+エミット(add-to-cart/toggle-favorite)
- App.vue:商品一覧 + お気に入りステータス
- お気に入りボタン:
toggle-favoriteを発行すると、親コンポーネントがお気に入りのステータスを更新します useAttrsおよびinheritAttrsを使用して、マルチルートコンポーネントを実装する(演習の内容を損なわない範囲で)- 少なくとも3人のバリデーターによる検証(メールアドレス/URL/列挙型)
- TypeScriptの厳密型付けされたプロップ



