
Next.jsディレクトリ構成を設計再考してみる
Next.jsを利用する機会はますます増えていると考えていますが、適切なディレクトリ構成について疑問を持ったことがある方も多いかもしれません。
最近では、特定のディレクトリ構成とそれに基づくスタイルが定着し始めているため、それを紹介したいと思います。
なお、以下のディレクトリ構成はNext.jsのバージョン12系を基準としていますので、13系とは大きく異なることに注意してください。
1.設計時に参考にした構成
後述しますが、このディレクトリ構成では、`Features`というレイヤーが非常に重要な役割を果たしています。
2.これまで
これまで、クリーンアーキテクチャーやアトミックデザインなど、さまざまな構成でプロジェクトを作ってきました。
API層、Presenter層、Props注入層、Data表示層のように細かく分けるアプローチもありましたが、冗長さや再利用性の難しさに直面しました。
また、Reduxの文脈で使用されていたContainer / Presentationalの設計も試してみましたが、Redux自体の使用に加えて、Container / Presentationalファイルの手続き的な記述が多く、大変苦労した記憶があります。
さらに、アトミックデザインを試してみると、どのレイヤーにどの粒度のコンポーネントを分けるか判断が難しく、こちらも苦労した記憶があります。
3.新しいフロント構成
では、まず今回の構成をご紹介します。(*.styled.tsxはStyled-Componentを使用する場合です)
├─ components/
│ ├─ elements/
│ │ ├─ button/
│ │ │ ├─ Button.tsx
│ │ │ ├─ Button.styled.tsx
│ │ │ └─ Button.stories.tsx
│ │ ├─ text/
│ │ │ ├─ Text.tsx
│ │ │ ├─ Text.styled.tsx
│ │ │ └─ Text.stories.tsx
│ ├─ layouts/
│ │ ├─ header/
│ │ │ ├─ Header.tsx
│ │ │ ├─ Header.styled.tsx
│ │ │ └─ Header.stories.tsx
│ │ └─ footer/
│ │ ├─ Footer.tsx
│ │ ├─ Footer.styled.tsx
│ │ └─ Footer.stories.tsx
├─ pages/
│ ├─ api/
│ │ ├─ v1/
│ │ └─ v2/
│ ├─ product/index.tsx
│ ├─ cart/index.tsx
│ ├─ _app.tsx
│ ├─ _document.tsx
│ └─ index.tsx
├─ features/
│ └─ /product
│ ├─ api/
│ │ └─ getProduct.ts
│ ├─ stores/
│ ├─ constants/
│ ├─ components/
│ │ ├─ Index.ts
│ │ ├─ ProductList/
│ │ │ ├─ ProductList.tsx
│ │ │ ├─ ProductList.styled.tsx
│ │ │ └─ ProductList.stories.tsx
│ │ ├─ ProductName/
│ │ │ ├─ ProductName.tsx
│ │ │ ├─ ProductName.styled.tsx
│ │ │ └─ ProductName.stories.tsx
│ │ ├─ ProductPrice/
│ │ │ ├─ ProductPrice.tsx
│ │ │ ├─ ProductPrice.styled.tsx
│ │ │ └─ ProductPrice.stories.tsx
│ │ ├─ ProductImage/
│ │ │ ├─ ProductImage.tsx
│ │ │ ├─ ProductImage.styled.tsx
│ │ │ └─ ProductImage.stories.tsx
│ ├─ hooks/
│ │ └─ useProduct.ts
│ └─ types/
│ └─ index.ts
├─ hooks/
├─ styles/
├─ stores/
├─ configs/
├─ constants/
└─ types/
3-1./components
このレイヤーは、アプリケーション全体で使用するグローバルなコンポーネントに使用されます。
高い抽象度を持つコンポーネントをまとめる役割を果たします。
このレイヤーでは、副作用を持たずに親から受け取ったpropsのみを表示することに特化しています。
状態の受け取りに対して、条件分岐やクラスの付与などを行うことは問題ありません。
デザインシステムとの密接な関係を持つため、components/elements/v1のように事前にバージョン管理することもおすすめです。
デザインシステムが大幅に変更され、ボタンが複数のページで使用されている場合、古いバージョンのボタンを一時的に使用する必要があるかもしれません。
そのため、事前にバージョンを切っておくことは有用な方法だと考えられます。
副作用を持たないレイヤーであるため、このレイヤーが大きく膨れ上がることは問題ありません。
3-2./pages
このレイヤーでは、アプリケーション全体のルーティングを管理します。
ディレクトリ構成がそのままルーティングの管理に反映されます。
product/indexやproduct/detailのようなルーティングが増えていくことを予想して、予めディレクトリを作成し、その中にindex.tsファイルを作成する方法をおすすめします。
03-3./features
いよいよ今回の大目玉であるfeaturesです。
詳細に説明していきたいと思います。
▼/features/components
componentsでは共通化が難しい、特定の機能やドメイン固有のコンポーネントをまとめるレイヤーです。
たとえば、usersListやmenuListなどのList系のコンポーネントは共通化が難しく、独自のデザインが必要な場合が多いです。
また、異なるデータを扱うことも多いでしょう。
そのようなコンポーネントはfeatures/componentsにまとめます。
今回の例ではfeatures/productを取り上げていますが、features/product/components内ではList.tsxやName.tsxではなく、ProductList.tsxやProductName.tsxという命名規則を採用しています。
これには理由があります。
`機能名+コンポーネント名`とすることで、featuresがどの機能に依存しているかを直感的に理解しやすくするためです。
▼/features/components/index.ts
features/products/components/index.tsには以下のような意図があります。
● このファイルは、products関連のコンポーネントを一括でインポートするためのエントリーポイントとなります。
● コンポーネントのインポートパスを短くすることで、コードの可読性を向上させます。
● 他のファイルからこのエントリーポイントを通じてproductsコンポーネントにアクセスすることができます。
● 新しいproductsコンポーネントが追加された場合でも、このファイルの変更のみで済みます。
このような設計にすることで、features/products/componentsディレクトリ内のコンポーネントが簡潔かつ一貫性のある方法でインポートされ、管理されることが期待できます。
// features/products/components/Index.ts
import ProductName from 'components/features/product/components/ProductName/ProductName';
import ProductList from 'components/features/product/components/ProductList/ProductList';
import ProductPrice from 'components/features/product/components/ProductPrice/ProductPrice';
import ProductName from 'components/features/product/components/ProductImage/ProductImage';
export {
ProductName,
ProductList,
ProductPrice,
ProductName
};
// pages/index.tsx
import * as Product from 'components/features/product/components/Index';
const Index = () => {
return (
<Product.ProductImage />
<Product.ProductName />
<Product.ProductPrice />
);
}
features/products/components/index.tsは、features/components内の全てのコンポーネントをimportし、exportしています。
exportされたコンポーネントは、機能名を指定して使用する必要があります。
例えば、<Product.ProductImage />という使用方法により、View側でもすぐにProductのProductImageというコンポーネントであることが理解できるようになっています。
▼features/product/api
features/product/api/getProduct.tsはProductの情報を取得するためのファイルです。
ただし、features/product/components内での使用は禁止されています。
同じドメインでも、使用する場所を制限しないと混乱の原因になるため、注意が必要です。
各機能のコンポーネントは、必要なデータを取得することで、不要な再レンダリングを防止することができます。
ただし、データを取得できるのはページコンポーネントのみであり、構造をシンプルに保つために、制限を設けることが望ましいです。
パフォーマンスが重視されるアプリケーションでは、不要なレンダリングを避けることが重要ですが、一般的なWebアプリケーションではシンプルさが優先されます。(useCallBackやuseMemoは別にして考えます)
▼features/hooks
features/hooksは数字や文字を加工、日時操作など、主にPresenter層と同じ役割として機能します。
また、hooks層はfeatures/componentsでしか使わないようにします。
▼features/stores
features/storesは、Recoilの状態を管理する場所です。
グローバルな状態管理はシンプルなアプリケーションになるため、できる限り使用しない方がよいです。
しかし、必要な場合には特定のatomを用意します。
// features/product/stores/index.ts
import { atom } from 'recoil';
export const productAtom = atom<{id: string, name: string, price: number}>
({
key: 'featuresProduct',
default: { id: 1, name: "", price: 0
});
例えば、次のような場合を考えます。
● 親子関係のない離れたコンポーネント内のonClickイベントでProductの情報を更新する必要があり、その変更をProductコンポーネントと同期させる場合。
● Productコンポーネントの描画時にデータを取得し、取得した値を親子関係のない他のコンポーネントとProductコンポーネントの両方で同期して使用したい場合。
このような場合には、storesを使用して管理します。
▼features/constants
この機能内で固定の値を使用する場合には、contantsを使用します。
▼features/styles
スタイルのみのコンポーネントを使用する場合など、Styled Componentsなどを利用する場合には、stylesを使用します。
▼features/types
型の拡張が必要な場合には、typesを使用します。
3-4./stores
アプリケーション全体で共有されるグローバルな状態を管理するためには、Global Storesを使用します。
ログイン情報やショッピングカートなど、最も頻繁に使用される例です。Reduxのような冗長なボイラーテンプレートは必要ありませんので、肥大化する心配はありません。
3-5./constants
アプリケーション全体で共有される固定値を管理するためには、Global Cconstantsを使用します。
サイト名、ドメイン名、タイトル、ディスクリプションなど、多くの場合はこれらを管理します。
また、Styled Componentsを使用している場合は、カラーやフォントなどの変数プロパティもAppConfigで管理することができます。
3-6./hooks
アプリケーション全体で共有されるフックを管理するためには、Global Hooksを使用します。
ボタンの非活性化制御や検索ワードによるルーターの遷移管理など、ユーティリティに近い処理をここで管理することが一般的です。
これにより、関連するロジックを一元化し、コンポーネント間で再利用できるようになります。
3-7./configs
アプリケーション全体のグローバルな設定を管理するためには、Global Configsを使用します。
プロジェクトによっては、環境変数をどこで管理するかによって異なる方法を選ぶことがあります。
一般的に、.envファイルではなくTypeScriptファイルで設定を管理したい場合には、Configsを使用することが多いです。
Configsを使用することで、グローバルな設定を一元管理し、アプリケーション内のさまざまな箇所で利用することができます。
3-8./styles
アプリケーション全体のグローバルなスタイルを管理するためには、Global Stylesを使用します。
主にリセットCSSなどの共通のスタイルを適用する場合に使用されます。Global Stylesを使用することで、アプリケーション全体に一貫性のあるスタイルを適用することができます。
例えば、デフォルトのフォントやボックスモデルの設定など、全てのコンポーネントに適用される共通のスタイルを管理することができます。
3-9./types
アプリケーション全体のグローバルな型を拡張する場合には、Global Typesを使用します。
ただし、実際に使用する機会はあまり多くありません。
主にライブラリの型を拡張する場合や、特定の機能がアプリケーション全体に関わる場合に使用されることがあります。
しかし、このような場合でも、features内で型を拡張することができるため、Global Typesよりもfeatures内に持っていく方が適切かもしれません。
4.featuresの存在
おすすめの方法は、pagesレイヤーでデータの取得を行うという規則を設けることです。
これにより、データの責任と範囲を明確にし、コードの変更をスムーズに行うことができます。
ただし、注意点もあります。
それはReactコンポーネントの再レンダリングのタイミングです。
Reactでは以下のタイミングでコンポーネントが再レンダリングされるようになっています。
● stateが更新された時
● propsが更新された時
● 親コンポーネントが再レンダリングされた時
Pagesレイヤーでデータを取得し、取得したデータをProps経由で下層のコンポーネントに渡すことで、Propsの更新時にはPages以下のコンポーネントがすべて再レンダリングされることになります。
パフォーマンスが重要なアプリケーションでは難しい場合もあります。
一般的なWebアプリケーションでは、構造をシンプルに保つことが重要であり、"データの取得はapi/hooksの両方がPagesレイヤーでのみ許可される"という規約を設けることが良いと考えています。
再レンダリングのコストを考慮した上で、最上位の親(pages)から子(elements)へとデータが流れていく構成、この構成がコードの保守性を担保します。
データが不明な場所で取得され、データがどこで変更されているのかが追いにくくなる状況は避けるべきです。
Reactでは、useMemoやuseCallbackなどのフックを使用することでパフォーマンスを改善できます。
規約を設けながらこれらのフックを活用してパフォーマンスを最適化することをおすすめします。
5.そもそもを考える
色々と話してきましたが、デザインの使い回し可能性について重要なポイントを述べる必要があります。
それは、デザインが実際に使い回せるかどうかということです。
特殊なアプリケーションやLPの制作など、デザイナーのこだわりが存分に発揮される場合には、「コンポーネントの使い回し」に過度にこだわることが開発体験を悪化させる可能性があります。
実際、使い回しが難しい場合も多く存在します。
過度な抽象化は、細かなイレギュラーな要件に対応できないコンポーネントの作成につながることがあります。
そのため、自分のプロジェクトの特性をよく考慮して、コンポーネントやデータの設計を慎重に行う必要があります。
それでは。