Vue.js コンポーネント間通信(Props & Emit)演習

2025-07-29

Propsと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>