らくしふのVue.jsアプリケーション設計
はじめに
株式会社クロスビットのメインプロダクトである「らくしふ」のフロントエンド実装にはVue3を採用していますが、Vueエコシステムの変遷などに伴い、色々な実装方法を導入してきた歴史があります。具体的には、
- RailsのERBによるテンプレート + JavaScript 時代
- Vue2 + JavaScript + Vuex 時代
- Vue2 + TypeScript + Vuex 時代
- Vue3 + TypeScript + Composition API 時代
- Vue3 + TypeScript + Composition API + VuexORM 時代
- 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
上記例では foo
と bar
という全く異なる(親子関係がない)ページが存在します。それらで共通利用したい Vue component を配置することになります。
Atomic design でいうところの atoms
、molecules
あたりまでがここに配置されるイメージです。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.ts
やcalcHoge.ts
など)- あるビジネスロジックを実装した関数
といったものがあります。具体的な配置例としては、
/domains/foos/api.ts
├ /decorator.ts
├ /types.ts
/domains/bars/api.ts
├ /validator.ts
├ /sortBars.ts
のようになります。 foos
や bars
のところは取り扱うものの名前(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管理プラットフォームを開発しています。
一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。
Discussion