【Vue】Vueプロジェクトのディレクトリ構成のベストプラクティス

JavaScript

こんにちは、かつコーチです。

前回は、Vueアプリのパフォーマンスチューニングについて解説しました。

今回は視点を変えて、「アプリが育ってきたときにどうディレクトリを構成するか」という設計のテーマを扱います。

Vue CLIやViteでプロジェクトを作った直後は、componentsviewsくらいしかフォルダがなく困りません。

ですが、機能が増えるにつれて「このファイルはどこに置くべきか」で毎回悩むようになります。

今日は、私が実務や個人開発を通じて行き着いた、規模に応じたディレクトリ構成の考え方を紹介します。

なぜディレクトリ構成が重要なのか

「探しやすさ」がチームの生産性を左右する

ディレクトリ構成は、動作には直接影響しません。

components直下にすべてのファイルを平積みしても、アプリ自体は問題なく動きます。

ただし、ファイル数が増えたときに「あの機能のコードはどこ?」と探す時間が積み重なると、チーム全体の生産性がじわじわ落ちていきます。

私は以前、50個以上のコンポーネントがcomponents直下にフラットに並んだプロジェクトの改修を任されたことがあります。

似た名前のファイルが並び、どれが実際に使われているのか把握するだけで半日かかりました。

この経験から、ディレクトリ構成は「未来の自分やチームメンバーへの道しるべ」だと考えるようになりました。

「機能ベース」と「種類ベース」という2つの軸

Vueのディレクトリ構成には、大きく分けて2つの考え方があります。

  • 種類ベース(Type-based)componentsviewsstoresのように、ファイルの種類でフォルダを分ける
  • 機能ベース(Feature-based)features/todofeatures/authのように、機能単位でフォルダを分け、その中に関連ファイルをまとめる

どちらが正解というわけではなく、プロジェクトの規模によって使い分けるのがベストプラクティスです。

小規模プロジェクトの構成:種類ベースで十分

基本の構成例

数画面程度の小規模なアプリであれば、種類ベースのシンプルな構成で問題ありません。

src/
├── assets/          # 画像・CSSなどの静的ファイル
├── components/      # 再利用可能なUIコンポーネント
├── views/           # ルーティングに対応するページコンポーネント
├── router/          # Vue Routerの設定
├── stores/          # Piniaのストア
├── composables/      # 再利用可能なComposition API関数
├── types/           # 型定義(TypeScript)
├── App.vue
└── main.ts

この構成は公式のスキャフォールディングツール(create-vue)が生成する構成にも近く、初学者にも分かりやすいのがメリットです。

小規模構成で注意したいポイント

小規模な段階でも、components直下だけは早めに整理しておくことをおすすめします。

src/components/
├── common/       # ボタン・モーダルなど汎用的な部品
├── layout/       # ヘッダー・フッターなどレイアウト部品
└── forms/        # フォーム関連の部品

「後で整理すればいい」と先延ばしにすると、ファイル数が増えたタイミングで整理コストが跳ね上がります。

最初からcommonlayoutのようなサブフォルダを切っておくだけでも、後の負担が大きく変わってきます。

中〜大規模プロジェクトの構成:機能ベースへの移行

種類ベースの限界

アプリが成長し、TODO管理・ユーザー認証・通知機能のように複数のドメインが混在してくると、種類ベースの構成には限界が見えてきます。

src/
├── components/
│   ├── TodoList.vue
│   ├── TodoItem.vue
│   ├── LoginForm.vue
│   ├── NotificationBell.vue
│   └── ... (30個以上)
├── stores/
│   ├── todo.ts
│   ├── auth.ts
│   └── notification.ts

componentsstoresを行き来しないと、TODO機能に関連するファイルの全体像がつかめません。

1つの機能を改修するために複数のフォルダをまたいで検索する必要があり、これが規模が大きくなるほど負担になります。

機能ベースでまとめ直す

そこで、機能(ドメイン)単位でフォルダをまとめる構成に切り替えます。

src/
├── features/
│   ├── todo/
│   │   ├── components/
│   │   │   ├── TodoList.vue
│   │   │   └── TodoItem.vue
│   │   ├── composables/
│   │   │   └── useTodoFilter.ts
│   │   ├── stores/
│   │   │   └── todoStore.ts
│   │   └── types.ts
│   ├── auth/
│   │   ├── components/
│   │   │   └── LoginForm.vue
│   │   ├── stores/
│   │   │   └── authStore.ts
│   │   └── types.ts
│   └── notification/
│       └── ...
├── components/       # 複数機能で共通利用する汎用コンポーネントのみ
├── composables/      # 複数機能で共通利用するcomposableのみ
├── router/
├── stores/           # アプリ全体で共有するグローバルなstoreのみ
└── App.vue

TODO機能を改修したいときはfeatures/todoだけを見れば完結する、という状態を目指すのがポイントです。

componentscomposablesは、あくまで複数の機能をまたいで再利用される、本当に汎用的なものだけを置く場所として残します。

機能ベースに移行するタイミングの目安

私の経験上、次のいずれかに当てはまってきたら、機能ベースへの移行を検討するサインです。

  • components直下のファイルが20〜30個を超えてきた
  • 1つの機能を直すのに3つ以上のフォルダを行き来している
  • チームメンバーが増え、担当機能ごとに作業が分かれてきた

最初から機能ベースで作り込みすぎると、逆に小規模なうちはオーバーエンジニアリングになりがちです。

「今の規模に合った構成を選ぶ」という感覚を持っておくと判断しやすくなります。

つまずきやすいポイント:composablesの置き場所

かつコーチが実際につまずいた話

私が機能ベースへの移行を初めて試したとき、一番迷ったのがcomposablesの置き場所でした。

「共通化できそうだから」という理由で、本来はtodo機能専用のロジックまで、安易に全部src/composables直下に置いてしまったことがあります。

結果、src/composablesに機能固有のロジックと本当に汎用的なロジックが混在し、「これは他の機能から使ってもいいものなのか」が名前だけでは判断できなくなりました。

❌ Before:機能固有のロジックまで共通フォルダに置いてしまう

// src/composables/useTodoFilter.ts
// TODO機能でしか使わないのに、共通フォルダに置いてしまっている
import { ref, computed } from 'vue'
import type { Todo } from '@/features/todo/types'

export function useTodoFilter(todos: Ref<Todo[]>) {
  const keyword = ref<string>('')

  const filteredTodos = computed<Todo[]>(() => {
    return todos.value.filter((todo) => todo.title.includes(keyword.value))
  })

  return { keyword, filteredTodos }
}

このファイルはTodo型に強く依存しているにもかかわらず、共通フォルダに置かれているせいで、一見「どの機能からでも使っていいもの」に見えてしまいます。

✅ After:機能固有のロジックは機能フォルダ内に閉じ込める

// src/features/todo/composables/useTodoFilter.ts
// TODO機能専用であることが、置き場所からも一目で分かる
import { ref, computed } from 'vue'
import type { Todo } from '../types'

export function useTodoFilter(todos: Ref<Todo[]>) {
  const keyword = ref<string>('')

  const filteredTodos = computed<Todo[]>(() => {
    return todos.value.filter((todo) => todo.title.includes(keyword.value))
  })

  return { keyword, filteredTodos }
}

判断軸としては、「特定のドメインの型やデータ構造に依存しているかどうか」で振り分けるようにしています。

  • 特定機能の型・データに依存する → その機能のフォルダ内に置く
  • 型に依存せず、日付フォーマットやフォームバリデーションのように汎用的 → src/composablesに置く

この判断軸を持ってから、置き場所で迷う時間がかなり減りました。

エイリアス設定で構成をさらに使いやすくする

@エイリアスでインポートを短くする

機能ベースの構成では相対パス(../../../)が深くなりがちなので、@エイリアスを設定しておくと快適です。

Viteプロジェクトであれば、vite.config.tsに次のように設定します。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

これにより、機能をまたいだインポートも次のようにすっきり書けます。

// features/notification/stores/notificationStore.ts
import { useAuthStore } from '@/features/auth/stores/authStore'

相対パスの深さに悩まされることなく、どこからでも同じ書き方でインポートできるのが利点です。

まとめ

この記事のポイント

  • ディレクトリ構成は動作には影響しないが、チームの生産性を左右する重要な設計判断
  • 小規模プロジェクトはcomponentsviewsstoresのような種類ベース構成で十分
  • 機能が増えて複数ドメインが混在してきたら、features配下にまとめる機能ベース構成へ移行する
  • composablesは「特定機能の型に依存するか」を基準に、機能フォルダ内か共通フォルダかを判断する
  • @エイリアスを設定すると、機能ベース構成でも深い相対パスに悩まされずに済む

次に読むべき記事

次回はいよいよVue.js編の最終回として、Vue×Piniaで簡単なTODOアプリを実際に作っていきます。

これまで学んできたComposition API・Pinia・設計の考え方を、1つのアプリとして総まとめします。

→ 次の記事:Vue×Piniaで簡単なTODOアプリを作ってみる

タイトルとURLをコピーしました