Web フロントエンドの推しディレクトリ構成と Next.js App Router なコードベース | Offers Tech Blog
Offers を運営している株式会社 overflow の あほむ でございます。暖冬と言われつつもすっかり寒い季節ですね。おかげさまで割と走っているほうの師です。(師走)
n 年ぶり n 回目の Web フロントエンド
最後にメイン開発者の立場でコードをスクラッチしたのいつだったっけ?と遡ると 2018年ごろのブログ記事 がでてきました💀 実際には 2017 年から 2018 年にかけての作品ですかね。当時の構想から読み取れる重厚かつ自己表現の感に内心苦笑いしつつ久々の新規建立です。
今回はディレクトリ構造の面から紹介していきます。
推しディレクトリの先達たち
推しディレクトリという言葉に乗っかってみたものの、ゴメンそこまでの熱感は持っていないかもしれない🥺 とはいえ先達の記事もご紹介しておきます。
今回の前提
本稿において、これらの前提に依存した論はほとんど含まれない認識ですが一応のコンテキストとして記載しておきます。
- 最終目標は単体事業でありつつ実質マルチプロダクトな画面群のリプレース
- クライアントサイドでヘビーなビジネスロジックを持つ必要がない
- アプリケーション特性としては SaaS というよりは古典的 Web メディアに近い
- いわゆる Web フロントエンドエンジニアだけが触るわけではない
- 既存の巨大 Ruby on Rails モノリスからクライアントサイドを引っぺがす過程
現在のディレクトリ構成(要約版)
まずはこちらが現在のディレクトリ構成を tree
コマンドで吐き出して記事用に編集したものです。後述のとおり奇をてらうことなく無難な構成をしています。(たぶん)
.
├── apps
│ ├── acme-app # 任意の粒度における1アプリケーション
│ │ ├── public
│ │ └── src
│ │ ├── __generated__ # graphql-codegen の出力先
│ │ ├── app
│ │ │ ├── xxx
│ │ │ │ └── [xxxId]
│ │ │ └── yyy
│ │ │ └── [yyySlug]
│ │ ├── components
│ │ │ ├── Breadcrumbs
│ │ │ ├── Footer
│ │ │ ├── Header
│ │ │ └── ...
│ │ ├── features
│ │ │ └── acme-feature
│ │ │ ├── components
│ │ │ │ └── AcmeComponent
│ │ │ │ ├── AcmeComponent.module.css
│ │ │ │ ├── AcmeComponent.stories.css
│ │ │ │ ├── AcmeComponent.test.tsx
│ │ │ │ ├── AcmeComponent.tsx
│ │ │ │ └── index.ts
│ │ │ ├── hooks
│ │ │ ├── providers
│ │ │ └── utils
│ │ ├── hooks
│ │ ├── mocks
│ │ ├── providers
│ │ └── utils
│ └── storybook
├── docs
│ └── adr # ADR 置き場
└── packages # 共通パッケージ置き場
├── eslint-config-offers
├── md2html
├── prettier
├── stylelint-config-offers
├── test-utils
├── tsconfig
└── ui # デザインシステム相当の実装置き場
├── src
│ ├── Avatar
│ ├── Button
│ ├── Checkbox
│ ├── Dialog
│ └── ...
├── static
│ └── icon
└── tokens
apps/**
マルチプロダクト的なコードベース管理を志向して apps 内に独立した複数のアプリケーションを配置されることを想定しています。直近のアプリケーションは Next.js ですが、理屈としては Remix や Qwik など他のフレームワークを利用したコードが配置される可能性もあります。
いったん今回は、直近のあんまり複雑でないアプリケーションの例です。
Next.js App Router
直近は Next.js App Router アプリケーションなので file-system based router に紐付く app
ディレクトリが配置されています。src
ディレクトリが無いと何となく気持ちが落ち着かなかったので一応挟んであります😇
Separating features
feature、domain、 concern、context ...命名の悩ましさはありつつ一旦 feature としているのが関心事による縦割り分類です。求人応募、ブックマーク、求人情報表示、のような単位でざっくりと切り分けています。
.
└── features
└── acme-feature
├── components
│ └── AcmeComponent
│ ├── AcmeComponent.module.css
│ ├── AcmeComponent.stories.css
│ ├── AcmeComponent.test.tsx # 操作インタラクションがあれば必須
│ ├── AcmeComponent.tsx
│ └── index.ts
├── hooks # 状態ロジックの追い出し先 (コンポーネントテストで済ませがち)
├── providers
└── utils # 表示ロジックや制御ロジックの追い出し先 (ユニットテスト必須)
features 以下は特段のモデルを整備しているわけではなく、Components から適宜 hooks
や utils
としてテストしやすい単位を念頭にロジックを切り出してすだけの単純な構成です。表示上のフォーマット処理などは utils
のユニットテストで担保し、コンポーネントテストはインタラクション(またはビヘイビア)のテストに関心を限定しています。
※ 追記: app
内に components 等を内包していないのは端的に App Router に依存しないディレクトリ構成を優先しているためです :)
Layering components
コンポーネントのレイヤリングは下記のとおり。feature 別のコンポーネントについては 1 ファイル 200 行以内を目安に適宜コンポーネント分割を試みるくらいのゆるふわ運用です。
コンポーネント種別 | 責務 | fetch | test |
---|---|---|---|
App Router Page | ページ単位レイアウトと全体データ取得 必要に応じ Suspense や Hydration する |
Server Components のみ | しない |
Feature in App | アプリ内の関心事ごとの UI と機能の束 必要に応じ use client で境界を宣言 |
Client Components のみ | やる |
Common in App | アプリ内で広く共有される UI と機能<Breadcrumbs> 、<Header> など必要に応じ use client で境界を宣言 |
Client Components のみ | やる |
Design System UI | 基礎ビジュアルと操作性の一貫性を担保<Button> 、<Dialog> など@offers-www/ui Internal Package今のところ use client 宣言なしで運用 |
なし | やる |
fetch 周りは根元の Server Components (page.tsx
) と、末端の Client Components のいずれかに寄せることで全体をテストしやすくしています。
ディレクトリツリー上の配置
- Next.js App Router Page Components
apps/acme-app/src/app/**/page.tsx
- Feature Compoents in App
apps/acme-app/src/features/**/components/*.tsx
- Common Components in App
apps/acme-app/src/components/*.tsx
- Design System UI Compoents
packages/ui/**/*.tsx
app 内で共通のコンポーネントは hooks
等と合わせて features/shared
にまとめるほうが扱いの違いを階層で暗黙的に示そうとするより自明かもしれない点は検討中です。
packages/**
packages 以下はインターナルパッケージとして pnpm の workspace 機能を介して参照されます。うまく育てていきたい所ですが、影響範囲が広い依存関係を産むので注意が必要です。
例えば eslint のベース設定は下記のように参照し、個別に extends して必要に応じて上書きをします。同様に prettier や stylelint もベース設定を切り出しています。
// package.json
{
"devDependencies": {
"@offers-www/eslint-config-offers": "workspace:*"
}
}
// .eslintrc.json
{
"extends": ["@offers-www/eslint-config-offers"],
}
docs/adr/*.md
個人的に関わりのある Resilire さんで ADR の書き方をパクって学んできたので、後世に鋭いもので刺されないよう ADR (Architecture Decision Record) を書き溜めています。
ADR については別の場所でケーススタディを紹介しているので、こちらの記事もよろしければご覧ください。
コンセプト「無難」
コンセプトがどうこうではありませんが、万事を無難にまとめようと努めています。Web フロントエンドにおける"現代的構成"が中長期にわたって無難かと問われると何とも言い難いところですが…。
Turborepo のスターターを踏襲
pnpm dlx create-turbo@latest
今回の構成では pnpm workspace を前提として Turborepo のスターターをまるっと踏襲しています。前述の apps
packages
の分類も独自に考案したものではなく無難に踏襲しています。
前回も workspaces/packages で分けてパッケージの独立境界は引いていたのでマイナーアップデートの範疇ではあります。
Colocation 志向
コンポーネント内のファイル配置に限らずですが、全体的に Colocation を大事にしています。拡大解釈すれば feature も Colocation に近しいものがありますし無難ですね。
GraphQL も Fragment Colocation を使用しています。規模的にヘビーユースする程でないですが悪くない使用感を得ています。
上から順に読めるコードベース
トリッキーな実装の混入や処理の流れが大きくジャンプすることを避け、コードベースを上から順に読んで適宜参照を追えば無難に理解できる状態の維持も優先度が高いテーマです。
コードベースを理解する上での初見殺しを仕込まないのは、副業でスポット稼働してもらいやすくするためにも必要な観点でしょう。いいトシなので ぼくのかんがえたさいきょう
は自重します。
リプレース初期段階(絶賛開発中)
今回紹介したコードベースは Offers の某所を置き換えるべく段階的にリリースされている最中です。使用感も良くなっていくと思うので利用者の皆さまにおかれましてはご期待ください。
- dependencies を切り詰めて清貧を尊ぶ編
- Next.js を薄く浅く反抗的に使う編
- Design System UI Components 建立編
- 限られたリソースの現実的なテスト戦略編
機会があればこのあたりのトピックもどこかで記事にするかもしれません。ではでは。
関連記事
自分のブログにも小ネタを書いていたので、こちらでも紹介しておきます。
副業転職の Offers 開発チームがお送りするテックブログです。【エンジニア積極採用中】カジュアル面談、副業からのトライアル etc 承っております💪 jobs.overflow.co.jp
Discussion
とっても丁寧な構成解説ありがとうございます!一点気になったのですが、appディレクトリ配下のpage.tsxにはどこまでロジックを書くことを想定していますか?
featuresディレクトリにpage.tsxと1:1対応となるPageコンポーネントを作成し、page.tsxはPageコンポーネントを呼び出すだけにすることで、ルーティングの責務のみを持つようにする方法もあるなと思った背景です!
コメントありがとうございますー。
page.tsx 内の処理としてはルーティングに伴うデータ取得をする程度で、他はそのページの構成要素の選択と配置を担っています。
雰囲気これくらい(下記)の加減ですね。レイアウト情報を剥がすほどではないかなと思い、page.tsx を見ればどんなデータ取得して、何を表示しているかが大体分かる見通しの良さのほうを優先しています。
解説ありがとうございます!確かに、apiで数本でデータ取得して渡してくならpage.tsxで全体像把握できるメリット大きいですね!