Vue.js (Vue3) のアーキテクチャ構成における課題
Vue.jsでアプリを構築する際における1つの課題として、「設計を入念に行わない限り、中規模以上のプログラムが書きにくくなってくる」といったものがあります。VueはRailsなどのフレームワークと比べて、エンジニアが自由にディレクトリ配置やクラス設計などのアーキテクチャを決定することができるといった特徴がありますが、その反面、先を見据えた設計ができていないと、目的のソースコードにアクセスするのに時間がかかったり、不具合発生時に原因が追いにくくなったり、最悪、単純な機能の追加すら何時間もかかるようになったりといった問題に遭遇することがあります。
そこでこの記事では、「ヒックの法則」を念頭に、Vue.jsのアーキテクチャ構成をどのようにするのが効率的なのかを取り上げます。
この記事の対象者
この記事は、中規模以上のプロジェクトにおいてVue.jsを構成する方を対象としています。小規模なアプリや、チームメンバーが2、3名と少数のケースは対象としていません。
また、Vueの動作概要を一通り理解しているものの、実際にどのようにディレクトリやファイルを配置すべきか検討されている方を対象としています。
そのため、記事内のソースコードは、あくまで概念を補足する程度に掲載しています。依存している関数を省略していますので、そのままでは動作しないかもしれません。
なお、この記事は、IT人材のマッチングサービスである「re:shine(リシャイン)」で進行している、Vue2からVue3への刷新で導入したアーキテクチャ構成を事例に、Vue.jsのアーキテクチャ設計について紹介します。
1. [結論] アーキテクチャ構成の全体像
「re:shine」で採用したアーキテクチャ構成は以下の通りです。
src/
  ├ assets                   // 共通で読み込みリソース
  │    ├ base_components     // ベースコンポーネント。ButtonやTextfieldなど。グローバルで読み込む
  │    ├ images              // 画像
  │    └ stylesheets         // CSS
  │
  ├ config                   // コンフィグ
  │    ├ initializers        // 初期化スクリプト。app.vueで、パッケージを初期化する際に使う
  │    ├ locales             // カラム名やエラーメッセージなどの日本語定義を配置する。
  │    ├ settings            // settings。プロジェクトの定数をおく。
  │    └ router.ts           // ルーティング。1つのファイルに全てのパスを記載する。URLとこのファイルだけ見れば処理を追える
  │
  ├ models                   // モデル定義
  │    ├ global              // グローバルステート。Provider/Injectパターンで定義。
  │    └ local               // ローカルステート。Typescriptで使う型の定義と、主にサーバからの返却値をセットする関数を持つ
  │
  ├ utils                    // 汎用的な関数定義
  │
  ├ views                    // ViewはContainer/Presentationalパターンを採用
  │    ├ containers          // URLと一致するVueファイルを1つ設置。api接続、データの用意といったロジックに専念する
  │    └ presentationals     // containerから読み込むview。文言やフォームやCSSクラスなどの表示要素を記載
  │
  ├ app.vue                  // Entrypoint。ログイン認証チェックなど、routerにわたる前の処理を記載
  └ main.ts                  // Vueの初期化。グローバルに読み込むものを記載
1-1. ヒックの法則とは
今回、アーキテクチャ設計をする際に念頭に置いた「ヒックの法則」とは、「選択肢が多ければ多いほど、反応時間が増加する」ことを示す法則です。意思決定に必要となる時間は、選択肢の数に比例して長くなります。例えば「3つの中から選ぶほうが、8個の中から選ぶよりも速く選べる」というようなものです。
この法則に基づいて考えると、ファイル選択の際にはディレクトリが多すぎると、無駄な選択肢を省くために時間を要するということになります。そのため、ディレクトリ数を必要最小限に留めることで、集中力の浪費を防ぎ、プログラム自体に集中できるようにすることにつながると言えます。この法則に沿って、ディレクトリを出来る限り少なくし、効率的に判断できるようなアーキテクチャ構成を検討します。
1-2. アーキテクチャ設計時のポイント
ディレクトリは3つに絞る
「ヒックの法則」に基づいて、作るディレクトリを3つに絞ります。おそらく判断力の高い人であれば5つくらいでも峻別できるかもしれませんが、3つまでであれば選択スピードを高いレベルで維持できます。そのため、あらゆるメンバーで運用しやすくするために、ディレクトリ数は多くても3つに絞ることにしました。
ディレクトリの名前は使いやすいものにする
できるだけ無意識に該当のファイルを開くことができるように、読みやすく、入力しやすい名前を選びます。具体的に配慮したのは以下の点です。
- 読みやすい名前にする (例:普遍という意味で使用するbaseやcommonは読みやすいが、ubiquityは読みにくい)
 - 人それぞれ認識が近い名前にする (例:frameworkは認識が異なることが少ないが、driverだと人それぞれ認識する意味が異なる)
 - 入力しやすい名前にする (例:setは入力しやすいが、informationやassignmentはスペルミスが起きやすい)
 - 連続するフォルダは別の頭文字を使う (例:employee, employerというように似たような単語が連続すると峻別できない)
 
プロダクト規模、チーム構成を考慮する
プロダクトの規模やチーム構成に応じて、最適な状態を検討します。たとえば「re:shine」のAPIのエンドポイント数は、数百レベルの規模感。チーム構成は、バックエンド担当者がフロントエンドも触る可能性があり、HTML/CSSはスキルを持つ実装者がいるチームです。そのため、バックエンド担当者がフロントエンドも触る可能性に配慮し、バックエンドで用いられているRailsに近いディレクトリ構成にしています。そして、HTML/CSSは、CSS in JSではなく、SCSSでCSSを実装し、SCSS技術の恩恵を十分に受けられる構成にしました。
2. 第1階層ディレクトリのアーキテクチャ構成
VIEW, MODEL, UTILSなど、一番大きなレイヤーの部分ですが、この第1階層では、どうしても3つに絞ることができません。しかし、並び順を踏まえた名前の付け方に配慮することで、視覚的にグルーピングが行われ、3つに見えるようにします。つまり、使用頻度が高い機能と、頻度が低い機能で、視覚的に二分されるよう命名します。
src/
  ├ assets    (使用頻度:低)
  ├ config    (使用頻度:低)    
--- ここで 視覚的に分断する ----
  ├ models    (使用頻度:高)
  ├ utils     (使用頻度:高)    
  ├ views     (使用頻度:高)
使用頻度が高い機能である、view、model、utilと、使用頻度が低い機能であるasset、configに二分されるような名付けをします。使用頻度が低い機能はアルファベットの前のほう[A-G]で命名し、使用頻度が高い主要機能はをアルファベットの後ろのほう[H-Z]で命名しています。こうして、「assets/config」のグループ、「models/utirls/views」のグループと分かれて見えるようにし、グループ内で"3つ"を実現します。
もし、ここで、configをsettingsとしてしまうと、視覚的なグルーピングが行えなくなり選択肢が5つになってしまい判断速度が低下してしまいます。
- assets
 - models
 - settings
 - utils
 - views
 
3. Viewのアーキテクチャ構成
まずはViewについて、その内部をさらにどのようにディレクトリ分けしたかを説明します。
3-1. Viewのアーキテクチャ構成の全体像
Viewは、以下のように、3つのディレクトリ分けを行っています。(base_componentsがassetsにある理由は、後ほど説明します。)
src/
  ├ assets                 
  │    ├ base_components  (1) 
  │
  ├ views                  
  │    ├ containers       (2) 
  │    └ presentationals  (3)
3-2. Viewのアーキテクチャ構成のポイント
Viewの設計をするにあたり、以下の点を考慮しています。
不具合が発生した際に、原因を特定しやすくする
UI用の変数と、ビジネスロジックのデータを分離することで、どのデータに問題が起きているかを特定しやすくします。
維持する部分と、切り捨てる部分を分けて考える
ViewやCSSは、デザイン変更に伴って、まとまった単位で切り捨てる場合があります。
そのため、末端のViewは、運用しやすさよりも、捨てやすさを重視しています。一方、APIからデータを受け取るようなルートに近いViewは、デザインが変わっても維持することが多いため、ここは運用のしやすさ、維持のしやすさを重視します。
3-3. Viewのコンポーネント構成
一般的によくある例としては、AtomicDesignにならって、Atoms/Molecules/Organisms/Pagesの4つに分ける構成です。しかし、AtomicDesignは、いざ開発を始めると4つの境界に悩まされることが多く、AtomicDesignの構成を維持するためのレビュアーを立てる必要が出てきます。そこで「re:shine」では別の道を探ることになりました。検討の結果、Reactのベストプラクティスにならって、Container/Presentationalパターンを採用し、BaseComponentという、いわゆるボタンのような共通パーツを別に切り出す構成としました。
src/
  ├ assets                 
  │    ├ base_components   
  │
  ├ views                  
  │    ├ containers        
  │    └ presentationals  
Viewの下にContainer ComponentとPresentational Component。Assetsの下にBase Componentを配置します。
前述のとおり、BaseComponentは、assetsの配下に置いています。その1点目の理由は、Container/Presentationalと、Baseとではコンポーネントを作り込むチームが異なったためです。「re:shine」ではViewはフロントエンジニアが、Assetsはデザイナが担当します。そのため配置先を分離しています。2点目の理由は、View配下はURLとディレクトリ構成を同一にしたいためです。BaseComponentはURLと一致ができないため、Assetsに逃がしました。
Container Componentの構成
ContainerComponentは、データをそろえるコンポーネントという立ち位置で、以下の役割を持ちます。
- ページで必要なモデルのデータを保持する。(ユーザー一覧の情報など)
 - 表示に関連する情報は、保持しない。(アコーディオンが閉じているかなどはContainerには持たない)
 - APIと通信し、受信したデータ保持する。
 - グローバルステートと通信し、データを保持する。
 - URLに一致するファイルを1つだけ作る。
 
ファイル構成としては、次のようにURLとファイルは必ず一致するようにします。これはNuxt.jsや、Railsのようなフレームワークと同じ発想です。ルーティングファイルを見なくても、該当のファイルにたどり着けるようにすることを意図しています。そのため、URLに一致しないファイルの作成は禁止しました。あまり巨大なファイルだからと言ってファイルを分割するのはNGとしています。
(URL)  sample.com/users/edit/13
(VIEW) src/views/containers/users/edit.vue
ただし、レイアウトファイルは共有する必要があるため、URLに一致しないファイルとしてContainerの直下に別ディレクトリを設けています。レイアウトだけ例外的に、HTMLダグとCSSは許可していますが、あくまでレイアウトレベルのものだけです。
(VIEW) src/views/containers/layouts/account-layout.vue
ソースコードとしては、以下のようにHTMLタグやCSSが含まれず、かつ、UI用のデータが含まれないものを想定しています。
※独自で組んだ関数が多少組み込まれているので、そのままでは動きませんが、イメージを把握する目的で記載しています。
<template>
  <!-- ここにHTMLタグ(div)などは発生しない -->
  <title-page />
  <!-- フォームのPresentationalに、更新するための関数(apiUpdateUser)を渡して、発火はPresentationalに任せる -->
  <form-user
    :isLoading="false"
    :user="user"
    :apiUpdateUser="apiUpdateUser"
  />
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue';
import { useRouter, Router } from 'vue-router';
// utils
import { apiServer } from '@/utils/api';
// models
import { User } from '@/models/local/user';
import { useSnackbarMessage } from '@/models/global/snackbar-message';
// view presentationals
import TitlePage from '@/views/presentationals/users/edit/title-page.vue';
import FormUser from '@/views/presentationals/users/edit/form-user.vue';
export default defineComponent({
  components: {
    TitlePage,
    FormUser,
  },
  setup() {
    const router = useRouter() as Router;
    const id = ref(router.currentRoute.value.params.id);
    //
    // ShowUser
    //
    const isLoading = ref(true)
    const user = ref(new User());
    const apiShowUser = () => {
      // apiServer (独自の関数)。axiousをラッピングした関数です。
      apiServer.get(`/users/${id.value}`).then((response) => {
        const model = new User();
        model.assign(response.data);
        user.value = model;
        isLoading.value = false;
      });
    };
    onMounted(() => {
      apiShowUser();
    });
    //
    // UpdateUser
    //
    const apiUpdateUser = (params: any) => {
      apiServer
        .put(`/users/${id.value}`, { user: params })
        .then(() => {
          // setSnackbarMessage (独自の関数)。グローバルステートにメッセージを入れると、スナックバーが表示される挙動をします。
          setSnackbarMessage({ message: '保存しました。', type: 'notice' });
          router.push('/users');
        })
        .catch(({ response }) => {
          if (response.status === 422) {
            // failure (独自関数)。サーバからのエラーを、よしなに、画面に表示する関数。
            apiServer.failure(response.data);
          }
        })
    };
    return {
      user,
      apiUpdateUser,
    };
  },
});
</script>
Presentational Componentの構成
Presentational Componentは、表示にまつわるコンポーネントという立ち位置で、以下の役割を持ちます。
- UIに関連するものを記載する。
 - HTMLタグ、CSSを記載する。
 - タイトルやデスクリプション、ラベル名などの日本語を記載する。
 - 表示/非表示といったUIにまつわる変数を保持する。
 - フォーム用の変数を保持して、送信できる状態に加工する。
 
Viewは複雑なUIに比例して、構築するのに時間がかかりますが、捨てる時はまとめて捨られてしまうため、まとまった単位で捨てやすいレベルでコンポーネントをつくり、ひとまとまりにします。
ファイル構成としては、ここでも以下のようにURLと一致させます。
(URL)  sample.com/users/edit/13
(VIEW) src/views/presentationals/users/edit/form-user.vue
もちろん、ページAとページBで共通のコンポーネントが必要な時も発生すると思います。その場合は、共通する階層で_partialsディレクトリを作ります。
(URL)  sample.com/users/edit/13
(VIEW) src/views/presentationals/users/_partials/menu.vue
Base Componentの構成
Base Componentは、基礎的なパーツのコンポーネントという立ち位置で、以下の役割を持ちます。
- 全部の画面から使われる可能性があるデータを必要最小限で保持する(Button/Textfield/Chips/Modal/Snackbarなど)
 
フォルダ構成としては、ディレクトリを分けずにすべて並列に配置します。BaseComponentには何でも切り出すわけではなく、変数や引数の影響で表示が変わるものを中心に配置していきます。単純なアイコンなどはCSS側で既にコンポーネント化されていると捉え、BaseComponentには登録しないこともあります。そのため、BaseComponentの数はそこまで多くはなっていません。
4. Modelのアーキテクチャ構成
以下のように、globalとlocalの2つに分けます。
  ├ models                   // モデル定義
  │    ├ global              // グローバルステート。Providerパターンで定義。
  │    └ local               // ローカルステート。
4-1. 構成時のポイント
Modelでは、グローバルステートをどのように構築をするかという点が一番の課題です。グローバルステートはVue2まではVuex一択でしたが、Vue3からはVuexに加えて、Provider/Injectパターンでの実装もできるようになりました。VuexとProvider/Injectパターンは、まだ使いどころが議論されており、どちらがよいかといった結論は出ていません。しかし、これまでの経験から、新規参入者に対するVuexの学習コストの高さも1つの課題だと感じていました。
4-2. Modelのコンポーネント構成
グローバルステートの構成
グローバルステートは、ページ遷移に影響を受けない変数として使用しています。
今回、グローバルステートはProvider/Injectパターンを採用して構築しました。Vuexは、Typescriptのコード補完がうまく効かなかったことがあり、補完が効くProvider/Injectパターンを採用したためです。
状態管理に関して、Vuexはゲッター・ミューテーション・アクションの3状態ですが、Provider/Injectでは、ゲッター・セッターだけのシンプルな状態で構築できます (ゲッター・セッターに分けないこともできますし、もちろん、3状態にすることもできます)。
使用イメージは、トップレベル(main.ts)でprovider関数を呼び、下層の使いたい箇所(container view)でinject関数を呼ぶイメージです。中規模レベルまではProvider/Injectパターンのほうがシンプルに記載できると感じました。
以下、サンプルコードです。
[model] src/models/global/current-user.ts
import { reactive, inject, readonly } from 'vue';
// 型定義
interface CurrentUser {
  id: number | null;
  email: string;
}
// Provider
export const createCurrentUser = () => {
  const currentUser = reactive({
    id: null,
    email: '',
  });
  return {
    currentUser,
  };
};
// Inject
export const keyCurrentUser = Symbol();
export const useCurrentUser = () => {
  const { currentUser } = inject(keyCurrentUser) as { currentUser: CurrentUser };
  const setCurrentUser = (data: any) => {
    currentUser.id = data.label || null;
    currentUser.email = data.email || '';
  };
  return {
    currentUser: readonly(currentUser),
    setCurrentUser,
  };
};
[provider] main.ts
import { createApp } from 'vue';
import App from './app.vue';
import { keyCurrentUser, createCurrentUser } from '@/models/global/current-user';
const app = createApp(App);
app.provide(keyCurrentUser, createCurrentUser());
app.mount('#app');
[Inject] 使いたいどこかのView
<template>
  <!-- 省略 -->
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue';
// models
import { useCurrentUser } from '@/models/global/current-user';
export default defineComponent({
  setup() {
    // GlobalState
    const { currentUser, setCurrentUser } = useCurrentUser();
    onMounted(() => {
      setCurrentUser({ id: 1, email: 'sample@sample.com' }); // これでグローバルステートを更新する
      console.log(currentUser); // 更新された値が表示される。
    });
  },
});
</script>
ローカルステートの構成
ローカルステートは、サーバからのレスポンスをJavascriptのオブジェクトに変換するために使用します。具体的には、jsonのレスポンスがスネークケース(snake_case)の時にキャメルケース(camelCase)に変換する場合や、文字列をDate型に変換したりBoolにしたりするなど型の変換を行う場合に使用します。
以下、サンプルコードです。
[model] src/models/local/project.ts
export class Project {
  id: number | null;
  name: string;
  createdAt: Date;
  constructor() {
    this.id = null;
    this.name = '';
    this.createdAt = new Date();
  }
  assign(data: any) {
    this.id = data.id || null;
    this.name = data.name || '';
    this.createdAt = data.created_at ? new Date(data.created_at) : new Date();
  }
}
[View] 使うときは、ContainerComponentで、サーバレスポンスをそのままここに格納します。
<template>
  <!-- 省略 -->
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue';
import { apiServer } from '@/utils/api';
// models
import { Project } from '@/models/project';
export default defineComponent({
  setup() {
    const router = useRouter() as Router;
    const id = ref(router.currentRoute.value.params.id);
    // form
    const project = ref(new Project());
    const apiShowProject = () => {
      apiServer.get(`/projects/${id.value}`).then((response) => {
        // ここでローカルステートを使って、サーバの値を、クライアントの値に変換して保持する。
        const model = new Project();
        model.assign(response.data);
        project.value = model;
      });
    };
    onMounted(() => {
      apiShowProject();
    });
  },
});
</script>
5. Utilの構成
Util直下のディレクトリは、3つという制約は行わずに、細かいディレクトリがたくさん入っている状態で構成します。ただし、Util直下にスクリプトは配置しないというルールだけ設けました。なお、共通関数はUtil/Service/Helperといった分類を行いますが、今回は共通関数はすべてUtilに統一しています。UtilとHelperは人それぞれ境界が違う場合があるためです。Serviceに関しては、API側で実装されているためVueには実質存在していないという判断です。
6. Assetsの構成
assetsも、最終的に3つのディレクトリで構成しています。
  ├ assets                   // 共通で読み込みリソース
  │    ├ base_components     // ベースコンポーネント。ButtonやTextfieldなど。グローバルで読み込む
  │    ├ images              // 画像
  │    └ stylesheets         // CSS
「re:shine」の場合、assetsはデザイナが中心となって運用します。base_componentsは、view属性のものですが、前述のとおり、チーム責務の観点と、View配下がURL通りのディレクトリ構成にして見通しをよくするという目的を優先したため、assetsに配置しています。
7. configの構成
configも、3つのディレクトリで構成しています。ただし、router.tsというファイルが存在しています。
  ├ config                   // コンフィグ
  │    ├ initializers        // 初期化スクリプト。app.vueで、パッケージを初期化する際に使う。
  │    ├ locales             // カラム名やエラーメッセージなどの日本語定義を配置する。
  │    ├ settings            // settings。プロジェクトの定数をおく。
  │    └ router.ts           // ルーティング。1つのファイルに全てのパスを記載する。URLとこのファイルだけ見れば処理を追える
config配下は、initializers、locales、settingsの3つに分けて構成し、あわせてrouter.tsを配置します。これはRailsに近い構成です。バックエンド担当者がフロントエンドも触ることが多いため、構成の違いによる時間ロスを最小にする目的があったためです。
またrouter.tsには、ルーティングの情報を配置します。ファイルは分割せず、router.tsにすべてを書いています。その目的は、URLがどのViewに一致しているかを一目で確認できるようにするためです。たとえばURLが20〜80個であれば、1つにまとめて、ルーティングを調べる際のドリルダウンの負荷を削れます。
8.その他の補足
API層
Viewを見たとき、ひとめで通信先を理解したいという理由から、API層は設けていません。
API層を設けることによって、同じ記述を書かなくてすむというメリットもありますが、それよりは、複数回書いてでも、一目で通信先を把握できるというメリットを優先しています。これは、「re:shine」がフロントとバックエンドを同時に触ることが多いチーム編成のためです。
バリデーション
バリデーションは、VeeValidateを使って実装しています。しかし2020年12月現在、難点は、サーバからのエラーレスポンスをVeeValidateに伝える方法がなくなってしまったため、その部分を自前で書く必要があることです。
CSSフレームワーク
自前でデザインを起こして適用するため、CSSのフレームワークは不要としました。ただしDropdownやモーダルなどのJSは、マテリアルコンポーネントから、該当のJSのみインポートして利用しています。
9.さいごに
今回適用したサービス「re:shine」は以下からご覧いただけます。
https://lp.re-shine.jp/
「re:shine」はエンジニアが、「フリーランス型正社員」として働けるサービスです。フリーランスのよさである「自由」がありながら、正社員のよさである「安定性」を担保するという新しい働き方をしてみませんか。少しでも興味を持っていただけたらご登録いただければと思います。
また、記事がお役に立てば、ぜひLGTMもお願いします。