JavaScript
vue.js

Vue.jsコンポーネント入門 (4) $emitによるイベントの発行

本記事は Vue.js コンポーネント入門の第4回です。

$emit という機能で親コンポーネントにイベントを通知する方法を紹介します。

前提

PC ブラウザ Node npm Vue
macOS 10.12 Firefox Quantum 9.3.0 5.5.1 2.5

ディレクトリ構成

第1回記事の通り環境構築を進めていただくと、以下の通りのディレクトリ構成になっているかと思います。

プロジェクトルート
├─ node_modules
├─ src
│  ├─ components
│  │  └─ Hello.vue
│  └─ index.js
├─ index.html
├─ .babelrc
├─ webpack.config.js
├─ package.json
└─ package-lock.json

今回の記事も、ここから進めます。

$emit によるイベント通知

第2回の記事でコンポーネントはカスタム HTML 要素と考えられるという説明をしました。そして第3回の記事では props を要素の属性と表現しました。

今回紹介する $emit は、カスタム要素の発行するイベントです。

Vue 用語 役割 既存の HTML 要素で例えると
コンポーネント 要素 <a>, <img>, <button>
props 属性 href, src, type
$emit イベント onclick, onmouseover, onsubmit

@click@submit のように既存のイベントもアットマーク(または v-on)で Vue のイベントとして扱うことができますが、さらに $emit を使えば独自のイベント定義を行うこともできます。

では実際のコードで $emit の使用方法を見ていきましょう。

(説明用ということで実用的な意味は特にないコード例ですがご容赦ください :sweat_smile:

src/components/EventButtons.vue
<template>
  <div>
    <button @click="emitEventOne">Event 1</button>
    <button @click="emitEventTwo">Event 2</button>
    <button @click="emitEventThree">Event 3</button>
  </div>
</template>

<script>
export default {
  methods: {
    emitEventOne () {
      this.$emit('event-one')
    },
    emitEventTwo () {
      this.$emit('event-two', 'This is an argument')
    },
    emitEventThree () {
      this.$emit('event-three', 123, { name: 'three' })
    }
  }
}
</script>
src/index.js
import Vue from 'vue'

// Components
import EventButtons from './components/EventButtons.vue'

const app = new Vue({
  el: '#app',
  data: {
    arguments: []
  },
  components: {
    EventButtons
  },
  methods: {
    onEventOne () {
      alert('Event 1')
    },
    onEventTwo (argument) {
      this.arguments = []
      this.arguments.push(argument)
    },
    onEventThree (arg1, arg2) {
      this.arguments = []
      this.arguments.push(arg1)
      this.arguments.push(arg2)
    }
  }
})
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Vue Component Tutorial</title>
</head>
<body>
  <div id="app">
    <event-buttons
      @event-one="onEventOne"
      @event-two="onEventTwo"
      @event-three="onEventThree"
    ></event-buttons>
    <pre v-for="argument in arguments">{{ argument }}</pre>
  </div>
  <script src="./dist/main.js"></script>
</body>
</html>

コード動作例

https://codesandbox.io/s/j2okqw46rw

イベントを発行する

まずはイベントを発行する側、つまり EventButton コンポーネントをご覧ください。ボタンを押すとそれぞれイベントを発行するコンポーネントです。

イベントを発行している箇所に注目しましょう。

this.$emit('event-one')
this.$emit('event-two', 'This is an argument')
this.$emit('event-three', 123, { name: 'three' })

$emit は Vue インスタンスに定義されたメソッドで、ひとつ以上の引数を持ちます。

  • 1つめの引数は、イベントの名前です。
  • 2つめ以降の引数は、イベントハンドラに渡される引数です。

イベントハンドラに引数を渡さない場合は、引数は1つでOKです。

それぞれ説明すると…

this.$emit('event-one')

'event-one' という名前のイベントを発行しています。
イベントハンドラに渡す引数はありません。

this.$emit('event-two', 'This is an argument')

'event-one' という名前のイベントを発行しています。
イベントハンドラに渡す引数がひとつあります。'This is an argument' という文字列です。

this.$emit('event-three', 123, { name: 'three' })

'event-one' という名前のイベントを発行しています。
イベントハンドラに渡す引数がふたつあります。ひとつめは 123 という数値でふたつめは { name: 'three' } というオブジェクトです。

また、上記からも分かる通り、ひとつのコンポーネントから何種類でもイベントを発行できます。

次にイベントを購読する側のコードを見てみましょう。

イベントを購読する

まずは index.html からご覧ください。

index.html
<event-buttons
  @event-one="onEventOne"
  @event-two="onEventTwo"
  @event-three="onEventThree"
></event-buttons>

@ にイベント名を続けた属性名(@event-one)に、イベントハンドラとなるメソッド名を値として記述しています。

<button>click イベントを @click="handleClick" という具合に受け取るのと同じ記述方法ですね。既存の HTML 要素であってもカスタム要素(つまりコンポーネント)であっても同じく @ 記法によってイベントを受け取る記述ルールは一貫しているわけです。

<component @イベント名="イベントハンドラ名"></component>

次に index.js でイベントハンドラの定義を見ていきましょう。

src/index.js
const app = new Vue({
  /* 中略 */
  methods: {
    onEventOne () {
      alert('Event 1')
    },
    onEventTwo (argument) {
      this.arguments = []
      this.arguments.push(argument)
    },
    onEventThree (arg1, arg2) {
      this.arguments = []
      this.arguments.push(arg1)
      this.arguments.push(arg2)
    }
  }
})

ここではイベントを発行した際に $emit の第2引数以降に指定したイベントハンドラーへの引数をどのように受け取っているかに注目してください。

それぞれ、イベントの発行と購読、そしてイベントハンドラーの定義を並べると繋がりが分かりやすいのではないでしょうか。

// イベント発行
this.$emit('event-one')
// イベント購読
@event-one="onEventOne"
// イベントハンドラー
onEventOne () {
  alert('Event 1') // 引数なし
}

event-one という名前で発行されたイベントを @event-one という記述で受け取っています。このイベント名と @ 以降の属性名は一致していなければいけません。

// イベント発行
this.$emit('event-two', 'This is an argument')
// イベント購読
@event-two="onEventTwo"
// イベントハンドラー
onEventTwo (argument) {
  this.arguments = []
  this.arguments.push(argument) // 'This is an argument'
}

ふたつめの例ではイベントハンドラーは引数をとります。イベント発行時の第2引数 'This is an argument' が渡されます。

// イベント発行
this.$emit('event-three', 123, { name: 'three' })
// イベント購読
@event-three="onEventThree"
// イベントハンドラー
onEventThree (arg1, arg2) {
  this.arguments = []
  this.arguments.push(arg1) // 123
  this.arguments.push(arg2) // { name: 'three' }
}

最後の例ではイベントハンドラーは引数をふたつとります。イベント発行時の第2引数と第3引数がその順番でイベントハンドラーに渡されます。

つまり、

123arg1
{ name: 'three' }arg2

ということです。

カスタムイベントを扱う際の以下のパターンを理解していただけましたでしょうか。

  • $emit メソッドでイベントを発行
  • @ 記法でイベントを購読
  • イベントハンドラーでイベント発行時の引数を受け取って処理

数当てゲームを作る

ここまでの説明で扱ったコードはイベントを発行するためだけの例でした。

そこで、もう少し意味のある例として数当てゲームを作ってみたいと思います。

ルールは以下の通りです。

  • 1〜100のランダムな整数を予想して10回以内に当てられたら勝ち。
  • 予想が外れた場合、答えが予想より大きいか小さいかがヒントとしてあたえられる。
  • 10回で当てられなかったら負け。

コードが少し長いので、先に完成形を見てもらった方が分かりやすいかと思います。
https://codesandbox.io/s/38n789685p

イベントを発行する

まずは NumberGuess コンポーネントを作成します。

このコンポーネントは見た目としてはスタートボタンと予想するための入力欄を持っています。

役割としては、

  • スタートボタンにより各データを初期化する
  • 予想の入力を受け付け、予想の正誤やヒント、残り予想回数をイベントとして発行する

ことです。

どこからどこまでを一つのコンポーネントにするかは設計のポイントとなりますが、今回は $emit の説明という目的にも鑑みてこのようなコンポーネントとしました。

src/components/NumberGuess.vue
<template>
  <div>
    <p>
      <button @click="start">start</button>
    </p>
    <div v-if="answer > 0">
      <input
        type="number"
        v-model.number="num"
        @keyup.enter="guess"
      />
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      answer: 0, // 答え
      num: 0, // 予想した数
      tryCountLeft: 10 // 残りの予想回数
    }
  },
  methods: {
    start () {
      // それぞれのデータを初期化する
      this.answer = Math.floor(Math.random() * (100 - 1) + 1)
      this.num = 0
      this.tryCountLeft = 10
      // startイベント発行
      this.$emit('start', this.tryCountLeft)
    },
    guess () {
      this.tryCountLeft -= 1

     if (this.answer === this.num) {
        // collectイベント発行
        // 正解したことを通知する
        this.$emit('collect', this.tryCountLeft)
        return
      }

      if (this.tryCountLeft === 0) {
        // loseイベント発行
        // 負けたことを通知する
        this.$emit('lose', this.tryCountLeft)
      } else if (this.answer < this.num) {
        // lowerイベント発行
        // 正解はより小さい数であることを通知する
        this.$emit('lower', this.tryCountLeft)
      } else {
        // higherイベント発行
        // 正解はより小さい数であることを通知する
        this.$emit('higher', this.tryCountLeft)
      }
    }
  }
}
</script>

アクションは2つしかありません。1つはスタートボタンで、クリックすると start メソッドが実行されます。2つめは <input> です。エンターキーを押すことで guess メソッドが実行されます。

guess メソッド内で予想と正解の突き合わせを行なって、結果に応じたイベントを発行しています。各イベントの $emit ではそれぞれ残り予想回数を第2引数に渡し、イベントハンドラーで受け取れるようにしています。

コンポーネントの data は関数でなければならない

ここで Vue コンポーネントにおける超重要な性質に触れたいと思います。

export default {
  data () {
    return {
      answer: 0, // 答え
      num: 0, // 予想した数
      tryCountLeft: 10 // 残りの予想回数
    }
  },
  /* 以下略 */

上記の通り、data プロパティの値がオブジェクトではなく、オブジェクトを返す関数になっています。

data プロパティの値がオブジェクトとして書いてしまうと…

誤った記述例
export default {
  data: {
    answer: 0, // 答え
    num: 0, // 予想した数
    tryCountLeft: 10 // 残りの予想回数
  },
  /* 以下略 */

このようなエラーがブラウザのコンソールに出力されます。

[Vue warn]: The "data" option should be a function that returns a per-instance value in component definitions.

コンポーネント定義におけるdataオプションはインスタンスごとの値を返す関数でなくてはいけません

前提として、コンポーネントを使用するたびに、新しいインスタンスが作成されます。その上で、同じコンポーネントのインスタンスが複数生成された場合、異なるインスタンスでは異なるデータを扱うはずです。

See the Pen Vue data must be a function by Masahiro Harada (@MasahiroHarada) on CodePen.

上記の例でいうと、3つの counter コンポーネントのインスタンスが生成されます。

それぞれの num はインスタンスごとに管理されるべきです。ひとつのボタンをクリックすると他のボタンの数も +1 されては困ります。

同じ種類のコンポーネントの異なるインスタンス間で、独立した data を扱うために、data はオブジェクトではなく、オブジェクトを返す関数でなくてはなりません

ただし、ルートコンポーネントでは data はオブジェクトとして記述します(つまり new Vue() の中の data)。これはルートコンポーネントは上記の counter コンポーネントの例のように複製されることが、あり得ないからです。

だからと言って複製しないつもりのコンポーネントでは data はオブジェクトでいいというわけではありません。ルートコンポーネント以外の全てのコンポーネントでは data は関数でなくてはならないというルールです。

フレームワークの文法なのでこういうふうに書くのだと覚えてしまって良いかと思います。

マニュアルスタイルガイドにも説明がありますので、ご一読ください。

イベントを購読する

さて、続いてイベントを購読する側です。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Vue Component Tutorial</title>
</head>
<body>
  <div id="app">
    <h1>数当てゲーム</h1>
    <number-guess
      @start="onStart"
      @collect="onCollect"
      @lower="onLower"
      @higher="onHigher"
      @lose="onLose"
    ></number-guess>
    <p>{{ message }}</p>
    <p>残り:{{ tryCountLeft }}回</p>
  </div>
  <script src="./dist/main.js"></script>
</body>
</html>

startcollect などのイベントに対してそれぞれのイベントハンドラーを登録しています。

src/index.js
import Vue from 'vue'

// Components
import NumberGuess from './components/NumberGuess.vue'

const app = new Vue({
  el: '#app',
  data: {
    message: 'スタートボタンを押してください',
    tryCountLeft: 0
  },
  components: {
    NumberGuess
  },
  methods: {
    onStart (tryCountLeft) {
      this.message = 'スタート! 🚀'
      this.tryCountLeft = tryCountLeft
    },
    onCollect (tryCountLeft) {
      this.message = '当たり! 🎉'
      this.tryCountLeft = tryCountLeft
    },
    onLower (tryCountLeft) {
      this.message = 'もっと小さいです 👇'
      this.tryCountLeft = tryCountLeft
    },
    onHigher (tryCountLeft) {
      this.message = 'もっと大きいです️ ️👆'
      this.tryCountLeft = tryCountLeft
    },
    onLose () {
      this.message = 'ゲームオーバー 👻'
      this.tryCountLeft = tryCountLeft
    }
  }
})

イベントハンドラーではイベントに合わせたメッセージを設定しています。また、残り予想回数を表示するために、自らの tryCountLeft に代入しています。

(ここではルートコンポーネントなので data はオブジェクトです。)

最終的なコードをもう一度貼っておきます。
https://codesandbox.io/s/38n789685p


以上、本記事では Vue.js コンポーネント入門の第4回として、$emit によるカスタムイベントの発行機能を紹介しました。

関連記事

Vue.jsコンポーネント入門

Vue.js入門