🏰

らくしふのVue.jsアプリケーション設計

2023/12/20に公開

はじめに

株式会社クロスビットのメインプロダクトである「らくしふ」のフロントエンド実装にはVue3を採用していますが、Vueエコシステムの変遷などに伴い、色々な実装方法を導入してきた歴史があります。具体的には、

  1. RailsのERBによるテンプレート + JavaScript 時代
  2. Vue2 + JavaScript + Vuex 時代
  3. Vue2 + TypeScript + Vuex 時代
  4. Vue3 + TypeScript + Composition API 時代
  5. Vue3 + TypeScript + Composition API + VuexORM 時代
  6. Vue3 + TypeScript + Composition API (setup script) 時代

という歴史があります。ただし、時代が変わる度に毎回全コードを新しい実装方法にリファクタリングしているわけではなく、機能開発のついでに必要に応じて都度改修をしています。(例えば今現在でもごく一部ですがERBテンプレートが残っていたり、あるいはVueの defineComponent 関数を使っていたりしますが、常にROIを考慮して改修判断をしています。)

今回は主に上記6番にあたる今現在において採用しているVueアプリケーション設計について説明していきます。構成としては、

  • Vue3
  • composition api (setup script記法)
  • TypeScript

を前提にしています。

設計コンセプト

現在の設計コンセプトを一言でいうと「Vue componentや状態の公開範囲を必要最小限にすることで、依存関係を把握しやすくする」です。

つまり、

  • ある Vue component のファイルパスを見て、それがどこから依存されているのか(=使用されているのか)がなんとなく分かる
  • ある state の定義を見て、それがどこから依存されているのか(=使用されているのか)がなんとなく分かる

ということです。

いかにも最初から明確にコンセプトが決まっていたような言い方ですが、実際のところそうではなく、これから説明する「ディレクトリ構成」「状態管理」を採用した結果として、このコンセプトが後付けで見えてきたいうのが正直なところです。

それでは以下で「ディレクトリ構成」「状態管理」について説明します。

ディレクトリ構成

/views

Vue router でルーティングするページの Vue componentを配置します。

例)

/views/foo/index.vue    // fooというページの Vue component

しかし index.vue を配置するだけに留めないのがポイントです。このページでのみ使用することが明らかな Vue component や compositionファイル、複雑なビジネスロジックを記述したピュアなTypeScriptファイルについては全てこの /views 配下に置きます。これにより各ファイルは /foo 内でのみ使用されることがぱっと見で分かるようになります。結果的にシンプルなページであればこの /views ディレクトリだけでほぼ実装ができてしまうことになります。

例)

/views/foo/index.vue                  // fooというページのVue component/components/Hoge.vue        // foo配下の各ページで使う可能性がある Vue component/composables/useFuga.ts     // foo配下の各ページで使う可能性があるcompositionファイル
/views/foo/bar/index.vue              // fooの子ページのVue component/components/Bar.vue     // bar配下でのみ使うVue component
/views/foo/baz/index.vue              // fooの子ページのVue component/composables/useBaz.ts  // baz配下でのみ使うcompositionファイル/domains/decorator.ts   // baz配下でのみ使うロジックファイル

正直、/views という名前よりは例えば(Nuxt.jsにおける)/pages の方がより明示的でしっくりくるなと思っているのですが、そこは歴史的経緯により /views となっています。(慣れの問題)

/components

異なるページで共通利用する Vue component を配置します。

例)

/views/foo/index.vue  // fooというページのVue component
/views/bar/index.vue  // barというページのVue component
/components/Hoge.vue  // アプリケーション全体の各ページで使う可能性があるVue component

上記例では foobar という全く異なる(親子関係がない)ページが存在します。それらで共通利用したい Vue component を配置することになります。

Atomic design でいうところの atomsmolecules あたりまでがここに配置されるイメージです。organisms までいくと個別具体性が強いので、結果的に前述した /views 配下に収まる印象です。ただしらくしふでは現在 element-plus という vue component ライブラリを使用しているので、自前実装の atoms 相当のものはほぼ存在していません。

/composables

異なるページで共通利用する compositionファイル を配置します。

例)

/views/foo/index.vue     // fooというページのVue component
/views/bar/index.vue     // barというページのVue component
/composables/useHoge.ts  // アプリケーション全体の各ページで使う可能性があるcompositionファイル

現状、らくしふ特有のドメイン知識的な state を扱うものが少数存在している(本当はもっと増えても良いのかもしれない)のに加えて、例えば、

  • 引数で渡した boolean 値を state として保持して、簡単にトグルできるようにするもの
  • リストデータを state として保持して、簡単に無限スクロールをできるようにするもの

といった便利系 compositionファイルが存在しています。

/domains

異なるページで共通利用するロジックファイルなどを配置します。ピュアなTypeScriptとして書くことで、単体テストを書きやすくしています。代表的には、

  • api.ts
    • らくしふバックエンドAPIを呼び出すためのクライアントコード
    • 基本的には、OpenAPI generator が自動再生したコードを呼ぶだけ
  • decorator.ts
    • 引数として渡した値を元に、画面表示するための文字列を返す関数
  • validator.ts
    • 引数として渡した値を元に、様々なバリデーション判定を行い、boolean 値を返す関数
  • types.ts
    • 型定義ファイル
  • constants.ts
    • 定数定義ファイル
  • その他の関数(sortHoge.tscalcHoge.ts など)
    • あるビジネスロジックを実装した関数

といったものがあります。具体的な配置例としては、

/domains/foos/api.ts/decorator.ts/types.ts
/domains/bars/api.ts/validator.ts/sortBars.ts

のようになります。 foosbars のところは取り扱うものの名前(REST API におけるリソースのようなイメージ)になります。

/stores

いわゆるグローバルな状態管理を行うファイルを配置します。現在は pinia というライブラリを使った実装ですが、過去には vuex ライブラリを使用した実装を行なっていました。

詳細は後述の Global state のところで説明します。

/utils

いわゆる便利関数を配置します。後述する prepareInjection といった、特定ドメイン知識に関連しない関数だけを配置するイメージです。

状態管理

以下3種類の状態管理を使い分けています。

名前 状態を参照する範囲 一言説明
Local state ある一つのVueページのみ ある単一ページでしか参照することがない状態
Shared state 複数のVueページ 親子関係にある複数のページで参照する状態
Global state アプリケーション全体 アプリケーション全体で参照する状態

基本的な考え方として、state はできるだけ最小限の範囲のみに公開します。つまりできる限り一番上の Local state として実装できるものは実装して、複数ページにまたがることが明らかな場合のみ Shared state を使用、アプリケーション全体で共通利用するもののみ Global state として実装します。これにより各 state がどこで参照されるのかを把握しやすくなるというメリットがあります。

上から順番に説明していきます。

1. Local state

ある特定のページでのみ参照するstateです。stateの定義方法は以下の2種類です。

A. Vue componentに直接定義

ページコンポーネント(/views/foos/index.vue など)や、それ以外のコンポーネント(/views/foos/components/Bar.vue など)で定義しているstateです。

例)

<template>
{{ foo }}
</template>

<script setup lang="ts">
const foo = ref("Hello");
</script>

必要に応じて子コンポーネントにpropsで渡します。

例)

<template>
{{ foo }}
</template>

<script setup lang="ts">
defineProps<{ foo: string }>();
</script>

B. composableとして定義

コンポーネントの切り方にもよりますが、仕様が複雑なコンポーネントに直接stateを書いていくとあっという間に肥大化することがあります。その場合対象stateとそれに関連する関数やcomputedをcomposableという単一のTypeScriptファイルに切り出すと見通しがよくなり、単体テストを書きやすくなるというメリットもあります。

例えば foo というstateを切り出した /views/foos/composables/useFoo.ts の例です。

例)

export const useFoo = () => {
  const foo = ref("Hello");
  const fooLength = computed(() => foo.value.length);

  return { foo, fooLength }
}
<template>
{{ foo }}
{{ fooLength }}
</template>

<script setup lang="ts">
import { useFoo } from "../composables/useFoo"

const { foo, fooLength } = useFoo();
</script>

2. Shared state

親子関係となっている複数のページ間で同じ状態を参照したい場合に使用します。いわゆる provide-inject です。

例として、/views/foos/index.vue/views/foos/bar/index.vue が親子関係になっていて、fooというstateを共通参照したい場合を考えます。まずは /views/foos/composables/useFoo.ts を作成します。

例)

const useFoo = () => {
  const foo = ref("Hello");
  const fooLength = computed(() => foo.value.length);

  return { foo, fooLength }
}

export const [provideFoo, injectFoo] = prepareInjection(useFoo);

あとはこれを親ページ側でprovideして、子ページ側でinjectするだけです。

例)

<script setup lang="ts">
import { provideFoo } from "../composables/useFoo"

provideFoo();
</script>

例)

<template>
{{ foo }}
{{ fooLength }}
</template>

<script setup lang="ts">
import { injectFoo } from "../composables/useFoo"

const { foo, fooLength } = injectFoo();
</script>

やや余談ですが、prepareInjection というユーティリティ関数を用意することで、シンプルにstateを共有するコードが書けるようになっています。

import { inject, provide, InjectionKey } from "vue";

export const prepareInjection = <F extends (...args: any[]) => {}>(func: F) => {
  type InjectionType = ReturnType<F>;

  const injectionName = `${func.name.charAt(0).toUpperCase()}${func.name.slice(1)}`;
  const injectionKey: InjectionKey<InjectionType> = Symbol(injectionName);

  const provideComposition = (...args: any[]) => {
    const provided = func(...args) as InjectionType;
    provide(injectionKey, provided);
    return provided;
  };

  const injectComposition = () => {
    const composable = inject(injectionKey);
    if (!composable) {
      throw new Error(`${injectionName} are not provided`);
    }
    return composable;
  };

  return [provideComposition, injectComposition];
};

3. Global state

最後はアプリケーション全体で参照する想定のstateです。とても多くのページで参照するので、各ページ全てに injectBar() を書くことが大変な場合に使用します。代表的にはユーザー認証の状態(ログイン情報)などが該当します。

例として、/views/foos/index.vue/views/bars/index.vue という全く異なる機能のページにて、fooというstateを共通参照したい場合を考えます。まずは /stores/useFooStore.ts を作成します。

例)

import { defineStore } from "pinia";
import { ref, toRefs } from "vue";

export const useFooStore = defineStore("foo", () => {
  const foo = ref("Hello");
  const fooLength = computed(() => foo.value.length);

  return { foo, fooLength }
});

piniaという状態管理ライブラリが提供する defineStore という関数を使用しています。あとはこれを参照したい各所で呼ぶだけです。

例)

<template>
{{ foo }}
{{ fooLength }}
</template>

<script setup lang="ts">
import { useFooStore } from "/stores/useFooStore"

const { foo, fooLength } = useFooStore();
</script>

まとめ

駆け足でしたがらくしふVueアプリケーション設計についての説明は以上になります。現状このようになっているよということに関してなんとなくご理解いただけたら幸いです。

最後にこの設計を採用した結果良かったなと感じている点と、その一方で課題感も話しておきたいと思います。

Good

  • (ある程度)生産性が高い
    • 「こういうコードはここに置く」ということが大体決まっているので、実装時に迷うことが少ない(もちろん全くゼロではないですが)と思っています。
  • (ある程度)依存関係を把握しやすい
    • そもそもこの設計のコンセプトとして書いたことにはなりますが、やはりディレクトリ構造をパッと見た時に、各ファイル同士の依存関係がなんとなく把握できることは大事だなと思っています。

More

  • 過去の設計パターンのコードが混在している
    • /stores ディレクトリ内にある各種vuexベースのコードだったり、最初に軽く触れた vuex-ormというライブラリベースのコードだったりがまだ残っているので、特に新しくジョインしたエンジニアの方の理解が大変だったり、たまに調査などで過去のコードを見たりすると「はぁ...」となることがあります。(リファクタ好きには堪らないですね!)

さいごに

株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。
一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。

https://x-bit.co.jp/recruit
https://herp.careers/v1/xbit
https://note.com/xbit_recruit/

クロスビットテックブログ

Discussion