SPA Componentの推しディレクトリ構成について語る

9 min read 1

こんにちは、よしこです。

この記事は 2020年に立ち上げたWebフロントエンド構成の振り返り の「Componentのディレクトリ構成」項の詳細記事です。単体でも読めますが、よければ元記事もあわせてどうぞ!


この記事では、今わたしが 株式会社ナレッジワーク というスタートアップで開発・運用しているプロジェクトにおいてうまくいっていると感じているComponentのディレクトリ構成についてご紹介していきます。

ディレクトリ構成

Componentは src/components の中にまとめていて、その下に以下の4種類の分類ディレクトリを切っています。

  • src/components/page
  • src/components/model
  • src/components/ui
  • src/components/functional

分類ディレクトリを考えるにあたって重視したポイントは以下。

  • 新しくcomponentを作るとき、どこに分類させるかで迷わないこと(最重要)
  • 運用中、作成済みのComponentの分類先の移動がなるべく発生しないこと
  • ディレクトリの階層の深さにルールがあり、予測可能であること

それぞれの分類ディレクトリの中ではComponentごとのディレクトリを作り、その中に関連ファイルをまとめています。
図にすると以下のような感じです。(わかりやすいよう各分類ディレクトリ以下にひとつ例となるComponentを置いています)

src/components/
├─ page/
│  └─ Top/
│     ├─ Top.tsx
│     ├─ Top.page.tsx
│     ├─ Top.module.css
│     ├─ Top.stories.tsx
│     └─ index.ts
├─ model/
│  └─ user/
│     └─ UserAvatar/
│        ├─ UserAvatar.tsx
│        ├─ UserAvatar.module.css
│        ├─ UserAvatar.stories.tsx
│        └─ index.ts
├─ ui/
│  └─ Button/
│     ├─ Button.tsx
│     ├─ Button.module.css
│     ├─ Button.stories.tsx
│     └─ index.ts
└─ functional/
   └─ KeyBind/
      ├─ KeyBind.tsx
      └─ index.ts

以下でそれぞれの役割を簡単に紹介していきます。

page

pageに分類しているのは「1つのページを表すComponent」です。

このプロジェクトはNext.jsを使っているので src/pages ディレクトリが別途あるのですが、そちらは責務をルーティングのみと定めて、ページの実体は src/components/page に書いています。
src/pages 以下だとファイル名 = URLになるのでファイル名と付けたいコンポーネント名が必ずしも一致しなかったり、ルーティングの変更でディレクトリ階層間のファイルの移動が発生するので、そちらにComponent定義を巻き込みたくなかったためです。
あとComponent定義は src/components 以下に全部ある!というほうが探しやすいかなと。

src/components/page 以下のComponentだけは、実体が Hoge.page.tsxHoge.tsx に分かれています。( index.ts からは Hoge.page.tsx をexport)
これはページレイアウトとメインコンテンツの定義を分離するためと、メインコンテンツで発生する非同期fetchをページレイアウト側のSuspenseでキャッチするためです。

たとえばトップページでいうと以下のようなイメージになります。

src/pages/index.ts
import { withAuth } from '@/pageHocs/withAuth'
import { TopPage } from '@/components/page/Top'

// ページごとの認証の要不要だけはsrc/pages側でhocをかけるかどうかで管理している
export default withAuth(TopPage)
src/components/page/Top.page.tsx
import { Top } from './Top'

export const TopPage = () => {
  return (
    <PageWithHeader header={<Header />}>
      <PageWithSideBar sideBar={<SideBar current="TOP" />}>
        <ErrorBoundary>
          <Suspense>
            <Top />
          </Suspense>
        </ErrorBoundary>
      </PageWithSideBar>
    </PageWithHeader>
  )
}
src/components/page/Top.tsx
export const Top = () => {
  // イメージコード。非同期fetchでSuspenseが走る
  const data = useTopPageData() 
  return (
    <main>
      // トップページの実装
    </main>
  )
}

全ページに共通するようなグローバルヘッダやサイドメニューなども各Componentの Xxx.page.tsx でそれぞれ配置しています。
共通だと思って _app.tsx とかに置いて完全に共通化してしまうと、例外的にヘッダを持たないページが出てきたりしたときに大変なんですよね…そういう経験があったので、今ではレイアウト系はページごとに定義を繰り返したほうが柔軟だなと思っています。(styleは繰り返し書かなくていいようUI Component化している)

model

modelに分類しているのは「modelに関心を持つComponent」です。

ここでいうmodelというのは例えばuser, article, category...のようなやつです。(domainとかentityみたいな呼び方もありますが、どの呼び方を使うにしろここで説明する構造には影響がないので割愛)

src/components/model 以下だけは唯一このmodelごとのディレクトリが挟まり、その下にそれぞれのComponentがフラットに並ぶので、他の分類ディレクトリよりも1階層深くなります。
それぞれのComponentの名前にはmodel名をPrefixとして付与します。Component単体で見たときにもどのmodelに関心を持つComponentかがわかりやすいようにするためです。

以下のようなイメージです!

src/components/
└─ model/
   ├─ article/
   │  ├─ ArticleCreateForm/
   │  └─ ArticleUpdateForm/
   └─ user/
      ├─ UserAvatar/
      └─ UserList/

複数のmodelに依存するComponentもたまにありますが、modelの主従関係がはっきりしている場合が多いので、「このComponentはどのmodelに分類すればいいんだ?」と悩んだことはあまりありません。

あと「modelに関心」というのは必ずしもそのmodelに依存していなければならないというわけではなく、そのmodelの文脈で使われるか?(言い換えれば、アプリケーションからそのmodelが不要になって消える場合はそのComponentも同じく不要になるか?)という判断です。
なので、たとえば記事(article)が一つもないときに「記事がありません」とEmpty表示をするためのComponentを作るときには、そのComponentはpropsを持たず何のmodelデータにもmodel定義にも依存はしていませんが、articleの文脈で使われるので src/components/model/article/ArticleEmptyMessage とする、のような感じになります。

ui

uiに分類しているのは「modelに関心を持たない、見た目を伴うComponent」です。

定義広くない!?ユーザーインターフェースに限らなくない!?っていう感じはありますね。笑
でもuiという単語が一番「modelに関心を持っていてはいけないレイヤー感」が出るかな…と思ってこうしています。(sharedとかcommonでもいいかもしれませんが、どの呼び方を使うにしろここで説明する構造には影響がないので割愛)

定義は「modelに関心を持たない」なので、Componentの用途や大小は問いません。なのでModalのような大きめで複雑なComponentもButtonのような小さめで単純なComponentも全部ここに入っています。
そう書くとすごく数が膨らみそうですが、実は意外とそんなこともなく、今のところはこの定義で困っていません。アプリケーションのViewって何らかのmodelの文脈を持つ部分が圧倒的に多いので、uiディレクトリの中には必然的に枝葉のUI Componentか、汎用化されたレイアウト定義が入ってくる形になっています。

ただ、一部だけ例外的になっているComponentもあります。ページを横断するグローバル系のComponentです。
たとえば、グローバルヘッダの中には記事検索窓やログイン中のユーザーのアイコンなど、 modelに関心を持つComponentが配置されているのですが、「グローバルヘッダ」としては特定のmodelに関心を持つものではないんですよね。なので今のところは例外として依存ルールを破りつつuiに置いています。( ui -> model の依存はルール違反だが、目をつぶる)
uiではなく依存ルールのpageとmodelの間に立つようなglobalみたいな分類軸を新設してもいいかもなーと思ってはいるのですが、数がとても少ないので今はちょっと例外として置いといちゃってます。

functional

ここは先の3つよりもだいぶ実験的な分類になります。

functionalに分類しているのは「modelに関心を持たない、見た目を伴わないComponent」です。
見た目を伴わないというのは、機能だけを持つということです。なのでディレクトリに cssstories ファイルが同梱されていません。Componentの返り値もnullなことが多いです。

それってhooksでよくない?って感じで、まさにhooksでも実現できる類の機能たちではあるんですが、個人的な好みでComponentのインターフェイスをとりたいケースがあったりします。以下の点など。

  • 使う側のComponentとmount/unmountのタイミングを切り離せる
  • refのインターフェイスを露出させず中に閉じ込められる(要素は一つ増えますが)
  • jsxの中で使えるので、関連する要素の近くに置くことで意味が通りやすくなる

たとえば functional以下にある KeyBind というComponentの例でいうと、「編集モードのときだけESCを押したら編集モードを抜ける挙動」を実現したいとき

SomeView.tsx
<div>
  {isEditMode ? (
    <>
      <KeyBind keys={['esc']} onPress={exitEditMode} />
      <Input name="title" value={data.title} onChange={changeTitle} />
    </>
  ) : (
    <p>{data.title}</p>
  )}
</div>

のように実装できます。

jsxではなく上の方でisEditModeを絡めたhook呼び出しでも同じことはできると思うんですが、なんか条件分岐のjsxの中で一緒に書けたほうが気持ちいいなって思ってComponent化しています。

ちなみにfunctionalには他に以下のようなComponentたちがいます。

ただここは完全に好みなので、Componentではなくhooksとして実装して整理したい派もいるだろうなと思います。Componentでなければならないのは上記の中だとSuspenseとErrorBoundaryぐらいなので、それだけuiのほうに入れちゃってfunctonalという分類は使わない、という方法もとれると思います。

依存ルール

分類ディレクトリ間の依存ルールは以下のような感じ。自分の横か下にある分類軸のComponentのみimportしてよい、としています。page以外は自分の所属する分類軸の参照もOK。

(ちなみにこういったimportルールは こちら で公開した自作eslintルールで縛っています!)

Component構成

Componentを構成する基本ファイル郡は以下です。

Hoge/
├─ Hoge.tsx
├─ Hoge.module.css
├─ Hoge.stories.tsx
└─ index.ts

scaffdog と自作の雛形を使って、これらのファイルとベースの中身が一括で作成されるようにしています。なので基本的に全Componentにひとつ以上はstorybookのstoryがある形になっています。

scaffolderの活用についてはまた別途深堀り記事を書く予定です。

振り返って、やっておけばよかったこと

ディレクトリ構成についてはあまり反省がないのですが、Component内部のことでいうといくつか思っていることがあります。

hooksの抜き出し

自分の中のcustom hooksを作るタイミングとして「複数のComponentで共有したいような汎用的な処理」が出てきたときに src/hooks に汎用hooksとして切り出す、という形をとっていました。
ただ、このルールだと「他のComponentとかぶる処理はないけどロジックが多めなComponent」の内部ロジックがどんどん膨らんできてしまうんですよね。
今考えれば、上記で紹介したComponent構成の中に Hoge.hooks.ts もgenerateされるようにしておいて、ひとつのComponentの中でもロジックは同階層のhooks用ファイルに切り出す、というルールにしておいたらもっと見通しがよくなったかもと思います。

useMemo, useCallbackの適用

useMemoやuseCallbackによるメモ化はどこまで厳密にやるべきなのか?というのは作り始めのときからかなり悩んでいたところでした。
パフォーマンス的な観点では「重い箇所にかけよう」みたいな理解をしていたので、全部が全部最初からかけてしまうとむしろ余計な処理が増えてしまうのかなとか、depsの指定は結構ミスが発生しやすいところなので重くもないのにわざわざミスしやすいポイントを増やすのはどうなのかなとか…。
などなど悩んだ結果として「custom hooks内から返す関数などは必ずメモ化する」「Component内での関数などは特別重くなければメモ化しない」という基準に決めてやってきました。

が、その後もモヤモヤしていたところ、とある場で「ReactはImmutabilityを前提に考えられている世界なので、意味合い的にはメモ化して内容が変化したときにだけ参照も更新されるようにしておいたほうが正しい」というコメントをいただく機会があり、なるほど!!と思いました。パフォーマンスだけじゃなくて、ライブラリの思想の中でどうあるべきか?という視点は新鮮でした。

あとは、「重くなったらかけよう」と言っておきながら、いざ重くなったときにひとつひとつメモ化からやっていくのは大変だしデグレが怖い!という実感が、アプリケーションが大きくなればなるほど湧いてきたり。笑
なので、やっぱり最初から全部やっておくルールにしといたほうが楽だったかな?と振り返って思っています。ここはちょっと色んな方の意見を聞いてみたい。

container/presenterの分離

これもまた複雑になることとのトレードオフなのですが。
うちのプロジェクトではComponentのlocal stateを使っているので、storybookで外から全状態を網羅的に再現できないんですよね。
なので、たとえばlocal stateを使うComponentは内部でcontainerとpresenterに分割して、local stateはcontainterに寄せ、presenterはpureに全状態を外から受け取るような構成にしておけば、presenterに対してstoryを書くことでstorybookによるvisual regression testのカバレッジを100%近くに上げられるんだよなーというのを思っています。
ただファイル数も増えるし、stateがでてくるたびに分割していくのはさすがに面倒かな…と思って踏み切れてません。もしくはscaffolderで全Component最初から分割された状態に出力されれば意外と楽なのかな?
ここも、やっている方いたら意見聞いてみたいですね。


以上!

冒頭から紹介したsrc/components以下のディレクトリ構成は、元の構成振り返り記事の中で意外にも反響が大きかったところでした。 page model ui の分類方法は、シンプルで必要最小限ながらわりと広いユースケースをカバーできていて結構気に入っています。もし参考になるところがあれば幸いです。

元記事では他にも様々な項目の構成紹介をしています。よければあわせて読んでみてください!

https://zenn.dev/yoshiko/articles/32371c83e68cbe

この記事に贈られたバッジ

Discussion

こんにちは。
useMemo useCallback のお話、とてもわかる部分が大きいのでコメントさせていただきます。

hooks は使っていけばいくほど、難しさに直面する人は多いようで、自分の周りの人も結構どう書くのが良いか迷ってる人多いですね。

ちゃんと書こうとすると簡単なことしたいだけでも、こんなに書かないといけないのか…となったり。レビューで安全かどうか見るのが大変だったり。

eslint で deps チェックしてくれるとは言え、直書きの useMemo useCallback などが対象なので、カスタムフックで deps を使うと一気にリスクが上がるなど、ちゃんと書けてるかを担保するコストが意外と大きいなと感じています。

さらに、ちゃんと書いているつもりでも、一箇所でも穴があると無駄な再描画に繋がるし、その割にパフォーマンス的に困ってない場所で頑張るのも無駄感あるし、塩梅みたいなところが出てくるとそれはそれでまた方針などに悩みます。

そんな中、ちょうど先日 useCallback の調べごとをしているときに以下の issue にたどり着きました。

https://github.com/facebook/react/issues/16956
2019年から議論しているようですが、 useCallback の使い方について色々書かれています。
主にイベント系のコールバックなど、描画内容に影響を与えない関数の場合、useRef と組み合わせて使えるととても簡潔に書けるようになるが、 useLayoutEffect が子から親に向かって実行されていくのでうまくいかないとか、 render 中に ref.current を更新した場合は Concurrent Mode のときにおかしくなるとか、議論を追っていくとややこしさ満載です。

途中で deps や useMemo を全て自動で補完するような最適化が入ればエンジニアが考えることがすごく減るみたいな夢のようなことも書いてありますが、現実的とは思えませんしね。

一応 useRef と組み合わせる場合、 useLayoutEffect の直前のタイミングで親から子の方向に実行される機構が追加されると成立しそうということはわかりましたが、どう着地するのか気になります。

ログインするとコメントできます