はじめに
Vue.jsでコンポーネント間のやり取りを記述する場合、親はプロパティ(props)を利用してデータを受け渡し、イベント(event)を利用して子コンポーネントの動作を購読する形式が一般的かと思います。
こうした中、TypeScript x Vue.jsでの実装をプロダクト開発で行っていく際に、propsにコールバック関数を渡したほうが型安全に記述でき、ランタイムエラーを減らせるのではないか?という意見が開発チーム内で挙がったため、従来のevent駆動形式とpropsにコールバック関数を渡す形式をそれぞれで比較し、どちらが型安全に開発ができそうか比較してみます。
前提
こうしたemit vs props-callback論争?はすでにいくつか意見があるようでした
vue, emitting vs passing function as props
「propで子に渡してeventで親に伝える」が基本理念というのは正しいようですが、callbackでも同じことは実現できるというようですね。
検証
ボタンをクリックするとconsoleにmessageを渡すような部品を作ってみて検証してみます。
実装したサンプルはこちらのGithubリポジトリに公開していますので、ご自由に参照ください。
まずはevent駆動のボタン部品ですが、以下のように
発火するイベントで渡す値をEmitButtonClickEventValue
という型で指定する形です。
このボタンだけで見れば、クリックしたことを親に伝えることだけを意識すれば良く、その先を意識することはありません。
<template>
<button @click="emitClick">イベント駆動ボタン</button>
</template>
<script lang="ts">
import Vue from "vue";
import { Component, Emit } from "vue-property-decorator";
export type EmitButtonClickEventValue = {
message: string;
};
@Component
export default class EmitButton extends Vue {
@Emit("click")
emitClick(): EmitButtonClickEventValue {
return {
message: "myEmitMessage"
};
}
}
</script>
一方で、callback駆動のボタン部品です。
こちらはevent駆動のものとは違い、CallbackButtonOnClickCallback
という名前でコールバック関数の型を定義しています。
また、executeClickCallback
関数内に記述している通り、親から渡されたコールバック関数が存在するかを意識する必要が出てきます。
<template>
<button @click="executeClickCallback">コールバック駆動ボタン</button>
</template>
<script lang="ts">
import Vue from "vue";
import { Component, Prop } from "vue-property-decorator";
export type CallbackButtonOnClickCallback = (value: {
message: string;
}) => void;
@Component
export default class CallbackButton extends Vue {
@Prop({ type: Function, required: false })
onClick?: CallbackButtonOnClickCallback;
executeClickCallback() {
if (!this.onClick) return;
this.onClick({ message: "myCallbackMessage" });
}
}
</script>
続いて、上記2つのボタンを利用する親のコンポーネントを見ていきましょう。
ここで、イベント駆動型ボタン部品と連動しているonEmitButtonClick
では、EmitButton.vue
のclick
イベントで渡される引数の型EmitButtonClickEventValue
をimportして利用する必要があります。
一方で、コールバック駆動型ボタン部品と連動しているonCallbackButtonClick
では、CallbackButton.vue
のpropに定義したコールバック関数の型CallbackButtonOnClickCallback
をimportして利用する必要があります。
<template>
<div id="app">
<emit-button @click="onEmitButtonClick" />
<callback-button :on-click="onCallbackButtonClick" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import EmitButton, {
EmitButtonClickEventValue
} from "./components/EmitButton.vue";
import CallbackButton, {
CallbackButtonOnClickCallback
} from "./components/CallbackButton.vue";
@Component({
components: {
EmitButton,
CallbackButton
}
})
export default class App extends Vue {
// イベント駆動の場合、イベントから渡される値の型を知っている必要がある
onEmitButtonClick(value: EmitButtonClickEventValue) {
console.log(value.message);
// -> 'myEmitMessage'
}
// コールバック駆動の場合、コールバック関数のインターフェースを知っている必要がある
onCallbackButtonClick: CallbackButtonOnClickCallback = value => {
console.log(value.message);
// -> 'myCallbackMessage'
};
}
</script>
どちらで実装すべきか
では、上記の2つの実装方式どちらを選択すべきでしょうか?
いくつかの観点から見てみます。
型安全の観点
先ほど実装したコンポーネントの型が変化した際、壊れやすいのはどちらでしょうか?
改めてそれぞれの実装形式で定義した型をまとめると、それぞれ
export type EmitButtonClickEventValue = {
message: string;
};
export type CallbackButtonOnClickCallback = (value: {
message: string;
}) => void;
ですが、messageを配列で渡すような修正を行い、
export type EmitButtonClickEventValue = {
messages: string[];
};
export type CallbackButtonOnClickCallback = (value: {
messages: string[];
}) => void;
に変更したとしましょう。
この場合、利用元のApp.vue
ではtype errorが発生し、変更を静的に検知することが出来ます。
そのため、既存の定義済の型を変更する分にはどちらの実装形式も特に変わりないといえます。
一方、今回のevent vs callbackそのものには関係ないのですが、現在Vue.jsの制約として型テンプレート内と、eventで渡される型そのもの静的型推論は効かない(はず)なので、以下のようにそもそもの型を変更するような修正をした場合は親側でimportしている型を変更する必要があります。
<template>
<button @click="emitClick">イベント駆動ボタン</button>
</template>
<script lang="ts">
import Vue from "vue";
import { Component, Emit } from "vue-property-decorator";
// 元の型を残したまま
export type EmitButtonClickEventValue = {
message: string;
};
// 新しい型を作って
export type NewEmitButtonClickEventValue = {
message: number[];
};
@Component
export default class EmitButton extends Vue {
@Emit("click")
emitClick(): NewEmitButtonClickEventValue {
return {
message: 12345
};
}
}
</script>
Vue.jsの基本理念
冒頭にも記述した通り、Vue.jsのコンポーネントの基本理念はpropsとeventで親子のやり取りを行うことだと思います。
そのため、prop-eventパターンで記述できるのであればそちらを選択するのが安牌だと考えています。
またcallback形式を選択した場合、例えば以下のようにネイティブのHTMLElementと組み合わせた際にイベント駆動とコールバック駆動の記述が混在してしまう恐れや、
<template>
<div>
<input type="text" :value="myTextValue" @input="onMyTextValueChange"/>
<callback-button :on-click="onClickCallback"/>
</div>
</template>
コールバック関数用のpropの命名規則を固めないと、親から見た際にコールバック関数を渡しているのかプリミティブな値を渡しているのか判別しにくいという問題もあるとは思います。
<template>
<my-sample-component
:name="myName"
:my-value="myValue"
:on-click="onClickCallback"
:context-menu="contextMenuFunction"
/>
</template>
結論
結論ですが、プロジェクトのメンバーと意見を擦り合わせつつ、好きなほうを選択すればいいと思います(最後に投げやりですみません)。
上記で見た通り、型推論の観点でもどちらも大差ないと思えますし、アプリケーションの要件はどちらの実装方式でも満たすことが出来ると思います。
私はprop-eventに長く触れてきており、これからもこの形式を推していきますが、プロジェクトの状況・メンバーや、Vue.jsが進化していく中で常に考え、検証を行い、堅牢なアプリケーションを開発していくことが大事かなーと思いました。
最後まで読んでくれた方がいれば、駄文にお付き合いいただきありがとうございました。
うちはこうやってるよー、とか、もっとこうした方が良いのでは?等意見がありましたら頂けると嬉しいです。