個人的ピュアなVue.js を採用した際のディレクトリ構成の考え方メモ📝
最近、ピュアなVue.jsを採用したフロントエンド周りのディレクトリ構成に想いを馳せることが多いので現時点での自分の考え方を共有しやすいようにメモしておく📝
基本思想
基本的にグローバルに適用するものを除いた全ての CSS は Vue.js SFC の Scoped CSS でカプセル化しつつ、BEM 的な命名規則を採用して記述する。
基本的にはドメインベースにディレクトリを分け、コロケーションすることで CODEOWNER 等で統制を効かせやすくします。
具体的には以下のようなディレクトリ構成を採用する。
.
└── src/
├── stylesheet/
│ ├── application.css
│ ├── foundation/
│ │ ├── reset.css
│ │ ├── base.css
│ │ ├── font.css
│ │ ├── variable.css
│ │ └── color.css
│ └── object/
│ └── util/
│ └── align.css
├── entrypoints/
│ ├── application.ts
│ └── books/
│ └── index.ts
├── components/
│ ├── ui/
│ │ └── UIButton/
│ │ ├── index.ts
│ │ ├── UIButton.vue
│ │ ├── UIButton.spec.ts
│ │ └── UIButton.storie.ts
│ └── book/
│ ├── BookCard/
│ │ ├── index.ts
│ │ ├── BookCard.vue
│ │ └── BookCard.spec.ts
│ ├── pages/
│ │ └── BookIndexPage/
│ │ ├── index.ts
│ │ ├── BookIndexPage.vue
│ │ └── BookIndexPage.spec.ts
│ ├── tamplates/
│ │ └── BookIndexTemplate/
│ │ ├── index.ts
│ │ ├── BookIndexTemplate.vue
│ │ └── BookIndexTemplate.spec.ts
│ └── types/
│ └── index.ts
├── services/
│ ├── FetchClient.ts
│ └── BookService.ts
├── types/
│ └── Global.d.ts
└── utils/
└── Text.ts
コンテナ・プレゼンテーションパターンを採用し各ページのルートコンポーネント(pages)に副作用を集約することで、それ以外は mock や stub 等をせずにテスト可能とし、Storybook 等によるコンポーネントカタログに載せやすくする。
コンテナ・プレゼンテーションパターン ビューとアプリケーションロジックを分け、関心の分離を実現する https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern
各ディレクトリの役割と基本ルール
stylesheet
stylesheet配下にはグローバルに利用するスタイルのみを記述するため FLOCSS における Foundation と Util のみになります。
実際の記述等は以下で記載したような内容に従い FLOCSS・BEM ライクな記法で記述します。
entrypoints
⚠️ MPA やダイナミックインポートを利用する場合には本章で記載した思想でエントリーを分離する設計を行う。
ビルド時のエントリーとなる.tsファイルを配置します。
├── entrypoints/
│ ├── application.ts
│ └── books/
│ └── index.ts
グローバルに適用する処理はentrypoints/application.tsに import しグローバルに読み込まれるエントリーとして出力します。
Project で定義した各ページに適用する処理はは他のページに影響を与えないようにentrypoints/books/index.tsのような各ページのエントリーの ts ファイルで import し各ページで読み込みます。
基本的にentrypoints/books/index.tsは後述の Pageコンポーネントを import し Vue アプリケーションを生成するような実装になります。
import BookIndexPage from "@/projects/book/pages/BookIndexPage.vue"; import { createApp } from "vue"; const app = crateApp({ components: { BookIndexPage }, }); app.mount("#vue-root");
components
components配下にはドメイン毎にディレクトリを切り、ドメインで利用するコンポーネント群を配置します。
特定のドメインに紐づかないですが、グローバルに利用するコンポーネントを管理するためのuiディレクトリもここの直下に配置します。
主に以下のような Vue.js SFC で記述されたコンポーネントと関連ファイルを以下のようにコロケーションで配置します。
│ ├── components/
│ │ └── BookCard/
│ │ ├── index.ts
│ │ ├── BookCard.vue
│ │ └── BookCard.spec.ts
コンポーネントのディレクトリには、利用する側でimport "@/projects/books/components/BookCard"と呼び出せるように以下のようなindex.tsを配置します。
// BookCard/index.ts import BookCard from "./BookCard.vue"; export default BookCard;
コンポーネントの本体は以下のような Vue.js SFC で記述し、Scoped CSS を利用して Component 内にスタイルをカプセル化します。
<!-- BookCard/BookCard.vue --> <script setup lang="ts"> defineProps<{ msg: string }>(); </script> <template> <div class="component-name"> <p class="msg">{{ msg }}</p> <p></p> </div> </template> <style scoped> .component-name { .msg { color: red; } } </style>
コンポーネントのルート要素にはコンポーネント名を表す一意な class を付与することで html で描画された際に、どのコンポーネントが元になっているのかわかりやすいようにします。
style は.component-name配下にネストして記述するようにすることで、親コンポーネントからのスタイル適用を回避します。
独自ルールを設けて親コンポーネントからのスタイル適用を回避しましょう。例として以下のようなルールが有用です。 次の 2 つのルールを同時に採用する。
components内に切られてドメインを表すディレクトリには更に以下のような特別な役割のディレクトリを作成されることを想定しています。
- pages
- templates
pages
pagesには以下のようなコンテナ・プレデンテーションパターンにおけるコンテナに該当する Vue.js SFC で記述されたコンポーネントです。ページ単位に作成し、ページのルート要素となります。
コンテナコンポーネント: 何のデータがユーザーに表示されるかを管理するコンポーネント。この例では、犬の画像を取得します。 https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern
コンポーネント名のサフィックスはPageとし、関連ファイルをコロケーションで配置します。
│ ├── pages/
│ │ └── BookIndexPage/
│ │ ├── index.ts
│ │ ├── BookIndexPage.vue
│ │ └── BookIndexPage.spec.ts
コンポーネントの本体はコンテナとしての役割を持つため style は記述せず、template には後述のTemplateに props を引き継ぎ、Templateから早出されたイベントをハンドリングし後述のServiceで実装された外部リクエスト等の副作用を伴う処理を行います。
(Pageコンポーネント以外からService等の副作用を持つ処理をを呼び出すことは禁止です。)
<!-- BookIndexPage/BookIndexPage.vue --> <script setup lang="ts"> import BookIndexTemplate from "../tamplates/BookIndexTemplate" import { postBook } from "../../services/BookService.ts" const submitBook = async (book: Book) => { await postBook(book) } defineProps<{ msg: string }>(); </script> <template> <BookIndexTemplate :msg="msg" @submit="submitBook"> </template>
template
pagesには主に以下のようなコンテナ・プレデンテーションパターンにおけるプレゼンテーションに該当するコンポーネント群のルート要素を表す Vue.js SFC で記述されたコンポーネントです。Pageコンポーネントと同様にページ単位に作成し、ページのルート要素であるPageコンポーネントから呼び出されます。
プレゼンテーションコンポーネント: データがユーザーにどのように表示されるかを管理するコンポーネント。この例では、犬の画像のリストをレンダリングします。 https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern
コンポーネント名のサフィックスはTeplateとし、関連ファイルをコロケーションで配置します。
│ ├── tamplates/
│ │ └── BookIndexTemplate/
│ │ ├── index.ts
│ │ ├── BookIndexTemplate.vue
│ │ └── BookIndexTemplate.spec.ts
コンポーネントの本体はプレゼンテーションとしての役割を持つため、各コンポーネントを import して配置し、副作用を持つ処理は行わずemitで必要な情報をPageコンポーネントにイベントの発行して実行します。
基本的な記述ルールはcomponentsディレクトリと同じですが、Templateコンポーネントは、基本的には各コンポーネントを組み合わせてページ組み立てレイアウト的な役割を持つことを想定しています。
<!-- BookIndexTemplate/BookIndexTemplate.vue --> <script setup lang="ts"> import BookCard from "../components/BookCard/BookCard.vue" defineProps<{ msg: string }>(); const emit = defineEmits<{ (e: 'submit', book: Book): void }>() </script> <template> <div class="book-index-template"> <header class="service-header"> ... </header> <main class="service-main"> <ul class="card-list"> <li class="card-list__item"> <BookCard :msg="msg" @submit="submitBook"> </li> </ul> </main> <footer class="service-footer"> ... </footer> <div> </template> <style scoped> .book-index-template { /* ... */ }; </style>
types
typesにはドメイン内で共通で利用する型定義を配置します。.tsファイルを配置し、利用する箇所で import します。
│ └── types/
│ └── index.ts
コンポーネントやファイル内で閉じた利用の型に関しては各ファイル内でインラインで記述して問題ありません。
services
servicesには外部リクエストやストレージ処理等の副作用を伴うロジックを配置するディレクトリです。
基本的にピュアな TypeScript で記述します。
├── services/
│ ├── FetchClient.ts
│ └── BookService.ts
types
src直下のservicesにはグローバルで利用する型定義を配置するディレクトリです。
基本的はルールはprojects配下のtypesと同じです。
主にライブラリのアンビエント宣言やドメインに依らないグローバルに利用される型宣言等が配置されることを想定しています。
utils
utilsには、ドメインに依らないグローバルに利用する共通処理と関連ファイルを配置するディレクトリです。
主に日付やテキストいったプリミティブな値を操作するような処理やグローバルに利用する便利関数等のドメイン知識に依存しない小さな処理を記述します。
// utils/Text.ts export function truncate(value: string, limit: number): string { if (value.length <= limit) return value; return `${value.trim().replace(/\s+/g, "").substring(0, limit)}…`; } if (import.meta.vitest) { const { describe, it, expect } = import.meta.vitest; describe("truncate", () => { it("文字列が指定した文字数で切り詰められること", () => { expect(truncate("あいうえお", 3)).toEqual("あいう…"); }); }); }