はじめに
この記事は、「Vue Advent Calendar 2021」の15日目の記事です。
Vue3のキャッチアップのため、個人開発でVue3を使っていた時に、Composition Function(Composable Functionとも言う)の使い方が分からず、すごく頭を悩ませたので、以下にまとめました。
Composition Functionとは?
Composable Functionとも言います。簡単に言えば、Vueのコンポーネントは複雑になればなるほど肥大化していくので、それを防ぐ手段として関数として切り出せるようにしたものです。
説明としては「なるほど。」と思うものの、何を?どの単位で切り出すのか?という問題が、様々なサイトで議論されているのを見かけます。
当の私自身も、どうすれば上手にComposition Functionを使えるのだろうか?ということですごく頭を悩ませました。
Composition Functionの切り出しパターン
私が色々なサイトで見た切り出しパターンは以下の通り。
- そもそも切り出さない
- コンポーネント自身の責務と、それ以外
- 副作用のあるものと、ないもの
- 共通化、再利用できるものと、できないもの
- 全部切り出す
もちろん、1つのパターンに拘ることはせずに、状況に応じて考えるべきだと思うし、パターンを組み合わせて考えてみるのも良いと思う。
以降、これらのパターンについて、以下コンポーネントをベースにして一つ一つ考察していく。
// コンポーネントの内容 :
// ボタンを押下するたびに、FizzBuzzの結果とFizzBuzzじゃなかった時の数値を加算した合計を出力する。
<template>
<p>FizzBuzzじゃなかった数値の合計: {{ count }}</p>
<button type="button" :class="classes" :value="fizzBuzz" @click="clickEvent">{{ label }}</button>
</template>
<script lang="ts">
import { defineComponent, computed, ref, toRef } from 'vue'
export default defineComponent({
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
number: {
type: Number,
required: true,
}
},
emits: ['randomNumber'],
setup(props, content) {
const classes = computed(() => ({
'is-red': props.color === 'red' ? true : false,
'is-green': props.color === 'green' ? true : false,
}))
const count = ref(0)
const fizzBuzz = computed(() => {
if (props.number % 15 === 0) {
return {
result: 'FizzBuzz',
count
}
}
if (props.number % 5 === 0) {
return {
result: 'Buzz',
count
}
}
if (props.number % 3 === 0) {
return {
result: 'Fizz',
count
}
}
count.value += props.number
return {
result: String(props.number),
count
}
})
const label = ref(props.label)
const clickEvent = () => {
label.value = `FizzBuzz: ${props.number} => ${fizzBuzz.value.result}`
content.emit('randomNumber', Math.floor( Math.random() * 101 ))
}
return {
label,
classes,
fizzBuzz,
clickEvent,
count,
}
},
})
</script>
そもそも切り出さない
Composition Functionの切り出し方について話しているのに、いきなり全否定。😲
というのも、Composition Functionとして切り出してしまうと、コンポーネントとしては視認性が悪くなる。
むやみに切り出すことを考えるより、切り出さないという選択も大事だという考え方。
コンポーネント自身の責務と、それ以外に切り出す
「コンポーネント自身の責務」とはなんだろうか?🤔
コンポーネントを大きく分けるとしたら、見た目と裏側で持つロジックの2つに分けることができる。そう考えると、コンポーネントの責務は、あくまで見た目なのではないだろうか。
たとえば、ボタンを押すと複雑な処理が動いて結果を出すようなコンポーネントがあったとする。
コンポーネントの責務とは「ボタンがあって、ボタンを押したら何かが出る」までが責務なのではないだろうか。裏側で持つロジックは切り出してしまえば良い。
そう考えると、以下のようなコードになるのではないだろうか。
<template>
<p>FizzBuzzじゃなかった数値の合計: {{ count }}</p>
<button type="button" :class="classes" @click="clickEvent">{{ label }}</button>
</template>
<script lang="ts">
import { defineComponent, Ref, ref, ComputedRef, computed} from 'vue'
import { useClasses } from '../composables/use-classes'
import { useFizzBuzz } from '../composables/use-fizzbuzz'
import { useClickEvent } from '../composables/use-click-event'
export default defineComponent({
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
number: {
type: Number,
required: true,
}
},
emits: ['randomNumber'],
setup(props, content) {
const count = ref(0)
const classes = computed(() => useClasses(props.color))
const fizzBuzz = computed(() => useFizzBuzz(props.number, count))
const label = ref(props.label)
const clickEvent = () => {
useClickEvent(label, props.number, fizzBuzz, content)
}
return {
label,
classes,
fizzBuzz,
clickEvent,
count
}
},
})
</script>
どうだろうか?🤔
「ボタンがあって、ボタンを押したら何かが出る」までをコンポーネント自身の責務とし、FizzBuzzの計算ロジックやColorからClassを決める裏側のロジックはComposition Functionとして切り出してみた。ただし、裏側の処理が各々Composition Functionとして切り出されたことで、視認性は悪くなった。
副作用のあるものと、ないものに切り出す
「副作用」とは何だろうか?🤔
式は、評価値を得ること(関数では「引数を受け取り値を返す」と表現する)が主たる作用とされ、
それ以外のコンピュータの論理的状態(ローカル環境以外の状態変数の値)を変化させる作用を副作用という
https://ja.wikipedia.org/wiki/副作用_(プログラム) より
今回のコードで言えば、FizzBuzzの部分でFizzBuzz以外の値の時に外側で宣言しているcountの状態を変化させているところだと言える。
というわけで、副作用のある部分だけを切り出してみる。
<template>
<p>FizzBuzzじゃなかった数値の合計: {{ count }}</p>
<button type="button" :class="classes" @click="clickEvent">{{ label }}</button>
</template>
<script lang="ts">
import { defineComponent, Ref, ref, ComputedRef, computed} from 'vue'
import { useFizzBuzz } from '../composables/use-fizzbuzz'
export default defineComponent({
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
number: {
type: Number,
required: true,
}
},
emits: ['randomNumber'],
setup(props, content) {
const classes = computed(() => ({
'is-red': props.color === 'red' ? true : false,
'is-green': props.color === 'green' ? true : false,
}))
const count = ref(0)
const fizzBuzz = computed(() => useFizzBuzz(props.number, count))
const label = ref(props.label)
const clickEvent = () => {
label.value = `FizzBuzz: ${props.number} => ${fizzBuzz.value.result}`
content.emit('randomNumber', Math.floor( Math.random() * 101 ))
}
return {
label,
classes,
fizzBuzz,
clickEvent,
count
}
},
})
</script>
どうだろうか?🤔
視認性が悪くならないように、副作用のあるロジックだけを切り出してみた。
副作用のあるロジックは個別にテストができるし、視認性も悪くない。
共通化、再利用できるものと、できないものに切り出す
共通化、再利用できるものを切り出す。Classを決めるロジックは他のボタンでも共通化できそうなので共通化し、それ以外は残す。
<template>
<p>FizzBuzzじゃなかった数値の合計: {{ count }}</p>
<button type="button" :class="classes" :value="fizzBuzz" @click="clickEvent">{{ label }}</button>
</template>
<script lang="ts">
import { defineComponent, computed, ref, toRef } from 'vue'
export default defineComponent({
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
number: {
type: Number,
required: true,
}
},
emits: ['randomNumber'],
setup(props, content) {
const classes = computed(() => useClasses(props.color))
const count = ref(0)
const fizzBuzz = computed(() => {
if (props.number % 15 === 0) {
return {
result: 'FizzBuzz',
count
}
}
if (props.number % 5 === 0) {
return {
result: 'Buzz',
count
}
}
if (props.number % 3 === 0) {
return {
result: 'Fizz',
count
}
}
count.value += props.number
return {
result: String(props.number),
count
}
})
const label = ref(props.label)
const clickEvent = () => {
label.value = `FizzBuzz: ${props.number} => ${fizzBuzz.value.result}`
content.emit('randomNumber', Math.floor( Math.random() * 101 ))
}
return {
label,
classes,
fizzBuzz,
clickEvent,
count,
}
},
})
</script>
どうだろうか?🤔
共通化できそうなロジックは、切り出すことを考えて良いと思う。
Composition Functionの目的が、Reactのカスタムフックと同様であれば、共通化できそうなロジックは積極的に切り出して問題ない気がします。汎用的な処理は、VueUseでもライブラリ化もされているため、ライブラリを積極的に使うのもありだと思う。
全部切り出す
Composition Functionをフル活用する。親側で呼び出す関数名を指定して、子側で実行する。
コンポーネントの裏側の処理はすべてComposition Functionとなった。
<template>
<p>FizzBuzzじゃなかった数値の合計: {{ count }}</p>
<button type="button" :class="classes" @click="clickEvent">{{ label }}</button>
</template>
<script lang="ts">
import { defineComponent, Ref, ref, ComputedRef, computed} from 'vue'
type useActionType = {
label: Ref<string>
classes: ComputedRef<{
'is-red': boolean;
'is-green': boolean;
}>
fizzBuzz: ComputedRef<{
result: string;
count: Ref<number>;
}>
clickEvent: () => void
count: Ref<number>
}
export default defineComponent({
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
required: true,
},
number: {
type: Number,
required: true,
},
action: {
type: Function,
required: true,
}
},
emits: ['randomNumber'],
setup(props, content) {
const action: useActionType = props.action(props, content)
return {
label: action.label,
classes: action.classes,
fizzBuzz: action.fizzBuzz,
clickEvent: action.clickEvent,
count: action.count
}
},
})
</script>
どうだろうか?🤔
すべてを切り出したことで、コンポーネント自身の責務と、それ以外で完全に分けることができた。コンポーネントとしては、ほぼテンプレートの部分しか持たないので、親側で呼ぶ関数を切り替えれば、コンポーネントの動きを容易に変更することもできる。
ただ、1つのコンポーネントに対して、複数の役割を持たせることは良いことなのだろうか?🤔
使い方は注意しなくてはならない気がします。
さいごに
Vue3初心者の目線でComposable Functionについてずらずらと書きましたが、ベストプラクティスは無さそうなので、複数人で開発する場合は切り出し方がバラバラにならない様に「何を?どの単位で切り出すのか?」を事前に共通認識を持っておいたほうが良さそうです。
最後に、Composable Functionが、Reactのカスタムフック(Vueでもカスタムフックと言う?)と同じようなものだというのは、こちらの記事 => Vue3のComposition APIでロジックをいい感じに切り分ける!カスタムフックのすゝめにて知りました。ありがとうございました。m(= =)m