フロントエンドのディレクトリ構成はこうしたい。という話
2023/12/06
webフロントエンドのコンポーネント設計やディレクトリ構成については最近たくさん記事が出たりしていますが、未だデファクトスタンダードみたいなものは無いと思っており、個人的にこうしたいというのが有るので書き散らかしていきます。
前提
まず前提としてReact等のフレームワーク・ライブラリを使用する想定です。
私がReactメインで書いているためややReact的な考え方に寄っている部分もあると思います。
ある程度の規模と複雑さを持ったwebサービスを開発することとします。
file-system based routingの様なフレームワーク依存の話については考慮しつつ、どのフレームワークでも機能するように考えます。
方針
個人的にコンポーネント設計の際には、以下の様な事を考えています
- サービス依存のデータを明確にし共通化したい
- 機能ベースの分離をしたい
- コンポーネント粒度が自然に統一されるようにしたい
サービス依存のデータを明確にし共通化したい
サービスに関わるデータ構造を型定義し、各コンポーネントや関数はこの型を使用します。
モチベーションとしては超簡易なドメインモデルを作りたいみたいなイメージです。
ただし、あくまでインターフェースとデータ構造の統一を目的としているため各オブジェクトの振る舞いみたいなものは意識しません。
機能ベースの分離をしたい
技術的関心によって分離するか、機能的関心によって分離するかという2種類の手法がよく語られます。
ある程度複雑なロジックやサービス特有の文脈があるような場合は機能的関心に注目して切り分けするのが良いと考えています。
一方で技術的関心によってディレクトリを分ける手法に関して、個人的にメリットを感じている点が分かりやすさです。
機能的関心による切り分けはコンポーネントや関数の粒度だったり、切り分け方のルールをしっかり明示しないと曖昧になってしまう事が有りますが、技術的に切り分ける際にはそういったことは基本的に起き得ません。関数か型定義かコンポーネントかといった判断や、状態を持つか持たないかといった判断はまず人によって分かれないでしょう。また、開発者目線ではそのような形式で分かれていた方がコードが追いやすいというメリットもあるでしょう。
しかし、全体としては依存関係の明確化やインターフェースの統一等には機能的関心による分離が有効と考えているため、一部ファイルの整理のために技術的な関心毎によってディレクトリを切るような使用方法が良いのではと思います。
コンポーネント粒度が自然に統一されるようにしたい
これはただの願望なんですが、ディレクトリ構成とコンポーネントをどこに分類するかの定義を明確にすることでそのルールに則っていればある程度コンポーネントの分割粒度が揃うような状態が理想だと思っています。
コンポーネントの責務毎に適切に切り分けないといけない様なディレクトリ構造にすることで、人による差を無くしたいと思っています。
どこに置くか迷ってるならそれコンポーネントの粒度おかしいよ。と言える状態が良いです。
全体の切り分け
まず前述の通り機能やドメイン等の関心事によって大別したい思います。
そのためsrc
直下のディレクトリは
- ドメインに関心があるか
- webページとしての振る舞いに関心があるか
- 文脈の無いモジュールか
によって分けてみます。
「webページとしての振る舞い」とは、画面遷移や「リクエスト時にページに必要な情報を取得する」のような処理などのことで、技術的にwebサイトという特性に依存しているようなイメージです。
src
├── webページの振る舞いに関心のあるやつら
│ └── 各ページ
├── ドメインに関心のあるやつら
│ └── 各モデル
└── 文脈の無い汎用的なやつら
一旦大別出来たので、この中で関数やコンポーネント等をディレクトリ毎に分けてみます。
src
├── webページの振る舞いに関心のあるやつら
│ └── 各ページ
│ ├── コンポーネント
│ ├── 定数
│ └── 関数
├── ドメインに関心のあるやつら
│ └── 各モデル
│ ├── コンポーネント
│ ├── 型定義
│ ├── 定数
│ └── 関数
└── 文脈の無い汎用的なやつら
├── コンポーネント
├── 型定義
├── 定数
└── 関数
大分雰囲気が見えてきました。
ポイントとしてはやはり特定のデータやページに関心のあるやつらは関数もコンポーネントも全て1か所にまとまっている点ですね。
データ構造の型定義を「各モデル」のディレクトリ下でに置き、eslint-plugin-importやeslint-plugin-import-access等を上手く使えば公開範囲の設定も出来て綺麗にパッケージング出来そうです。
さらにドメインに関する型定義にbranded typeを使用すればドメインに関わるデータの受け渡しがかなり堅牢になりそうです。
※branded typeの参考記事
コンポーネントの分類
コンポーネントをさらに分類します。
まずContainer/Presentationalパターンをベースに以下の2つに分けます。
- 見た目だけを提供するコンポーネント
- 機能を持つコンポーネント
「機能を持つ」とはクライアントサイドで動作するJavaScriptを含んでいるかどうか、を基準にしてよいと思います。
意味的にはイベントハンドラを登録する側コンポーネントではなく、「イベントハンドラのロジックを持っているコンポーネント≒振る舞いを定義している側」のことです。
また、「機能を持つコンポーネントA」の「見た目を提供するコンポーネントB」があった場合はコンポーネントAの配下にコンポーネント用ディレクトリを切ってBを置くようにすると良いです。
さらに、見た目を提供するコンポーネントを以下のように分類します。
- 単体でUIとして意味を成すコンポーネント
- 単体では意味を持たず中の要素の位置や見た目を操作するコンポーネント
- アイコンとして使われるsvg要素
「 単体では意味を持たず中の要素の位置や見た目を操作するコンポーネント」はラッパーやコンテナ系の要素のように子要素の位置を調整したりするコンポーネントのことです。
これは例えばボタンやアイコンのように単体でUIとして成立するものとは明らかに性質や使われ方が異なっているため、切り分けたいと思っています。
svg要素については基本的には画像として使われることが多いと思います。
その場合切り分けておくと分かりやすいですし、汎用的なものは別パッケージ化するなどもできて後々便利です。
ちなみに個人的にはこの分類を以下のように名前を付けています
- 単体でUIとして意味を成すコンポーネント
elements
- 単体では意味を持たず中の要素の位置や見た目を操作するコンポーネント
layouts
- アイコンとして使われるsvg要素
icons
- 機能を持つコンポーネント
features
storeをどうするか
storeに保存するglobal stateのようなものをどうするかですが、これは格納する情報の性質を見て今まで切り分けてきたどこかに置くのがよさそうです。
- ドメインに関心があるか
- webページとしての振る舞いに関心があるか
- 文脈の無いモジュールか
この3つのどこに属するか、という観点で見てそれぞれの直下にstore
みたいなディレクトリを切るイメージです。
実際の例
あくまで例であってこれがベストかは分かりませんが、現状の結論です。
src
├── domain
│ └── user
│ ├── components
│ │ ├── elements
│ │ │ └── ElementComponent
│ │ │ ├── components
│ │ │ │ └── elements
│ │ │ │ └── ChildElementComponent
│ │ │ ├── const
│ │ │ └── modules
│ │ ├── features
│ │ │ └── FeaturedComponent
│ │ │ ├── components
│ │ │ │ └── elements
│ │ │ │ └── ChildElementComponent
│ │ │ ├── const
│ │ │ ├── hooks
│ │ │ └── modules
│ │ └── icons
│ │ └── SVGComponent
│ ├── const
│ ├── model
│ │ └── index.ts // ここにデータ構造を定義
│ ├── modules
│ └── store
│ └── index.ts // ここにglobal stateとかの定義
├── shared
│ ├── components
│ │ ├── elements
│ │ ├── features
│ │ └── icons
│ ├── const
│ ├── hooks
│ ├── modules
│ └── types
└── templates
│ ├── HogePage
│ ├── Top
│ │ ├── components
│ │ │ ├── elements
│ │ │ └── features
│ │ ├── const
│ │ ├── hooks
│ │ ├── modules
│ │ └── index.tsx // このコンポーネントが1画面に相当
│ └── store
│ └── hogeStore
└── pages // フレームワークの仕様に従う
└── index.ts // ここからtemplates/Top のコンポーネントを呼び出す