【Vue.js】イメージとアナロジーとヒストリーで理解する Component Emits
Emit が分からない!
Vue.js で ref
や computed
でのステート管理を学び、コンポーネント間でのやり取りで props
を学び、次に emit
を学ぼうとするとなんだか難しく感じることがあります。
確かに初学者の方からすると、慣れるまで少し時間がかかるかもしれません。
でも大丈夫です。
この記事では図でのイメージとあなたが既に知っているはずの知識を使って emit を理解するためのヒントを提供します。
基本のキ
とりあえず emit の基本をおさらいします。
<!-- 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 で大事なことは イメージ です。
図でイメージする
まずは図でイメージしてみましょう。
基本的な構図は「子供がイベントを発行する」です。
そして「どういうイベントか」とういう情報が必要なので「〇〇イベントを親に発行する」というイメージです。
〇〇にはイベント名が当たります。例えば「ステート 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>
もちろん、イベントを発行する際には一緒にデータを渡すこともできます。
<!-- 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>
このような図がイメージできたら完璧です。
アナロジーで理解する
正直、先ほどの図のイメージができれば十分で、ここから下はおまけ程度なのですが、
切り口はいくつもあった方がいいということで、次はやや違うアプローチで説明してみます。
(やや見慣れない切り口で説明してみるので返って理解できない方はスルーでも OK です。)
アナロジー 1: DOM イベント
さて、コンポーネントの emit についてだいぶイメージがついてきたと思います。
ではこの Vue コンポーネントの emit とという概念の発生を考えてみましょう。
先ほどのこのイメージ図、
実はみなさん 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);
input 要素が発行する input
というイベントに対して事前にハンドラを登録しておくと、イベントが発生したときにハンドラが呼ばれます。
input 要素の場合、UI からの入力が発生したときに input
イベントが発行されるようにブラウザが実装されています。
この発行部分の実装を担うのが Vue の emit です。
実はこの要素のイベントは手動でトリガーすることもできます。
それが、EventTarget.dispatchEvent
です。この関数はまさしく Vue Component における emit
関数に相当します。
addEventListener
という関数は、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"));
MDN/Web/API/EventTarget や MDN/Web/API/Element を見てもらえるとわかる通り、
Element
は EventTarget
を実装しているので、いつもの 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
の話はおまけ話に追記してます)
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
だと思います。
const EventEmitter = require("node:events");
const ee = new EventEmitter();
ee.on("event", () => {
console.log("an event occurred!");
});
ee.emit("event");
まぁ、こちらはもはや説明不要だと思います。
このイベントシステムを利用してプログラムを書くことというのはしばしばあります。
Node.js の http ライブラリなんかがまさにこれです。
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 のマイグレーションガイドを見てみましょう。
$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.$emit
と vm.$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 で使えなくなりました。
この変更では、$on
や $off
が削除され、$emit
はそのまま残って、購読は親コンポーネントからのみできるという API に変更され、今現在の形になっています。
バブリングの有無
Component の Emit と、DOM の Event がよく似ているという話がありましたが、バブリングに関しては実は異なっています。
DOM の Event はバブリングがありますが、Component の Emit にはありません。
これはドキュメントにも明記されています。
ネイティブの DOM イベントとは異なり、コンポーネントから発行されたイベントはバブリングしません。直接の子コンポーネントから発行されたイベントのみを購読できます。兄弟コンポーネントや深くネストしたコンポーネント間で通信する必要がある場合は、外部のイベントバスやグローバルな状態管理ソリューションを使ってください。
v2 -> v3 の変更で Vue インスタンスをイベントバスとして使うことができなくなったので、回避策としても外部のイベントバスを使うことや、グローバルな状態管理を推奨している旨がここにも書かれています。
そして先ほど紹介した、$dispatch
という昔あったメソッドは root コンポーネントまでバブリングするものでした。(アプトアウトはできました)
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 (インスタンス)に対するイベント発行のためのものでした
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
は親コンポーネントによって宣言的にアタッチされたイベントハンドラをトリガーするために使用されるものになりました。
Discussion
Vue 3.3以降では、 より簡潔な
defineEmits()
の記法を利用できます確かに 👀
こっち使うように変更しておきました。ありがとうございます!