こんにちは、ハコベル事業本部ソリューションスクラムチームの大川です。 この記事はRaksul Advent Calendar 2021 18日目の記事です。
ソリューションスクラムチームではハコベルコネクト を開発・運用しています。 ハコベルコネクトは物流における配送依頼や配車業務などをWeb上で管理することで 情報共有の効率化や一部作業の自動化により配車工数削減を実現するためのサービスです。 自分はチームの中でフロントエンドエンジニアとしてUIの開発をしています。
先月入社したばかり&これまで主にReactを使ってきて仕事でVueを使うのは初めてですが、 Composition APIのsetup関数をいい感じに分割するアイデアを考えてみました。 アイデア自体はちょっとしたことですが、同じ課題感を持つ方の参考になれば幸いです。
背景
ハコベルコネクトのUIはVue (v2) で実装されており、 新規作成するコンポーネントやページはComposition APIを利用する方針です。 (v2向けのプラグインを利用しています)
Composition APIは従来のOption APIと比較して、関心事に沿ってロジックを分離・集約できて可読性が向上し、 再利用可能な関数を切り出すことができて便利だと感じています。 一方で、setup関数に全ロジックを集約すると関数全体が冗長になり、 別のcomposition関数に切り出そうとしても再利用性を意識できていないことがありました。
例えば、あるフォームの実装ではページのライフサイクルフックを設定する関数と フォームのイベントハンドリングを設定する関数で分けましたが、 それぞれの分割したcomposition関数がVuexやローカルのステート・Vueのライフサイクルに依存しておりページ固有のものとなっていました。 また、setup関数の実装方針が明確になかったので、ロジックの組み方や分割方法が開発者によってバラバラになりがちでチームでの共通認識を作りにくいのも気になっていました。
考えた方針
- composition関数の分割時に再利用性を意識できていなかった
- setup関数の実装方針に対して共通認識を作りにくい
という課題に対して「setup関数をusecaseとserviceに分ける」方針を考えました。 それぞれの役割は以下のとおりです。
- setup関数: serviceとusecaseを呼び出し、プロパティとライフサイクルフックを設定する
- usecase: serviceを引数で受けて、そのコンポーネント(ページ)のロジックを実装する
- service: usecaseが必要とするVuexやローカルのステートとアクションを用意する
- Vuexと連携するservicesと、ページのローカルステート管理用のserviceの2つに分かれる
サンプルコードを書いてみる
ユーザーが登録した住所の一覧を表示するページを例に、 上の方針でページ遷移時の以下2つのロジックを実装してみます。
- 住所の一覧を取得するイベントハンドラー
- 住所の一覧を取得するAPIをリクエスト
- APIがエラーを返したらエラーメッセージを表示
- 住所の一覧を取得するAPIをリクエスト
- 初期化処理ができているかのフラグ
- コンポーネントの表示切り替えに利用する
特に分割せずにsetup関数で実装すると以下のようになります。
import { defineComponent, computed, ref } from '@vue/composition-api' export default defineComponent({ setup (_, ctx) { // ページに必要なデータをロードできてから描画するためのフラグを管理する const isInitialized = ref(false) // Vuexのストアを取得 // NOTE: コンテキストのrootオブジェクトはComposition APIのプラグインでのみ利用可能です const store = ctx.root.$store // アプリ全体でローディング表示を管理しているプラグイン const loading = ctx.root.$loading // 住所の一覧をVuexのステートから取得 const locations = computed<LocationState["locations"]>( () => store.getters["locations/locations"] ) // 初期化処理 const onCreated = async () => { const { error } = await loading( store.dispatch("locations/fetchLocations", 1) ) if (error) { store.dispatch('app/addError', { message: '場所情報の取得に失敗しました' }) } isInitialized.value = true } // DOM操作がないのでonMountedを使わず初期化処理を実行 onCreated() return { isInitialized, locations, } }, })
これだけだと問題があまりなさそうですが、 たとえばページに他の機能(検索や住所情報の削除)があった場合はさらにsetup関数が長くなりそうです。 また、onCreated関数の中でVuexアクションのディスパッチとローカルステートを更新しており、 この関数を別ファイルで実装するだけでは他のページやコンポーネントでの再利用性はなさそうです。
それでは、このコードをusecaseとserviceに分ける方針で分割してみます。 まずは、serviceを用意します。 appService.ts: エラーメッセージ表示用のVuexのステートと連携する
// appService.ts import type { SetupContext } from '@vue/composition-api' export const useAppService = (ctx: SetupContext) => { const store = ctx.root.$store return { addError: (message: string) => store.dispatch('app/addError', { message }), } }
locationService.ts: 場所の一覧を取得するVuexのステート・アクションと連携する
// locationService.ts import { SetupContext, computed } from '@vue/composition-api' import type { LocationState } from '@/models/location' export const useLocationService = (ctx: SetupContext) => { const store = ctx.root.$store const loading = ctx.root.$loading const locations = computed<LocationState["locations"]>( () => store.getters["locations/locations"] ) const fetchLocations = (page: number) => { return loading( store.dispatch("locations/fetchLocations", page) ) } return { locations, fetchLocations, } }
pageStateService.ts: ページ(コンポーネント)用のステートを用意する
// pageStateService.ts // コンポーネントと同じディレクトリで実装する import { ref } from '@vue/composition-api' export const usePageStateService = () => { const isInitialized = ref(false) const setIsInitialized = (newStatus: boolean) => { isInitialized.value = newStatus } return { isInitialized, setIsInitialized, } }
続けて、usecaseを用意します。
// locationUsecase.ts // コンポーネントと同じディレクトリで実装する type UseInitializerParams = { fetchLocations: (page: number) => Promise<{ error?: Error }> addError: (message: string) => void setIsInitialized: (status: boolean) => void } export const useInitializer = (params: UseInitializerParams) => { const { fetchLocations, addError, setIsInitialized, } = params const onCreated = async () => { const { error } = await fetchLocations(1) if (error) addError('場所情報の取得に失敗しました') setIsInitialized(true) } return { onCreated, } }
最後にsetup関数でserviceとusecaseを呼び出し、プロパティとライフサイクルフックを設定します。
import { defineComponent } from '@vue/composition-api' import { useAppService } from '@/services/appService' import { useLocationService } from '@/services/locationService' import { usePageStateService } from './pageStateService' import { useInitializer } from './locationUsecase' export default defineComponent({ setup (_, ctx) { // service呼び出し const { isInitialized, setIsInitialized, } = usePageStateService() const { addError, } = useAppService(ctx) const { locations, fetchLocations, } = useLocationService(ctx) // usecase呼び出し const { onCreated, } = useInitializer({ fetchLocations, addError, setIsInitialized, }) // 初期化処理を実行 onCreated() return { isInitialized, locations, } }, })
上記の例以外にもいくつかサンプルコードを書いてみて、以下のような気付きがありました。
(よかった点)
- setup関数でロジックを直接実装していないので可読性が上がった
- usecaseがVue / Vuexに依存しないので、テストしやすくなりライブラリなどの変更に強くなった
- API呼び出しのserviceはページのロジックとは分離できたので、再利用性が上がった
- usecase / serviceという分け方を明文化できたので、チームで認識あわせやすくなった
(改善点)
- serviceの実装方針が決まっていない
- Vuexのserviceとローカルステートのserviceが別のディレクトリで定義されている
- ローカルステートは直接setup関数で、Vuexのserviceはドメインモデルごとに定義したほうがよさそう
- usecaseのロジックが複雑にならないときは、分割して定義するメリットが大きくない
- setup関数でserviceを利用するだけでもよさそう
- usecaseがリアクティブな変数を参照していないので、setup関数での結合が必要になる
- refの値をusecaseの引数にしてイベントハンドラーを定義すると、refの初期値でハンドリングされてしまう
最初に感じていた課題に対しては一定の効果がありそうですが、 serviceの実装方法などは引き続き改善していく必要がありそうでした。
さいごに
今回は可読性や再利用性の向上とチームでの共通認識をあわせてみるためのアイデアとして、 Composition APIのsetup関数をusecaseとserviceで分ける方法を紹介しました。 まだアイデア段階なので、今後もComposition APIのメリットを意識して改善し続けていこうと思います。 他にもいい方針があればぜひ教えて下さい!
ハコベルではエンジニアを絶賛募集中です! 物流業界のDXを加速させる!ハコベル事業を牽引するテックリード候補を募集! | ラクスル株式会社