🔰

【Vue.js】イメージとアナロジーとヒストリーで理解する Component Emits

2024/03/16に公開
2

Emit が分からない!

Vue.js で refcomputed でのステート管理を学び、コンポーネント間でのやり取りで props を学び、次に emit を学ぼうとするとなんだか難しく感じることがあります。
確かに初学者の方からすると、慣れるまで少し時間がかかるかもしれません。

でも大丈夫です。
この記事では図でのイメージとあなたが既に知っているはずの知識を使って emit を理解するためのヒントを提供します。

基本のキ

とりあえず emit の基本をおさらいします。

https://ja.vuejs.org/guide/components/events.html

<!-- ChildComponent.vue -->
<script setup lang="ts">
const emit = defineEmits<{ "my-event": [] }>();

function handleClick() {
  emit("my-event");
}
</script>

<template>
  <button type="button" @click="handleClick">Click Me!</button>
</template>
<!-- ParentComponent.vue -->
<script setup lang="ts">
import ChildComponent from "./ChildComponent.vue";

function handleMyEvent() {
  console.log("My event is emitted!");
}
</script>

<template>
  <ChildComponent @my-event="handleMyEvent" />
</template>

慣れてる方からするとなんてことないかもしれませんが、初学者の方にとっては分かりづらいかもしれません。
emit で大事なことは イメージ です。

図でイメージする

まずは図でイメージしてみましょう。

基本的な構図は「子供がイベントを発行する」です。

emit-fig-1

そして「どういうイベントか」とういう情報が必要なので「〇〇イベントを親に発行する」というイメージです。

〇〇にはイベント名が当たります。例えば「ステート A が更新された」とか、「OK ボタンがクリックされた」などです。

先ほどの例で言うところの、my-event がこのイベント名に当たります。

<!-- ChildComponent.vue -->
<script setup lang="ts">
const emit = defineEmits<{ "my-event": [] }>();

function handleClick() {
  emit("my-event");
}
</script>

<template>
  <!-- このボタンがクリックされたら、 "my-event" と言うイベントを発行する -->
  <button type="button" @click="handleClick">Click Me!</button>
</template>

発行したイベントは、親コンポーネント側で @イベント名 で受け取ることができます。

<!-- ParentComponent.vue -->
<script setup lang="ts">
import ChildComponent from "./ChildComponent.vue";

function handleMyEvent() {
  console.log("My event is emitted!");
}
</script>

<template>
  <ChildComponent @my-event="handleMyEvent" />
</template>

emit-fig-2

もちろん、イベントを発行する際には一緒にデータを渡すこともできます。

<!-- ChildComponent.vue -->
<script setup lang="ts">
const emit = defineEmits<{ "my-event": [message: string] }>();

function handleClick() {
  emit("my-event", "Hello, World!");
}
</script>

<template>
  <button type="button" @click="handleClick">Click Me!</button>
</template>
<!-- ParentComponent.vue -->
<script setup lang="ts">
import ChildComponent from "./ChildComponent.vue";

function handleMyEvent(message: string) {
  console.log(message);
}
</script>

<template>
  <ChildComponent @my-event="(message) => handleMyEvent(message)" />

  <!-- 以下でも OK -->
  <ChildComponent @my-event="handleMyEvent" />
  <ChildComponent @my-event="handleMyEvent($event)" />
</template>

emit-fig-3

このような図がイメージできたら完璧です。

アナロジーで理解する

正直、先ほどの図のイメージができれば十分で、ここから下はおまけ程度なのですが、
切り口はいくつもあった方がいいということで、次はやや違うアプローチで説明してみます。
(やや見慣れない切り口で説明してみるので返って理解できない方はスルーでも OK です。)

アナロジー 1: DOM イベント

さて、コンポーネントの emit についてだいぶイメージがついてきたと思います。
ではこの Vue コンポーネントの emit とという概念の発生を考えてみましょう。

先ほどのこのイメージ図、

emit-fig-2

実はみなさん Vue を学び始める前から知っているはずです。
これは DOM イベントにとても似ています。

<!-- HTML -->
<input id="my-input" type="text" />
function handleInputEvent(e) {
  console.log(e.target.value);
}

const el = document.getElementById("my-input");
el?.addEventListener("input", handleInputEvent);

handle-event

input 要素が発行する input というイベントに対して事前にハンドラを登録しておくと、イベントが発生したときにハンドラが呼ばれます。
input 要素の場合、UI からの入力が発生したときに input イベントが発行されるようにブラウザが実装されています。
この発行部分の実装を担うのが Vue の emit です。

実はこの要素のイベントは手動でトリガーすることもできます。
それが、EventTarget.dispatchEvent です。この関数はまさしく Vue Component における emit 関数に相当します。
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/EventTarget
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

addEventListener という関数は、EventTarget というインターフェースを実装しているオブジェクトが使える関数で、リスナの登録があるのと同様に、もちろんイベントの発行もできます。それが dispatchEvent です。
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent

const eventBus = new EventTarget();

function handleMyEvent(e) {
  console.log("My event is emitted!", e);
}

// Vue でいうところの `@my-event="handleMyEvent"`
eventBus.addEventListener("my-event", handleMyEvent);

// Vue でいうところの `emit("my-event")`
eventBus.dispatchEvent(new Event("my-event"));

handle-event

MDN/Web/API/EventTargetMDN/Web/API/Element を見てもらえるとわかる通り、
ElementEventTarget を実装しているので、いつもの addEventListener などが使えます。

Common targets are Element, or its children, Document, and Window, but the target may be any object that supports events (such as IDBRequest).

先ほどは、 new EventTarget() というコンストラクタを使って、イベントバスを生成しましたが、この説明の通り、ここは任意の Element に置き換えることができます。

const el = document.getElementById("my-input");
el?.addEventListener("my-event", handleMyEvent);
el?.addEventListener("input", handleInput);

function handleMyEvent(e) {
  console.log("My event is emitted!", e);
}

function handleInput(e) {
  console.log("Input event is emitted!", e);
  el?.dispatchEvent(new Event("my-event")); // input event のハンドリングとして、 my-event の発行を行ってみる
}

Vue はこのような元々あるメンタルモデルを模倣しただけなのです。
実は Vue の昔のイベントシステムには、$dispatch/$onというものがあったりしました。
(※ この時代から vm.$emit というという関数もありましたが、説明の都合上 $dispatch で説明して、$emit の話はおまけ話に追記してます)

https://011.vuejs.org/guide/components.html#Event_System

var Child = Vue.extend({
  created: function () {
    this.$dispatch("child-created");
  },
});

var parent = new Vue({
  template: '<div v-component="child"></div>',
  components: {
    child: Child,
  },
  created: function () {
    this.$on("child-created", function (child) {
      console.log("new child created");
    });
  },
});

これはまんま EventTarget と同じです。

少し書き換えてみると、

const eventBus = new EventTarget();
const $dispatch = eventBus.dispatchEvent.bind(eventBus);
const $on = eventBus.addEventListener.bind(eventBus);

const Child = {
  created: function () {
    $dispatch(new Event("child-created"));
  },
};

const parent = {
  created: function () {
    $on("child-created", function (e) {
      console.log("new child created");
    });
  },
};

// Vue 内部で呼ばれるイメージ
parent.created();
Child.created();

本当によく似ています。
Vue の場合は実際に EventTarget で実装されているわけではないと思いますが、このコードでいうところの EventTarget に相当するものを独自に実装しているということです。

細かいことは置いておいてとにかく、「Vue の Component のイベントは DOM のイベントみたいなやつ!」ということを感じることができれば OK です。

アナロジー 2: Node.js の EventEmitter

これはもうアナロジーというよりデザインパターンの話のような気もしますが、Vue や DOM の世界以外でも 「イベントの購読と発行で実装する」みたいなことはよくあります。
これのいい例は Node.js の EventEmitter だと思います。

https://nodejs.org/docs/latest/api/events.html

const EventEmitter = require("node:events");

const ee = new EventEmitter();
ee.on("event", () => {
  console.log("an event occurred!");
});
ee.emit("event");

まぁ、こちらはもはや説明不要だと思います。
このイベントシステムを利用してプログラムを書くことというのはしばしばあります。

Node.js の http ライブラリなんかがまさにこれです。

https://nodejs.org/api/http.html

const http = require("node:http");

// Create an HTTP server
const server = http.createServer();
server.on("request", (req, res) => {
  // impl
});

まとめ

初学者にとっては慣れない Vue の emit ですが、今回は「図でイメージすると分かりやすい」ということと、「DOM の event などの模倣だと考えると分かりやすい」「イベントシステムは Vue に限った話ではない」という切り口で説明してみました。
単に、Vue のコードを読んでみて動作を暗記するよりも、図でイメージしたり他のものからの類推で考えてみたりする方が理解しやすいと思います。

emit に限らず、何かわからないことがあったら図を書いてみたり、由来などを考えたり調べたりしてみると、理解が深まるかもしれません。
文法と動作を呪文のように覚えるのはやめて、イメージや成り立ちを大事にしてみましょう!

おまけ

いくつかおまけ話です。(読まなくてもいいです)

イベントバスとしての Vue インスタンス (昔話)

今から話すことは昔話です。大体 2015 年くらいの話でしょうか。

今回の説明ではややこしくなるので省略しましたが、

※ この時代から vm.$emit というという関数もありましたが、説明の都合上 $dispatch で説明して、$emit の話はおまけ話に追記してます

についてです。

v1 から v2 のマイグレーションガイドを見てみましょう。

https://v2.ja.vuejs.org/v2/guide/migration#dispatch-および-broadcast-置き換え

$dispatch および $broadcast については、 Vuex などような、よりはっきりとしたコンポーネント間の通信及び状態管理のソリューションを支持するかたちで廃止となりました。これまでの問題として、コンポーネントツリーが肥大化した際、その動作を推論することが非常に困難となり、また、コンポーネントのツリー構造に依存する、非常に脆いイベンフローがありました。それは、単純にうまくスケールしませんし、後々に痛みを伴う変更となってはなりません。$dispatch および $broadcast に関しても、兄弟コンポーネント間の通信を解決するものではありません。これらの方法の最も一般的な用途の 1 つは、親とその直接の子供との間の通信です。このような場合、実際に v-on で子から $emit によって購読できます。これにより、明示的に追加されたイベントの利便性を保つことができます。

しかしながら、遠い子孫/祖先の間で通信するとき、$emit はあなたを助けないでしょう。代わりに、最も簡単なアップグレードは、集中型のイベントハブを使用することです。これはコンポーネントツリー内のどこにあっても(兄弟間だとしても)、コンポーネント間で通信できるという利点があります。Vue インスタンスは event emitter インタフェースを実装しているため、実際にはこの目的で空の Vue インスタンス ($mount で DOM にマウントしない状態のこと)を使用できます。

var eventHub = new Vue();
// NewTodoInput
// ...
methods: {
  addTodo: function () {
    eventHub.$emit('add-todo', { text: this.newTodoText })
    this.newTodoText = ''
  }
}
// DeleteTodoButton
// ...
methods: {
  deleteTodo: function (id) {
    eventHub.$emit('delete-todo', id)
  }
}
// Todos
// ...
created: function () {
  eventHub.$on('add-todo', this.addTodo)
  eventHub.$on('delete-todo', this.deleteTodo)
},
// 以下は、コンポーネントの削除前に
// イベントリスナーをクリアするための良い手法です。
beforeDestroy: function () {
  eventHub.$off('add-todo', this.addTodo)
  eventHub.$off('delete-todo', this.deleteTodo)
},
methods: {
  addTodo: function (newTodo) {
    this.todos.push(newTodo)
  },
  deleteTodo: function (todoId) {
    this.todos = this.todos.filter(function (todo) {
      return todo.id !== todoId
    })
  }
}

そうなんです。イベントハブ用の Vue インスタンスを用意して、vm.$emitvm.$on でイベントを発行・購読するという方法が昔はありました。
これこそ本当にそのまま

const eventBus = new EventTarget();

function handleMyEvent(e) {
  console.log("My event is emitted!", e);
}

// Vue でいうところの `@my-event="handleMyEvent"`
eventBus.addEventListener("my-event", handleMyEvent);

// Vue でいうところの `emit("my-event")`
eventBus.dispatchEvent(new Event("my-event"));

と同じです。

移行ガイドをみるとおり、この方法は $dispatch の代替品として紹介されている方法でした。(推奨されているわけではありません)

単純なシナリオ上では、 $dispatch および $boardcast を代替品に置き換えるパターンで動かすことができますが、より複雑なケースを想定して、 Vuex のような専門的な状態管理層を設けることをおすすめします。

この、Vue インスタンスをイベントバスとして利用する手法は、v2 -> v3 で使えなくなりました。

https://v3-migration.vuejs.org/breaking-changes/events-api.html#events-api

この変更では、$on$off が削除され、$emit はそのまま残って、購読は親コンポーネントからのみできるという API に変更され、今現在の形になっています。

バブリングの有無

Component の Emit と、DOM の Event がよく似ているという話がありましたが、バブリングに関しては実は異なっています。
DOM の Event はバブリングがありますが、Component の Emit にはありません。
これはドキュメントにも明記されています。

https://ja.vuejs.org/guide/components/events#emitting-and-listening-to-events

ネイティブの DOM イベントとは異なり、コンポーネントから発行されたイベントはバブリングしません。直接の子コンポーネントから発行されたイベントのみを購読できます。兄弟コンポーネントや深くネストしたコンポーネント間で通信する必要がある場合は、外部のイベントバスやグローバルな状態管理ソリューションを使ってください。

v2 -> v3 の変更で Vue インスタンスをイベントバスとして使うことができなくなったので、回避策としても外部のイベントバスを使うことや、グローバルな状態管理を推奨している旨がここにも書かれています。

そして先ほど紹介した、$dispatch という昔あったメソッドは root コンポーネントまでバブリングするものでした。(アプトアウトはできました)

https://011.vuejs.org/api/instance-methods.html

Dispatch an event from the current vm that propagates all the way up to its $root. If a callback returns false, it will stop the propagation at its owner instance.

ちなみに $emit はというと、同じ vm (インスタンス)に対するイベント発行のためのものでした

https://011.vuejs.org/api/instance-methods.html

Trigger an event on this vm only.

つまり歴史的な背景としては

Vue v0.11 の時から、$dispatch, $broadcast, $emit, $on というメソッド自体は存在していて、この頃は $dispatch$broadcast はコンポーネントを跨いで末端 (最親または最子) までイベントを発行し、$emit は同じ vm 内にイベントを発行するためにも使われ、$on はそれらを購読するという感じでした。

そして、v1 -> v2 で $dispatch$broadcast は廃止されました。
代わりに、$emit をイベントバス用のインスタンスを用意して使うような形になっていましたが、Vue 自身はこれをあまり推奨せず、Vuex などの専門的な状態管理層を使うことを推奨していました。(あくまで $dispatch/$broadcast の代替品として紹介されていた)

そして、さらに時間は過ぎ、v2 -> v3 ではこの vm.$on は廃止され、$emitは親コンポーネントによって宣言的にアタッチされたイベントハンドラをトリガーするために使用されるものになりました。

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion