hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

肥大化する Nuxt の Composables 関数をどう小さく設計するべきか



みゅーとんです。どうも

最近個人開発で Astro に凝っています. no-js いいですね, ウェブサイトが爆速で描画されるのは本当にすばらしい. CMS 運用前提の静的サイトはみんなこうあってほしいですね.
個人開発だといくらでも時間をかけて LightHouse スコアを 100 点満点に近づけることができるので, とても挑戦意欲が湧いて楽しいです.

TL;DR

対象読者

  • Vue3+, Nuxt3+ のコード設計に悩む人

3 行でまとめ

  • 複数ページにまたがって引き回さざるを得ないグローバル変数を一挙に composable 関数としてまとめてしまうと, 将来的に神 composable になってしまう.
  • 状態の定義・値の書き換え・取得など単一処理の関数をそれぞれ composable として定義すると安全に書ける
  • 結果的に, Flux アーキテクチャをフレームワークなしで導入する形に最終的に近づいていく


背景

hacomono の POS レジアプリのフロントエンドの技術選定として選んだのは当時 Nuxt3 の RC 版でした. 当時は hacomono 本体側が CompositionAPI に追いついていない状況であり Nuxt3 で初めて CompositionAPI に触れ, ビジネスロジックの分離のしやすさにはかなり感動していた覚えはあります.

それも, リリース後に機能がどんどん追加していくと, 結局 useCart のような関数は肥大化を重ね, 収集がつかなくなっていくつかの機能ごとに分割する必要性が出てきました.
以下の interface は現時点での useCart の返り値の型です. これでも, 過去に肥大化しすぎた useCart を複数の composable に分割した後の状態でこの規模です.

interface CartCompose {
  setCartMode(mode: CartMode): void
  reset(): Promise<void>
  isEmpty: ComputedRef<boolean>
  totalQuantity: ComputedRef<number>
  totalPrice: ComputedRef<number>
  totalDiscount: ComputedRef<number>
  tax: ComputedRef<number>
  hasType(type: ItemType): boolean
  hasCartItem(itemRef: ItemReference, opt?: { searchBundles?: true }): boolean
  hasCartPlanItem(itemRef: ItemReference, opt?: { searchBundles?: true }): boolean
  hasSamePlanContractGroupNo(contractGroupNo: number): boolean
  hasItemInBundle(item: Item): boolean
  addCartItem(item: DeepReadonly<Item>): Promise<void>
  getCartItem(itemId: string): DeepReadonly<ItemInCart | undefined>
  getBundlesInCart(): DeepReadonly<ItemReference[]>
  removeCartItem(itemId: string): Promise<void>
  removeCartItemsByTypes(types: ItemType[]): Promise<void>
  setCoupon(coupon: CouponViewModel): Promise<void>
  resetCoupon(option?: CartUpdateOption): Promise<void>
  hasSomeCouponDiscount(): boolean
  getAppliedCouponCodes(): string[]
  setManualDiscount(manualDiscount: ManualDiscount): Promise<void>
  resetManualDiscount(option?: CartUpdateOption): Promise<void>
  addOrUpdateSingleDiscount(singleDiscount: SingleDiscount): Promise<void>
  removeSingleDiscount(targetItemCode: string, option?: CartUpdateOption): Promise<void>
  canSetSingleDiscount(item: DeepReadonly<Item>): boolean
  singleDiscounts: ComputedRef<SingleDiscount[] | undefined>
  coupon: ComputedRef<CouponDiscount | undefined>
  manualDiscount: ComputedRef<ManualDiscount | undefined>
  getManualDiscount(): Fluctuation | undefined
  setQuantity(itemId: string, quantity: number): Promise<boolean>
  setPrice(itemId: string, price: number): Promise<void>
  calculate(cart: Cart): Promise<Cart | undefined>
  simulate(cartOverwrite?: (cart: Cart) => Cart): WrappedAsyncData<CalculateResult>
  resetPrice(): void
  resetRemoveTarget(): void
  setRemoveTarget(newValue: RemoveTarget[]): void
  removeSelected(): Promise<void>
}

これを返す useCart, 想像したく有りませんね.

export function useCart() {
  return {
    /**
     * ...
     * [!] 精神的負荷を軽減するためにコードを省略しています.
     * ...
     */
   }
}
Composables 設計における Bad Practice

経験則によるものではありますが, useCart のようなドメインロジックを丸ごと返すファサード型の Composable は, どう設計していたとしても Bad Practice になり得ます. 利用側は以下のようになります. 神 Composables ですね. かなりひどいです. これを私が作りました. ギルティです.

const { setPrice, coupon, setCoupon, totalQuantity, /* .... */ } = useCart()

重要なのは,use[DomainName] という命名でドメイン全体を内包する設計自体が不適切だったと認め, それをどう分解するかです.

問題のコードを分析する

実際にどう useCart が書かれているか, 簡易版を用意しました.

function useCart(): CartCompose {
  const state = createState()
  
  return {
    setCartMode: createSetCartMode(state),
    reset: createReset(state),
    isEmpty: createIsEmpty(state),
    /* ... */
  }
}
function createIsEmpty(state: CartState): CartCompose['isEmpty'] {
  return computed(() => state.items.value.length <= 0)
}


雑に眺めてみてわかるのは, 元凶は隠蔽された state にある, ということです. この構造により, すべてのロジックがその state に依存する形で, 密結合にせざるを得なくなっていました.
createIsEmpty のような内部関数は一見すると責務が分散されているように見えますが, state そのものが useCart 外からアクセスできないため, 結局 useCart を通じてしか呼び出すことができず, 独立しているとは言えない状況です.

ステートの定義は, それ単体として composables 関数として切り出すべきです. これができれば, 自ずとすべての関数が useCart を介さずに関数として独立可能であることもわかります.

export function useCartState(): CartState {
  return {
    items: useState('cart-items', () => []),
  }
}
export function useIsCartEmpty(): ComputedRef<boolean> {
  const { items } = useCartState()
  return computed(() => items.value.length <= 0)
}


設計原則の確立と責務の再定義

state を分割することで, useCart のような一つの大きなファサードは不要になります.
useCart が担っていた責務を以下の 4 種に分解し, それぞれに設計原則を定めました.

1. 状態プロバイダー (state provider)
  • 責務 .. リアクティブな状態を生成し, 提供するだけを担う. ドメインにおける唯一の情報源となりうる.
  • 命名規則 .. use[Domain]State (e.g. useCartState useProductsState )
// src/composables/cart/internals/useCartState.ts
export function useCartState(): CartState {
  return {
    items: useState('cart-items', () => []),
    coupon: useState('selected-coupon', () => undefined),
  }
}
2. アクション (action)
  • 責務 .. 状態プロバイダーが提供する状態を変更するだけを担う. 状態への書き込みは必ずこのアクションを経由して行う.
  • 命名規則 .. use[ActionVerb]... (e.g. useAddItemToCart or useAddCartItem )
// src/composables/cart/useAddItemToCart.ts
export function useAddItemToCart(): (item: Item) => void {
  const { items } = useCartState()
  // 本来は "すでに cart に同じ item があるかどうか ?" などの処理がある
  
  return (item: Item) => {
    items.value = [...items.value, item]
  }
}
<script setup lang="ts">
const add = useAddItemToCart()
// ...
</script>
<template>
  <!-- ... -->
  <button @click="add(item)"> カートに追加 </button>
  <!-- ... -->
</template>
3. ゲッター (getter)
  • 責務 .. 状態から値を算出した結果を computed として返却する.
  • 命名規則 .. use... (e.g. useCartIsEmpty )
// src/composables/cart/useCartItems.ts
// state を返すだけの関数も getter として定義する
export function useCartItems(): ComputedRef<Item[]> {
  const { items } = useCartItems()
  return readonly(items)
}
// src/composables/cart/useCalculateCartTotal.ts
// 純粋関数にビジネスロジックを切り出し, それをリアクティブとして呼び出すだけの関数として定義しても良い
export function useCalculateCartTotal(): ComputedRef<CartTotal> {
  const { items, coupon } = useCartItems()
  return computed(() => calculateCartTotal({ items: items.value, coupon: coupon.value }))
}
4. 純粋関数 (util)
  • 責務 .. リアクティブに依存せず, 純粋なドメインロジックを提供する. 引数に与えられたデータを元に計算や変換をするのみ.
  • 命名規則 .. prefix なし (e.g. calculateCartTotal simulateCartTotal )
// src/utils/cart/calculateCartTotal.ts
export function calculateCartTotal({ items, coupon }: Cart): CartTotal {
  // 実際は税率計算などが含まれるが, 今回は省略
  let total = items.reduce((acc, item) => item.price * item.currency, 0)
  
  if (coupon) {
    total = total * coupon.discountRate
  }
  
  return total;
}
命名規則の根拠

命名規則として use を prefix とするかどうかについては, Vue の composables の説明に則ったルール付けを考えています.

Vue アプリケーションの文脈で「コンポーザブル(composable)」とは、Vue の Composition API を活用して状態を持つロジックをカプセル化して再利用するための関数です。

つまり, 状態 (ref, computed, useState など) に依存する関数は composable なので use をつける. そうでない関数は composable ではなく純粋な関数なので use をつけない. このシンプルな原則に従うだけです.

コードベースへの適用と composable の隠蔽

Nuxt の仕組みとしては, composables 配下は auto-import の対象となります.
この仕組みに則るのであれば 状態プロバイダーにあたる composable は auto-import されないように設定しておくと, より安全です.

雑なアイデアではありますが, こういうケースは internals のような “そこでしか使われないことがわかる名前” のフォルダを分けてしまうのが一番手っ取り早いです. また, auto-import 対象とするかどうかの判定に “internalsを含まないパス” のような設定ができるはずです.

Nuxt の思想/仕様に則るなら, 上記の 4種の関数は以下のとおりに配置されます.

composables/
└── cart/
        ├── internals/
        │   └── useCartState.ts       # 1. 状態プロバイダー
    ├── useAddItemToCart.ts       # 2. アクション
    └── useCartItems.ts           # 3. Getter (ComputedRefを返す)
utils/
└── cart/
    └── calculateCartTotal.ts     # 4. 純粋関数


まとめ

use prefix の関数でドメインロジックを固めればいいんだな!” という発想は, 巨大な Composable を生み出す罠に陥りがちです. その本質はドメインを固めることではなく, “状態をもつ再利用可能なロジックのカプセル化” です.

最終的にたどり着いた「状態プロバイダー」「アクション」等という設計は, 奇しくも、Flux アーキテクチャ(特に Pinia)の思想に非常に近いものであると感じました.

私は redux, vuex がすごく嫌いでしたが, この構造は嫌いではないです. Composition API を使うことで、外部ライブラリの規約に縛られることなく、ただの TypeScript / JavaScript の関数として、Flux の健全な部分だけを自然な形で導入できていたことになります. 単一責任の原則を意識し, composables utilsといった Nuxt の規約に則り, 内部でしか使用しない関数を internals としてカプセル化していけば自ずと Flux ライクな健全なアーキテクチャに近づいていくと言ってよいのかもしれないです.

今回私が確立した設計指針は, この Vue の思想の原点に立ち返った, 非常にシンプルなものでした.この責務分離のアプローチが, 読者の Nuxt プロジェクトをより健全で, メンテナンスしやすいものにするための一助となれば幸いです.



💁 関連記事