Composition APIとロジックとコンポーネント
Composition APIの登場により、ロジックの大部分をコンポーネントの外に書くことができるようになりました。 ライフサイクルフックでの処理をコンポーネントの外にもかけるようになったのは大きな変化のひとつです。
それはそれとして、いつの時代も権利と義務はセットでもたらされます。 ロジックをどこに書いてもよいという自由を得た一方で、それをどこに書くべきかを考えなければいけなくなりました。
この記事を書いた時点では、全部をコンポーネント内に書くのはComposition APIの意味がないなと思いますし、 なんでもかんでもコンポーネントからロジックを分離するのも違うなと思ってます。
そのロジック、どこに書く?
結論は上に書いたとおりですが、念のため両極端な例について考えてみましょう。
全部setup()の中に書く
全部setup()の中に書く作戦は「そのファイルを見ればコンポーネントの動作が(ほぼ)全てわかる」という単一ファイルコンポーネントのメリットを最大限享受できます。 しかし、Composition APIから得られる恩恵は小さくなってしまいます。
外部ライブラリをラップしただけ、みたいな明らかに再利用すべきロジックを逐一setup()の中に書いていくのは非常にいただけません。 Vue 3.0の単体テストとはまだ真剣に向き合ってませんが、たぶんコンポーネントの中に書かれていると無駄にテストに苦労するみたいな弊害も生まれそうな気がします。
全部別ファイルに書いてimportして使う
これができれば本当に嬉しかったと思います。 何度か挑戦したのですが、残念ながらうまくいかなかったので断念しました。
とくに、エラーハンドリングをする場合とrouterを使う場合の2つが問題になります。 これらのロジックはコンポーネントと密接に関連しているため、コンポーネント外に分離しようとするのは合理的とはいえません。
どこになにを書くかの一例
というわけで、ロジックはコンポーネント内外にいいかんじに振り分ける必要があります。
エラーハンドリングの例
まずはエラーハンドリングをやる例をみてみましょう。 なんらかのロジックを実行し、エラーが発生したらエラーメッセージを表示するような例です。
<template>
<div v-if="errorMessage">{{ errorMessage }}</div>
<button @click="onClickHoge">Hoge</button>
<button @click=onClickFuga>Fuga</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { doHoge, doFuga } from '~/compositions/sample'
export default defineComponent({
setup() {
const errorMessage = ref<string | null>(null)
const onClickHoge = () => {
try {
// なんらかのビジネスロジックの実行 (1)
doHoge()
} catch (error) {
errorMessage.value = error.message
}
}
const onClickFuga = () => {
try {
// なんらかのビジネスロジックの実行 (2)
doFuga()
} catch (error) {
errorMessage.value = error.message
}
}
return {
errorMessage,
onClickHoge,
onClickFuga,
}
}
})
</script>
コンポーネント内にエラーの発生箇所が複数ある場合に、エラーメッセージを保持する値は共通のものを利用する前提で書いています。 エラーと同じ数だけメッセージを保持する変数を増やすことによりエラー処理をcompositionメソッド内に押し込むことはできますが、 エラーメッセージをうまく統合して表示するロジックを書くことになりあまり良い結果にならないのでやめたほうがいいでしょう。
また、この例では単純に投げられたエラーからメッセージを取得していますが、業務アプリケーションではcatch節でもっと複雑な処理をすることも想定されます。 そういった処理は往々にしてコンポーネントと密接に関連しているので、このようなスタイルで書くとうまく責務が分離できるのではないかと思います。
Routerを使う例
続いて、Routerを使う例です。
<template>
<button @click="onClickHoge">Hoge</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useRouter } from 'vue-router'
import { doHoge } from '~/compositions/sample'
export default defineComponent({
setup() {
const router = useRouter()
const onClickHoge = () => {
try {
doHoge()
router.push({ name: 'next' })
} catch (error) {
router.push({ name: 'error' })
}
}
return {
onClickHoge,
}
}
})
</script>
これもRouterをdoHoge()に押し込むことはできなくないのですが、「処理が終わった後にどのページに遷移するか」はやはりコンポーネントの都合によるところが大きいので、 コンポーネント側に書いておくべきと考えます。 単一責任の原則から言葉を借りてくれば、doHoge()のような処理が「ビジネスロジックの実行」と「画面の遷移」という2つの責務をもつのはよろしくないと言えます。
余談: ライフサイクルフックはどうなの
一見コンポーネントに張り付いていそうなライフサイクルフックですが、とくにPromiseを処理する場合などデータの初期化のタイミングが偶然onBeforeMountだと都合がよいみたいなケースもあります。 一概にライフサイクルフックだからコンポーネント側に書こうというのはやはり誤りで、その処理が本当にコンポーネントに依存しているのか、 それとも都合の良いタイミングを使っているだけなのかはよく考えてくださいませ。
まとめ
結局のところ、あるロジックをコンポーネント内に書くか外に書くかは、そのロジックがコンポーネントと密接な関連を持つか持たないかで切り分けていくしかなさそうです。 実のない結論でごめんなさい。
まだ機械的に判定できるほどのユースケースは見ていないので「こうすればいいよ!」という話をズバリできない点に関しては心苦しいのですが、 SOLIDの各原則(特に単一責任の原則)を念頭に起きながらやればそこそこうまくいくのではないかと思います。