VISASQ Dev Blog

ビザスク開発ブログ

Vue.jsにおけるcomposablesを切り出す個人的な考えをまとめてみた

こんにちは、クライアント開発チームの田中智之です!

さて、ビザスクではVue.jsを利用してフロントエンドを実装していますがcomposablesの使い方、難しくないでしょうか?

自分自身の解像度を高めるためにも、今回はVue.jsの中でもcomposablesに焦点を当てた記事を書いてみることにしました!

SFC(Single File Component)について

composabelsの話に入る前に、SFCについて触れさせてください。

SFCとは単一ファイルコンポーネントの章にあるように、単一ファイルでコンポーネントの振る舞いを実現するvueの記法になります。

現代の UI 開発では、コードベースを互いに織り交ぜる 3 つの巨大なレイヤーに分割するのではなく、それらを疎結合コンポーネントに分割して構成する方がはるかに理にかなっていることが分かっています。コンポーネント内では、そのテンプレート、ロジック、およびスタイルが本質的に結合されており、それらを連結することで、実際にコンポーネントがよりまとまり、保守しやすくなります。

上記からはSFC内でロジックを閉じることを推奨しているようにも読み取れます。

composablesについて

ひとまずcomposablesが生まれた背景を理解するのが良さそうです。

vue公式ドキュメントのコンポーザブルの章を見てみます。

フロントエンドアプリケーションを構築するとき、共通のタスクのためにロジックを再利用しないといけないことがよくあります。例えば、多くの箇所で日付をフォーマットする必要があるので、そのための再利用可能な関数を抽出します。このフォーマッターは状態のないロジックをカプセル化し、ある入力を受け取ったら即座に期待される出力を返します。状態のないロジックを再利用するためのライブラリーはたくさんあります。例えば lodash や date-fns などは聞いたことがあるかも知れません。 対照的に、状態のあるロジックは時間とともに変化する状態の管理が伴います。ページ上のマウスの現在位置をトラッキングするようなものがシンプルな例といえます。実際のシナリオでは、タッチジェスチャーやデータベースへの接続状態など、より複雑なロジックになる場合もあります。

なるほど、ライブラリを交えつつ説明しているのがポイントでしょうか。 状態を持った再利用可能なロジックcomposablesと理解して良さそうです!

実践編

inputv-modelcomposabelsを試す

早速、よくある例でcomposabelsを試してみましょう。

inputv-modelcomposabelsを試してみます。

// Input.vue
<script setup>
import { ref } from 'vue'

const message = ref('Awesome Input Message')
</script>

<template>
  <h1>{{ message }}</h1>
  <input v-model="message" />
</template>

これをcomposabelsにしてみます。

import { ref } from 'vue'

// composabelsはuse~という命名にするのが慣習
export const useInputMessage = () => {
  const message = ref('Awesome Input Message')
  return {
      message: message
  }
}
// Input.vue
<script setup>
const { message } = useInputMessage();
</script>

<template>
  <h1>{{ message }}</h1>
  <input v-model="message" />
</template>

いかがでしょうか。
inputrefは結合度が高いためSFCの考えである下記と乖離してしまうように思います。

コンポーネント内では、そのテンプレート、ロジック、およびスタイルが本質的に結合されており、それらを連結することで、実際にコンポーネントがよりまとまり、保守しやすくなります。

もう少し、抽象的で再利用可能なロジックをcomposablesにしたいところです!

バージョンを切り替えるcomposabelsを試す

「最新」と「全件」の表示を切り替えるcomposabels*1を考えてみます。

今回は歴代ポケットモンスターを世代ごとに分類分けする仕様を例としてみます。 最終的なUIのイメージは下記です。

以下がcomposabelsです。

コードの肝は以下でしょうか。

  • T extends Record<string, unknown> & { version: number }を受け取る
  • MaybeRefで定義することでreactiveを受け取れるようにしておく
export const useVersion = <
  T extends Record<string, unknown> & { version: number },
>(
  records: Readonly<MaybeRef<T[] | undefined>>, // `Readonly`で利用側には`immutable`であることを明示しておく
) => {
  /** 最新のversionか全件かに応じてフィルタするcomposables */
  const displayVersion = ref<"latest" | "all">("latest");
  const showAll = () => (displayVersion.value = "all");
  const showLatest = () => (displayVersion.value = "latest");
  return {
    records: computed(() => {
      if (!records.value) return [];
      const latest = Math.max(
        ...records.value.map((record) => record.version),
      );
      return displayVersion.value === "latest"
        ? records.value.filter((record) => record.version === latest)
        : records.value
            .filter((record) => record.version > 0)
            .sort((a, b) => (a.version < b.version ? 1 : -1));
    }),
    displayVersion,
    showAll,
    showLatest,
  };
};

利用側は下記です。

// versionの分類分けは世代ごと(eg. version=1は第1世代)
const { records, showAll, showLatest } = useVersion([
  { id: 1, title: "ポケットモンスター 赤・緑", version: 1 },
  { id: 2, title: "ポケットモンスター 青", version: 1 },
  { id: 3, title: "ポケットモンスター ピカチュウ", version: 1 },
  { id: 4, title: "ポケットモンスター 金・銀", version: 2 },
  { id: 5, title: "ポケットモンスター クリスタルバージョン", version: 2 },
  { id: 6, title: "ポケットモンスター ルビー・サファイア", version: 3 },
  {
    id: 7,
    title: "ポケットモンスター エメラルド",
    version: 3,
  },
  {
    id: 8,
    title: "ポケットモンスター ダイヤモンド・パール",
    version: 4,
  },
  {
    id: 9,
    title: "ポケットモンスター プラチナ",
    version: 4,
  },
]);

....省略......

今回はポケモンを例に取りましたが、Record<string, unknown> & { version: number }の型に当てはまるものは同様の分類分けが可能になります!

実践編のまとめ

フロントエンドの実装では何かしら外部のロジックを利用することになるのがほとんどかと思います。 簡単な図にすると下記のようなイメージでしょうか。

多くの場合、SFC内ではライブラリ, utils, composabelsを組み合わせてロジックを組み立てていくことになるわけですが、自分の中では、自前で実装することになるutils, composabelsを切り出す際には下記の2点が重要な観点なのではと思っています。

  • 抽象度の高さ(≒再利用性の高さ)
  • 信頼性の担保

この二つは無関係ではなく、「抽象度が高い(≒再利用性が高い)」ため「信頼性の担保」が必要になる、ということになるのではないでしょうか? また、utilscomposabelsは状態を持っているか否かが大きな違いと言えるかもしれません。

そのため、切り出したcomposabelsはテストを書いた方がベターかと思いました。
むしろ、テストが書きづらい場合は以下の考慮が必要だという印象です!

  • composablesの処理が具体的すぎないか
  • composablesではなく<script />内に記述すべきではないか

その他のケースを考える

コードの分割目的

コード整理のためのコンポーザブル抽出 に記載のある通り、

  • コードが肥大化した
  • 論理的な関心でロジックを分割できる

というような場合に、composabelsに切り出すというケースもありそうです!

レンダーレスコンポーネントという選択肢

状態を持ったロジックの切り出しにはレンダーレスコンポーネントという手段もあり、scoped slotsを用いてテンプレート内に閉じて状態を扱えるという長所があります。

ただしレンダーレスコンポーネントレンダリングコストが高くなることが多いため、不必要に利用するべきではなさそうです。 詳しくは下記を参照してください!

ここまでで

Vue.jsでは、例えばRuby on RailsRails WayやReactにおけるReduxのように、明確な設計方針を提示してないため、開発者側で意図を汲み取り実装/設計を進める必要があります。

本記事はVue.jsのコアメンバーのお一人の記事からの一節を引用しつつ締めさせていただければと思います!

https://ublog.dev/blog/vue-is-approachable-ja

Vue.js は Approachable なのだ. 何かを考える時,邪魔にならない. しかし,それと引き換えに実装者は少しの責任を負う. これはトレードオフなのだ.

終わりに

過去には弊社エンジニアが書いた Vue.js のソースコードを読んでみよう(ref/reactive 編) - VISASQ Dev Blog というようなVue.jsに関する記事もあるのでぜひ読んでみてください!

また、ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!

https://developer-recruit.visasq.works/

*1:全件表示時のソートは見逃してください