
Vue.js コンポーネント間通信(Props & Emit)演習
2025-07-29PropsとEmitのデータフロー解説
1. 親から子へのデータ渡し(Props)
基本概念
- 一方向データフロー: 親 → 子のみの流れ
- 宣言的受け取り: 子コンポーネントで
props
オプションに明示的に宣言 - 型チェック: データ型を指定可能(String, Number, Objectなど)
データフロー図
[親コンポーネント]
└─ :prop-name="data" →
[子コンポーネント]
└─ props: ['propName']で受取
コード例
// 親コンポーネント
<template>
<Child :message="parentMsg" />
</template>
<script>
export default {
data() {
return {
parentMsg: "Hello from parent"
}
}
}
</script>
// 子コンポーネント
<template>
<div>{{ message }}</div>
</template>
<script>
export default {
props: ['message']
}
</script>
2. 子から親へのイベント発行(Emit)
基本概念
- イベント駆動: 子がイベントを発火、親がリスニング
- カスタムイベント: 任意の名前でイベントを作成可能
- データ付与: イベントと一緒にデータを送信可能
データフロー図
[子コンポーネント]
└─ this.$emit('event') →
[親コンポーネント]
└─ @event="handler"で受取
コード例
// 子コンポーネント
<template>
<button @click="notifyParent">通知</button>
</template>
<script>
export default {
methods: {
notifyParent() {
this.$emit('child-event', 'データ付き')
}
}
}
</script>
// 親コンポーネント
<template>
<Child @child-event="handleEvent" />
</template>
<script>
export default {
methods: {
handleEvent(data) {
console.log(data) // "データ付き"
}
}
}
</script>
3. PropsとEmitの組み合わせ
双方向バインディングパターン
// 子コンポーネント
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script>
export default {
props: ['modelValue'],
emits: ['update:modelValue']
}
</script>
// 親コンポーネント
<template>
<CustomInput v-model="inputText" />
</template>
<script>
export default {
data() {
return {
inputText: ''
}
}
}
</script>
演習問題
初級問題(6問)
問題1
親からtitle
というpropsを受け取り、h1
要素で表示する子コンポーネントを作成してください。
問題2
ボタンクリックでincrement
イベントを発行する子コンポーネントを作成し、親でこのイベントを受け取ってカウンターを増加させるコードを書いてください。
問題3
以下の子コンポーネントのprops定義を修正してください(バリデーション追加):
props: {
age: Number
}
問題4
親からitems
配列(文字列のリスト)を受け取り、ul
要素で表示する子コンポーネントを作成してください。
問題5
子コンポーネントでv-model
互換の入力フィールドを作成してください(親ではv-model="text"
で使用可能に)。
問題6
イベント発行時に複数の引数(name, age)を親に渡す子コンポーネントを作成してください。
中級問題(12問)
問題7
動的に変更可能なスロット名を持つコンポーネントを作成してください。
問題8
親から関数をpropsで受け取り、子で実行するコンポーネントを作成してください。
問題9
孫コンポーネントから祖父母コンポーネントにイベントを伝播させるコードを書いてください。
問題10
provide
/inject
を使用して、深くネストされたコンポーネントにテーマデータを渡してください。
問題11
非同期でデータを読み込むコンポーネントを作成し、読み込み中と完了時にイベントを発行してください。
問題12
カスタムバリデーションを持つprops(郵便番号形式の文字列)を定義してください。
問題13
子コンポーネントのライフサイクルフックでイベントを発行するコードを作成してください。
問題14
動的コンポーネントを使用して、2つのコンポーネントを切り替えるUIを作成してください。
問題15
スコープ付きスロットを使用して、子のデータを親のテンプレートで表示してください。
問題16
v-model
を複数持つカスタムフォームコンポーネントを作成してください。
問題17
イベントバスを使用して、兄弟コンポーネント間で通信するコードを書いてください。
問題18
関数型コンポーネントでイベントを発行するボタンコンポーネントを作成してください。
上級問題(6問)
問題19
レンダー関数を使用してカスタムイベントを発行するコンポーネントを作成してください。
問題20
プラグインとして登録可能なグローバルコンポーネントを作成してください。
問題21
Teleport
を使用したモーダルコンポーネントで、閉じるイベントを親に伝えるコードを書いてください。
問題22
コンポジションAPIでuseEventEmitter
コンポーザブルを作成してください。
問題23
カスタムディレクティブで要素の可視状態を親に通知するコードを書いてください。
問題24
Vuex/Piniaを使用せずに、propsとemitだけで状態管理を実装してください。
解答例
初級解答
解答1
// 子コンポーネント
<template>
<h1>{{ title }}</h1>
</template>
<script>
export default {
props: ['title']
}
</script>
解答2
// 子コンポーネント
<template>
<button @click="$emit('increment')">+1</button>
</template>
// 親コンポーネント
<template>
<Child @increment="count++" />
<p>Count: {{ count }}</p>
</template>
<script>
export default {
data() {
return {
count: 0
}
}
}
</script>
解答3
props: {
age: {
type: Number,
required: true,
validator: value => value >= 0 && value < 120
}
}
解答4
// 子コンポーネント
<template>
<ul>
<li v-for="(item, index) in items" :key="index">
{{ item }}
</li>
</ul>
</template>
<script>
export default {
props: ['items']
}
</script>
解答5
// 子コンポーネント
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script>
export default {
props: ['modelValue'],
emits: ['update:modelValue']
}
</script>
解答6
// 子コンポーネント
methods: {
sendData() {
this.$emit('user-data', '山田太郎', 30)
}
}
// 親コンポーネント
<Child @user-data="(name, age) => { userName = name; userAge = age }" />
Vue.js 中級・上級演習問題の詳細解答
中級問題の解答
問題7: 動的スロット名
<template>
<div>
<slot :name="slotName"></slot>
</div>
</template>
<template>
<DynamicSlot :slot-name="currentSlot">
<template v-slot:[currentSlot]>
動的スロットコンテンツ
</template>
</DynamicSlot>
<button @click="toggleSlot">スロット切り替え</button>
</template>
<script>
export default {
props: {
slotName: {
type: String,
default: 'default'
}
}
}
</script>
<script>
export default {
data() {
return {
currentSlot: 'header'
}
},
methods: {
toggleSlot() {
this.currentSlot = this.currentSlot === 'header' ? 'footer' : 'header'
}
}
}
</script>
問題8: 親から関数をpropsで受け取り
<template>
<button @click="executeParentFunction">
親の関数を実行
</button>
</template>
<template>
<FunctionReceiver :parent-function="handleChildCall" />
</template>
<script>
export default {
props: {
parentFunction: {
type: Function,
required: true
}
},
methods: {
executeParentFunction() {
const result = this.parentFunction('子からデータ')
console.log(result)
}
}
}
</script>
<script>
export default {
methods: {
handleChildCall(data) {
alert(`親が受け取ったデータ: ${data}`)
return '親からの返却値'
}
}
}
</script>
問題9: 孫から祖父母へのイベント伝播
<template>
<button @click="$emit('deep-event', '孫からのデータ')">
祖父母に通知
</button>
</template>
<template>
<Grandchild @deep-event="$emit('deep-event', $event)" />
</template>
<template>
<Child @deep-event="handleDeepEvent" />
</template>
<script>
export default {
methods: {
handleDeepEvent(data) {
console.log(`祖父母が受け取ったデータ: ${data}`)
}
}
}
</script>
問題10: provide/inject によるテーマデータの受け渡し
<template>
<button :style="buttonStyle">
<slot></slot>
</button>
</template>
<script>
import { inject } from 'vue'
export default {
setup() {
const theme = inject('theme')
const buttonStyle = {
backgroundColor: theme.colors.primary,
color: theme.mode === 'dark' ? '#fff' : '#333',
padding: '8px 16px'
}
return {
buttonStyle
}
}
}
</script>
<!-- 祖先コンポーネント (ThemeProvider.vue) はJavaScriptのみなのでHTMLエスケープ不要 -->
<script>
import { provide } from 'vue'
export default {
setup() {
provide('theme', {
mode: 'dark',
colors: {
primary: '#42b983',
secondary: '#35495e'
}
})
}
}
</script>
問題11: 非同期データ読み込みコンポーネント
<template>
<div v-if="loading">読み込み中...</div>
<div v-else-if="error">エラーが発生しました</div>
<slot v-else :data="data"></slot>
</template>
<script>
export default {
props: {
url: {
type: String,
required: true
}
},
data() {
return {
loading: false,
error: null,
data: null
}
},
emits: ['loading', 'loaded', 'error'],
async created() {
try {
this.loading = true
this.$emit('loading')
const response = await fetch(this.url)
this.data = await response.json()
this.$emit('loaded', this.data)
} catch (err) {
this.error = err
this.$emit('error', err)
} finally {
this.loading = false
}
}
}
</script>
問題12: 郵便番号形式のバリデーション
export default {
props: {
postalCode: {
type: String,
required: true,
validator(value) {
// 日本郵便番号形式 (123-4567 または 1234567)
return /^\d{3}-?\d{4}$/.test(value)
}
}
}
}
問題13: ライフサイクルフックでのイベント発行
<template>
<div>ライフサイクルイベントを監視するコンポーネント</div>
</template>
<template>
<LifecycleEmitter
@mounted="handleLifecycleEvent"
@updated="handleLifecycleEvent"
@before-unmount="handleLifecycleEvent"
/>
<button @click="toggleComponent">コンポーネント切り替え</button>
</template>
<script>
export default {
emits: ['mounted', 'updated', 'before-unmount'],
mounted() {
this.$emit('mounted', {
message: 'コンポーネントがマウントされました',
timestamp: new Date()
})
},
updated() {
this.$emit('updated', {
message: 'コンポーネントが更新されました',
updateCount: this.$options.updateCount = (this.$options.updateCount || 0) + 1
})
},
beforeUnmount() {
this.$emit('before-unmount', {
message: 'コンポーネントがアンマウントされます',
lifespan: Date.now() - this.$options.mountTime
})
},
created() {
this.$options.mountTime = Date.now()
this.$options.updateCount = 0
}
}
</script>
<script>
export default {
data() {
return {
showComponent: true
}
},
methods: {
toggleComponent() {
this.showComponent = !this.showComponent
},
handleLifecycleEvent(event) {
console.log('ライフサイクルイベント:', event)
}
}
}
</script>
問題14: 動的コンポーネントの切り替え
<template>
<div class="component-a">
<h2>コンポーネントA</h2>
<p>これは動的に表示されるコンポーネントAです</p>
</div>
</template>
<template>
<div class="component-b">
<h2>コンポーネントB</h2>
<p>これは動的に表示されるコンポーネントBです</p>
<button @click="$emit('special-event')">特別イベント</button>
</div>
</template>
<template>
<div>
<button @click="currentComponent = 'ComponentA'">Aを表示</button>
<button @click="currentComponent = 'ComponentB'">Bを表示</button>
<keep-alive>
<component
:is="currentComponent"
@special-event="handleSpecialEvent"
/>
</keep-alive>
<p>現在表示中: {{ currentComponent }}</p>
</div>
</template>
// ComponentA.vue
// ...(省略、上記のまま)
// ComponentB.vue
// ...(省略、上記のまま)
// DynamicSwitcher.vue
<script>
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
export default {
components: { ComponentA, ComponentB },
data() {
return {
currentComponent: 'ComponentA'
}
},
methods: {
handleSpecialEvent() {
alert('コンポーネントBから特別なイベントが発行されました!')
}
}
}
</script>
<style>
.component-a {
background-color: #f0f8ff;
padding: 20px;
margin: 10px 0;
}
.component-b {
background-color: #fff0f5;
padding: 20px;
margin: 10px 0;
}
</style>
問題15: スコープ付きスロットの実装
<template>
<div>
<slot
:items="items"
:addItem="addItem"
:removeItem="removeItem"
>
<!-- デフォルトスロットコンテンツ -->
<ul>
<li v-for="(item, index) in items" :key="index">
{{ item }}
</li>
</ul>
</slot>
</div>
</template>
<template>
<DataProvider v-slot="{ items, addItem, removeItem }">
<div class="custom-renderer">
<h3>カスタムアイテムリスト</h3>
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
<button @click="removeItem(index)">削除</button>
</div>
<input
v-model="newItem"
@keyup.enter="addNewItem(addItem)"
placeholder="新しいアイテムを追加"
>
</div>
</DataProvider>
</template>
<script>
// DataProvider.vue(子コンポーネント)
export default {
data() {
return {
items: ['アイテム1', 'アイテム2', 'アイテム3']
}
},
methods: {
addItem(newItem) {
this.items.push(newItem)
},
removeItem(index) {
this.items.splice(index, 1)
}
}
}
// 親コンポーネント
export default {
data() {
return {
newItem: ''
}
},
methods: {
addNewItem(addItem) {
if (this.newItem.trim()) {
addItem(this.newItem)
this.newItem = ''
}
}
}
}
</script>
<style>
.custom-renderer {
border: 1px solid #ddd;
padding: 20px;
max-width: 400px;
}
.item {
display: flex;
justify-content: space-between;
margin: 5px 0;
padding: 5px;
background: #f5f5f5;
}
</style>
問題16: 複数のv-modelを持つフォームコンポーネント
<template>
<div class="multi-form">
<div class="form-group">
<label>ユーザー名:</label>
<input
type="text"
:value="username"
@input="$emit('update:username', $event.target.value)"
>
</div>
<div class="form-group">
<label>メールアドレス:</label>
<input
type="email"
:value="email"
@input="$emit('update:email', $event.target.value)"
>
</div>
<div class="form-group">
<label>サブスクリプション:</label>
<input
type="checkbox"
:checked="subscribed"
@change="$emit('update:subscribed', $event.target.checked)"
>
</div>
</div>
</template>
<template>
<div>
<MultiVModelForm
v-model:username="formData.username"
v-model:email="formData.email"
v-model:subscribed="formData.subscribed"
/>
<div class="form-preview">
<h3>フォームデータのプレビュー</h3>
<pre>{{ formData }}</pre>
</div>
</div>
</template>
<script>
// 子コンポーネント
export default {
props: {
username: String,
email: String,
subscribed: Boolean
},
emits: ['update:username', 'update:email', 'update:subscribed']
}
// 親コンポーネント
export default {
data() {
return {
formData: {
username: '',
email: '',
subscribed: false
}
}
}
}
</script>
</style>
.multi-form {
border: 1px solid #eee;
padding: 20px;
max-width: 400px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input[type="text"],
.form-group input[type="email"] {
width: 100%;
padding: 8px;
}
</style>
問題17: イベントバスを使った兄弟コンポーネント間通信
<template>
<div class="sender">
<h3>送信側コンポーネント</h3>
<input v-model="message" placeholder="メッセージを入力">
<button @click="sendMessage">送信</button>
</div>
</template>
<template>
<div class="receiver">
<h3>受信側コンポーネント</h3>
<div v-if="messages.length === 0">
メッセージがまだありません
</div>
<ul v-else>
<li v-for="(msg, index) in messages" :key="index">
{{ msg.text }} ({{ formatTime(msg.timestamp) }})
</li>
</ul>
</div>
</template>
<template>
<div class="sibling-communication">
<SenderComponent />
<ReceiverComponent />
</div>
</template>
<script>
// eventBus.js
import Vue from 'vue'
export const EventBus = new Vue()
// SenderComponent.vue
import { EventBus } from './eventBus'
export default {
data() {
return {
message: ''
}
},
methods: {
sendMessage() {
if (this.message.trim()) {
EventBus.$emit('message-sent', {
text: this.message,
timestamp: new Date()
})
this.message = ''
}
}
}
}
// ReceiverComponent.vue
import { EventBus } from './eventBus'
export default {
data() {
return {
messages: []
}
},
created() {
EventBus.$on('message-sent', this.handleNewMessage)
},
beforeUnmount() {
EventBus.$off('message-sent', this.handleNewMessage)
},
methods: {
handleNewMessage(message) {
this.messages.push(message)
},
formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString()
}
}
}
// 親コンポーネント
export default {
components: { SenderComponent, ReceiverComponent }
}
</script>
<style>
.sender, .receiver {
border: 1px solid #ddd;
padding: 20px;
margin: 10px;
max-width: 400px;
}
.receiver ul {
list-style: none;
padding: 0;
}
.receiver li {
padding: 5px;
border-bottom: 1px solid #eee;
}
</style>
上級問題の解答
問題19: レンダー関数でカスタムイベント発行
export default {
render() {
return this.$createElement('button', {
on: {
click: () => this.$emit('custom-event', 'レンダー関数からのデータ')
}
}, 'カスタムイベントを発行')
}
}
問題20: プラグインとして登録可能なグローバルコンポーネント
// GlobalAlert.vue
<template>
<div class="global-alert" :class="type">
{{ message }}
</div>
</template>
<script>
export default {
props: {
message: String,
type: {
type: String,
default: 'info',
validator: value => ['info', 'success', 'warning', 'error'].includes(value)
}
}
}
</script>
// plugin.js
import GlobalAlert from './GlobalAlert.vue'
export default {
install(app) {
app.component('GlobalAlert', GlobalAlert)
app.config.globalProperties.$alert = (message, type = 'info') => {
const AlertComponent = app.extend(GlobalAlert)
const instance = new AlertComponent({
propsData: { message, type }
})
instance.$mount()
document.body.appendChild(instance.$el)
}
}
}
問題21: Teleportを使ったモーダル
// Modal.vue
<template>
<teleport to="body">
<div class="modal-overlay" v-if="visible">
<div class="modal-content">
<slot></slot>
<button @click="close">閉じる</button>
</div>
</div>
</teleport>
</template>
<script>
export default {
props: {
visible: Boolean
},
emits: ['close'],
methods: {
close() {
this.$emit('close')
}
}
}
</script>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
}
</style>
問題22: コンポジションAPIのuseEventEmitter
// useEventEmitter.js
import { getCurrentInstance } from 'vue'
export default function useEventEmitter() {
const instance = getCurrentInstance()
const emit = (event, ...args) => {
if (!instance) {
console.warn('useEventEmitterはsetup()内でのみ使用可能です')
return
}
instance.emit(event, ...args)
}
return { emit }
}
// 使用例
<script>
import useEventEmitter from './useEventEmitter'
export default {
setup() {
const { emit } = useEventEmitter()
const sendEvent = () => {
emit('custom-event', 'データ')
}
return { sendEvent }
}
}
</script>
問題23: カスタムディレクティブで可視状態通知
// ディレクティブ定義
export default {
mounted(el, binding, vnode) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
vnode.context.$emit('element-visible', {
element: el,
id: binding.value
})
}
})
})
observer.observe(el)
el._visibilityObserver = observer
},
unmounted(el) {
if (el._visibilityObserver) {
el._visibilityObserver.disconnect()
}
}
}
// 使用例
<template>
<div v-visibility="'section1'" @element-visible="handleVisibility"></div>
</template>
<script>
export default {
methods: {
handleVisibility({ id }) {
console.log(`${id}が表示されました`)
}
}
}
</script>
問題24: PropsとEmitのみでの状態管理
// store.js (シングルトンオブジェクト)
export const store = {
state: {
count: 0,
user: null
},
listeners: new Set(),
subscribe(callback) {
this.listeners.add(callback)
return () => this.listeners.delete(callback)
},
setState(newState) {
this.state = { ...this.state, ...newState }
this.notify()
},
notify() {
this.listeners.forEach(callback => callback(this.state))
}
}
// StateProvider.vue (最上位コンポーネント)
<template>
<slot :state="state"></slot>
</template>
<script>
import { store } from './store'
export default {
data() {
return {
state: store.state
}
},
created() {
this.unsubscribe = store.subscribe(state => {
this.state = state
})
},
beforeUnmount() {
this.unsubscribe()
}
}
</script>
// StateConsumer.vue (子コンポーネント)
<template>
<div>
<p>Count: {{ state.count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script>
import { store } from './store'
export default {
props: {
state: {
type: Object,
required: true
}
},
methods: {
increment() {
store.setState({
count: this.state.count + 1
})
}
}
}
</script>
// 使用例
<template>
<StateProvider v-slot="{ state }">
<StateConsumer :state="state" />
</StateProvider>
</template>