カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

コンポーネントディレクトリをフラットに保つために

こんにちは。カミナシで「カミナシ 設備保全」の開発を行っている澤木です。今回はフロントエンドのコンポーネントディレクトリの構成、特に「ネストを深くしないために何をやっているか」という話をご紹介したいと思います。

feature-basedなディレクトリ構成

まず前提として私たちのチームでは機能(feature)単位でディレクトリを切り、各featureの中をさらにcomponents / hooks / contexts / model / repositoryといった責務ごとのディレクトリに分けるスタイルを採用しています。現在のフロントエンドの実装では一般的な構成かと思います。

features/hoge/
├── components/
├── contexts/
├── hooks/
├── model/
├── repository/
└── index.ts

今回はこの中で主にcomponents/ディレクトリに着目してどういった規約で実装を行なっているかについてお話しします。

なぜネストが深くなるのか

例としてfeatures/hoge/というfeatureを考えます。この中のcomponents/には一覧画面のHogeList、詳細画面のHogeDetail、フォーム画面のHogeFormなどhoge featureに関わるコンポーネントが置かれる形になります。今回は詳細画面のHogeDetailを例に見ていきます。

それぞれのコンポーネントには、そのコンポーネント内でしか使われないコンポーネントや hook、model などが存在します。例えばHogeDetailであればHeaderFooterContentといった子コンポーネントや、HogeDetail内でだけ使う hook(useHogeDetail)やロジック(logic.ts)など。これらはHogeDetailでしか使われないものなので、外部ではなくHogeDetailディレクトリの内部に置きたくなります(いわゆるcollocation)。

components/HogeDetail/
├── HogeDetail.tsx
├── components/
│   ├── Header/
│   │   └── Header.tsx
│   ├── Footer/
│   │   └── Footer.tsx
│   └── Content/
│       └── Content.tsx
├── hooks/
│   └── useHogeDetail.ts
└── model/
    └── logic.ts

このようにコンポーネントの階層が浅ければこれで問題ないのですが、子コンポーネントがさらに専用の子コンポーネントを持ち始めたりすると、どんどんネストが深くなっていきます。

components/HogeDetail/
├── HogeDetail.tsx
├── components/
│   ├── Header/
│   │   └── Header.tsx
│   ├── Footer/
│   │   └── Footer.tsx
│   └── Content/
│       ├── Content.tsx
│       ├── components/
│       │   └── Section/
│       │       ├── Section.tsx
│       │       └── components/
│       │           └── SectionTitle/
│       │               └── SectionTitle.tsx
│       └── hooks/
│           └── useContent.ts
├── hooks/
│   └── useHogeDetail.ts
└── model/
    └── logic.ts

この構成はコンポーネント内の構造を統一的に保てるというメリットはあるのですが、同じ components/ や hooks/ といった責務ごとのディレクトリが各階層に繰り返し現れるうえ、階層を辿っていきながら目的のファイルを探すのが大変になります。特にSectionTitleのような小さな部品を作るたびに階層が深くなっていくと、どこに何があるのか見通しが悪くなっていきます。

同一構造をあきらめる

そこで、各階層で components/ hooks/ model/ といったディレクトリを統一的に置く方針はあきらめて、コンポーネントの中身はフラットに並べてしまう方針にしました。

今回以下の二つの理由からあえて責務ごとにディレクトリを切る必要はないと考えました。

1つ目は、コンポーネント1つあたりの内部部品の数がそもそも多くならないこと。1つのコンポーネントの中の hook や model はせいぜい数個で、ディレクトリを切っても中身のファイル数は多くならないため、わざわざディレクトリを切らなくてもファイル数は見通せる範囲におさまります。

2つ目は、ファイル名の規約さえ揃っていれば、種類はディレクトリで分けなくても名前から判別できること。useXxx.ts であれば hook、Xxx.tsx であればコンポーネント、というように、hooks/ や model/ といったディレクトリに入っていなくても何のファイルなのかは伝わります。ディレクトリでの種類分けと命名規約は同じ情報を二重に持っているので、名前のほうを揃えればディレクトリは省略してよいと判断しました。

コンポーネントもフラットにしてみる

責務ごとのディレクトリを切らない方針に合わせてコンポーネント同士の入れ子もやめて、HogeDetail/ の中身も完全にフラットに並べてみました。そうすると以下のようになります。

components/HogeDetail/
├── HogeDetail.tsx
├── Header.tsx
├── Footer.tsx
├── Content.tsx
├── useHogeDetail.ts
└── logic.ts

これで HogeDetail/ の中身は完全にフラットになりました。

ただこうした場合、コンポーネント同士の入れ子が消えてしまったことで、どれが内部部品でどれが外から呼び出される公開コンポーネントなのかがわからなくなってしまいます。HogeDetail.tsx は外から呼び出される公開コンポーネントですが、Header.tsxFooter.tsxuseHogeDetail.tslogic.ts は HogeDetail 内でしか使わない内部実装です。同じ階層にフラットに並べてしまうと、どれを外から呼んでよくて、どれが内部実装なのかが見た目で区別できなくなってしまいます。

ネストの代わりに命名規則で集約する

そこで、ディレクトリで分けるのではなくファイル名で public/private を表現する方法を試してみました。コンポーネント内は以下のようなルールを決めています。

  • _ から始まるファイル・ディレクトリはprivate(外から参照されないもの)とする
  • _ が付かないもののみpublic(外から参照されるもの)とする
  • コンポーネントディレクトリの中は1階層しか掘らない

例えばHogeDetail/ の中はこのようになります。

components/HogeDetail/
├── HogeDetail.tsx
├── _Header.tsx
├── _Footer.tsx
├── _Content.tsx
├── _useHogeDetail.ts
└── _logic.ts

componentsディレクトリ内はhooksやmodelといった種類で分けることもせずにpublicかprivateかのみをファイル名で区別して全てをフラットにおくようにしています。この結果、_Header という名前を見れば「コンポーネント内の内部部品」だと判別がつき、HogeDetail/ の中にあるので HogeDetail の部品だと判別できるようになりました。この方法であればディレクトリを作成しなくとも、ファイル名と置き場所で親子関係が表現できます。

このルールは HogeDetail/ の中だけでなくその外側でも同じように適用できます。features/hoge/components/ 直下にも _ 付きの private コンポーネントを置けますし、features/hoge/hooks/_useHoge.ts のように feature 全体で使う private hooks を置くこともできます。どの階層でも「_ 付きは private」の1つのルールで通せるので、レイヤーごとに違う規約を覚えなくて済みます。

privateなファイルを表現する方法はディレクトリを分ける・barrel export(index.ts)で公開範囲を絞るなど他にもありますが、それらはいずれもディレクトリを増やすアプローチで、今回避けたかったネストや階層構造が生まれてしまいます。

_ プレフィックスはファイル名だけで public/private を表現できるので、ディレクトリを増やさずに公開範囲を示せるのが利点です。一方で、ファイル一覧が _ で埋まって見た目が少しノイジーになる、規約を知らないと意図が伝わりにくいといったデメリットは存在します。後者に関しては後述の dependency-cruiser で機械的に守る形にして補っています。

コンポーネントが大きくなったら上の階層に切り出す

先ほどの命名規則での集約ではコンポーネント内の部品が増えていくと、コンポーネント内での依存関係も増えていき見通しが悪くなっていく可能性があります。そこで、コンポーネントが大きくなってきたら、「ネストさせるのではなく上の階層に切り出す」というルールも同時に運用しています。

最小は、HogeDetail/ の中に全部 _ 付きでフラットに並べた状態。

components/HogeDetail/
├── HogeDetail.tsx
├── _Header.tsx
├── _Footer.tsx
├── _Content.tsx
├── _useHogeDetail.ts
└── _logic.ts

例えばこの中で _Content が自身の子コンポーネント(Section)や専用 hook(useContent)を持つようになったとします。そうした時にHogeDetail/ の中でさらにディレクトリを作成するのではなく、components/ 直下に独立したディレクトリとして外に出します。

components/
├── HogeDetail/
│   ├── HogeDetail.tsx
│   ├── _Header.tsx
│   ├── _Footer.tsx
│   ├── _useHogeDetail.ts
│   └── _logic.ts
└── _Content/
    ├── Content.tsx
    ├── _Section.tsx
    └── _useContent.ts

_Content は private のままなので、HogeDetail.tsx 側の import は変更不要です。ここでも同じ「_ 付きは private」ルールが効くので、_Content/ の中でもまた _Section.tsx を private として扱えます。さらに Section が育ってきたら同じように _Section/ を切り出せばよく、同じパターンが入れ子で適用される形になります。

こうして切り出された _ 付きのコンポーネントが features/hoge/components/ 直下に増えてくると、HogeDetail まわりだけでなく HogeForm の private 部品も同じ階層に並ぶようになり、_Content や _Section がどちらのコンポーネントに属するものなのかが見分けづらくなってきます。

features/hoge/components/
├── HogeDetail/
├── HogeForm/
├── _Content/    ← HogeDetail / HogeForm どちらの部品?
├── _Header/
├── _Section/
└── ...

そこで HogeDetail まわりや HogeForm まわりが大きくなってきたら、それぞれを sub-feature として別 feature に切り出します。features/ 全体ではこのように切り出していきます。

features/
├── hoge/
├── hoge-detail/
├── hoge-form/
└── ...

最初は hoge/ だけ切っていたものを、詳細画面やフォーム画面が大きくなってきたタイミングで hoge-detail/ hoge-form/ を切り出す、というイメージです。<feature>-detail <feature>-form で命名を揃えることで、どの feature の詳細やフォームなのかもわかるようにしています。

切り出しの基準は数値などで厳密には決めていませんが、深くネストさせる代わりに上に出す、という選択肢が常にあることで実装時の迷いが減り、コンポーネントディレクトリが一定以上には深くならないように保たれるようになっています。

命名規則をツールで守る

命名規則は、規約としてはシンプルですがコード上で強制されているわけではないので、どこからでも_がついているファイルをimportできてしまいます。そこで静的解析で弾けるように dependency-cruiser を導入して、_ 付きのファイルは外から参照できないようにルールを設定しています。

まず、feature の外からは index.ts(barrel)経由でしかアクセスできないようにします。

{
  name: "features-barrel-only",
  from: { pathNot: "^app/features/" },
  to: {
    path: "^app/features/[^/]+/.+",
    pathNot: "^app/features/[^/]+/index\\.ts$",
  },
},

from(参照元)が feature の外、to(参照先)が feature 内部の index.ts 以外のファイル、という組み合わせを禁止するルールです。これによって外部から feature の内部実装に直接 import することを防ぎます。

次に、その index.ts から _ 付きのファイルを再 export するのを禁止します。

{
  name: "no-underscore-in-barrel",
  from: {
    path: "^app/features/[^/]+/index\\.ts$",
  },
  to: { path: "/_" },
},

from が各 feature の index.tsto がパスに /_ を含むファイル(=private なもの)で、barrel export で private なファイルを外に公開すること自体を禁止するようにしています。

この2つによって、「外からは barrel export 経由のみ」かつ「barrel export は private なファイルを通さない」が成立し、_ 付きは構造的に外から参照されない状態が保てます。

おわりに

今回コンポーネントディレクトリをフラットに保つために以下の二つの工夫を導入しました。

  • ネストして集約する代わりに、命名規則(_)で集約する
  • 深くネストさせる代わりに、大きくなったら上の階層に切り出す

元々 collocation を意識しすぎてコンポーネントディレクトリのネストが深い構造になっていたのですが、この運用にしてからディレクトリ構造の見通しが良くなりました。またコンポーネントの切り出し方もルールとして決めておくことで、実装時の迷いも減っています。_ 1文字というシンプルなルールの割に意外とディレクトリの見通しはよくなったので、コンポーネントディレクトリの規約やネストに悩んでいる方は試してみる価値があるかもしれません。