Next.jsにおけるアーキテクチャー設計を考える
By kkoudev
Next.js はSSR対応したReactを使ったUniversal JavaScriptフレームワークで、Vueを使っている Nuxt.js とよく比較されています。
セットアップがとにかく簡単で、TypeScript対応も非常に簡単です。(tsconfig.json の空ファイルを置いておくと自動的に中身を作ってくれます!)
今回とあるSPAを作成するにあたって Next.js を採用したため、そのとき考えたアーキテクチャー設計を書き残したいと思います。
ディレクトリ構成とクラス設計
ディレクトリ構成はクラス設計にも反映される重要な部分になります。
近年自分が好んでいるクラス設計は「Onion Architecture + Clean Architecture + Re-Ducks Pattern」です。
(React Nativeでもこの構成でアプリケーションを作成しています)
フロントエンド界隈だとこのような構成を取っている人は殆どみたことがないのですが、アプリケーションの規模が大きくなってもコードが複雑化しづらいので好んで使っています。
どういう構成なのかというと、以下のようなディレクトリ構成・ファイル構成となります。
src
├── applications # アプリケーション層
│ └── usecases
│ ├── base
│ ├── stateful
│ └── stateless
├── config
├── di
│ ├── binders
│ │ ├── test
│ │ └── pages
│ └── defs
│ ├── domains
│ ├── interfaces
│ └── usecases
├── domains # ドメイン層
│ ├── entities
│ ├── repositories
│ ├── services
│ └── values
├── infrastructures # インフラストラクチャ層
├── interfaces # インターフェース層
│ ├── controllers
│ │ └── base
│ ├── presenters
│ │ └── base
│ └── ui
│ ├── components
│ │ ├── atoms
│ │ ├── base
│ │ ├── molecules
│ │ ├── organisms
│ │ └── templates
│ ├── popups
│ ├── screens
│ └── styles
├── pages
└── utils
データの流れとしては、
- インターフェース層 (interfaces)
- アプリケーション層 (applications)
- ドメイン層 (domains)
- インフラストラクチャ層 (infrastructures)
の順に流れることを意識しています。
基本的にはOnion Architectureの考え方なのですが、
インターフェース層のControllerとPresenterの使い分けや、
アプリケーション層のUseCaseとInteractorについてはClean Architectureの考え方を一部取り入れています。
各レイヤについて1つずつ説明していきます。
前提として、状態管理には MobX を使うこととしています。
各レイヤの説明
インターフェース層 (interfaces)
インターフェース層はユーザーからの入力データを最初に受け取る層になります。
各配下のディレクトリについては以下の通りです。
プレゼンター (presenters)
画面に変更を反映する際の状態値とそのアクションメソッドを定義します。
具体的には MobX 管理対象となる値(observable)とそれを操作するメソッドを定義します。
プレゼンターのアクションメソッドには MobX の@action
デコレーターを付与し、プロパティメソッドには @computed
デコレータを付与します。
このディレクトリでは各画面やコンポーネントで共通利用されるプレゼンターのみを定義し、
各画面やコンポーネントにのみ依存するコントローラーは ui ディレクトリ配下の対象画面またはコンポーネントのディレクトリ内にクラスファイルを作成します。
コントローラー (controllers)
画面からの入力処理を行うコントローラーを定義します。
各画面のReactコンポーネントにコントローラーのインスタンスを持たせ、
そのコントローラーに定義されたイベントハンドラを利用してイベントをハンドリングします。
イベントハンドラとなる各メソッドにはMobXの @action.bound
デコレーターを付与します。
コントローラー内ではPresenter、UseCase、Repository、Serviceを参照可能としています。
このディレクトリでは各画面やコンポーネントで共通利用されるコントローラーのみを定義し、
各画面やコンポーネントにのみ依存するコントローラーは ui ディレクトリ配下の対象画面またはコンポーネントのディレクトリ内にクラスファイルを作成します。
コンポーネント (ui/components)
各画面で共通利用されるようなコンポーネント(ボタンなど)を定義します。
コンポーネントはAtomic Designで設計し、atoms、molecules、organismsの3つの分類となるように作成します。
ポップアップ (ui/popups)
ポップアップのコンポーネントをここに定義します。
画面 (ui/screens)
各画面実装を定義します。具体的には画面を表すReactコンポーネントを定義し、定義したReactコンポーネントを画面として扱います。
グローバルスタイル (ui/styles)
Next.jsにおいて、グローバルに読み込むデフォルトのスタイルや、
CSS変数(custom properties)、メディアクエリの定義をここにまとめます。
アプリケーション層 (applications)
インターフェース層から渡されたデータを元に
アプリケーション固有の処理を定義します。
ユースケース (usecases)
ユースケースはビジネスロジックに関わる処理を定義します。
役割としてはドメイン層のサービスに近いですが、こちらはより画面やコンポーネントに依存するような処理を定義します。
ユースケースはコントローラ(controllers)から呼ばれることを前提として作成します。
このディレクトリでは各画面やコンポーネントで共通利用されるユースケースのみを定義し、
各画面にのみ依存するユースケースは ui ディレクトリ配下の対象画面のディレクトリ内にクラスファイルを作成します。
ドメイン層 (domains)
アプリケーション層から渡されたデータを元にインフラストラクチャ層へアクセスするためのインターフェースを定義します。
この層は以下の 3 つに分かれています。
エンティティ (entities)
エンティティはユーザー情報など、主に識別子を持つデータを表現する場合に利用します。
リポジトリ (repositories)
リポジトリはデータの保存または取得を定義します。
ドメイン層でのリポジトリはインターフェース(処理の実態は定義しない)として定義します。
サービス (services)
サービスはデータの保存・取得に関係しない処理を定義します。
(例として、メール送信など)
ドメイン層でのサービスはインターフェース(処理の実態は定義しない)として定義します。
バリューオブジェクト (values)
enum のような種別を定義します。
一般的な定義だと、不変的なデータオブジェクトを定義する際に利用するのですが、
改修時にエンティティとの使い分けが曖昧になるケースが多くなりがちだったので、
enum のような種別以外のデータオブジェクトは全てエンティティにするという区分けとしています。
インフラストラクチャ層 (infrastructures)
APIやローカルストレージなど、実際にデータが保存されている箇所へ実際にアクセスする実装ロジックを定義する層になります。
具体的にはリポジトリやサービスの実装クラスの定義をこの層で行います。
リポジトリとサービスの実装クラス名は、インターフェースと区別するために末尾に Impl という名称を末尾に付けます。
その他機能区分の説明
設定 (config)
アプリケーション全体で利用する設定値を管理します。
依存性注入設定 (di)
各画面やコンポーネントの依存関係の設定を行います。
依存関係の設定には Inversify を使っています。
ここに記述する処理としては、具体的には、Inversify の bind 処理を記述していきます。
また、bind時に必要となる識別子となる定数も宣言します。
ページ (pages)
Next.jsのページ(URL)を表すディレクトリです。
Next.jsではこのディレクトリを /pages か /src/pages のいずれかに必ず配置しなければならず、
それ以外の場所に配置することは現状許可されていません。また、名前も変更できません。
このディレクトリ内の配置されたファイル構成でURLが作られます。
画面(ui/screens)やルーター(ui/routers)をインポートして利用箇所で定義します。
ユーティリティ (utils)
どの層でも利用可能なよく使う処理(ユーティリティー関数やクラス)を定義します。データの流れそのものには関係しない便利関数を集めたものになります。
コンポーネント・画面のファイル構成
上記までは Onion Architecture + Clean Architectureを参考に作成したディレクトリ構成となりますが、
これに加えて各コンポーネントと画面のディレクトリ内を以下のようなファイル構成としています。
例えば、DefaultButton というコンポーネントを定義すると、
src ディレクトリ構成中の interface/ui/components/atoms
に以下のようなファイル構成で配置します。
DefaultButton
├── controller.ts
├── index.tsx
├── presenter.ts
├── props.ts
├── style.module.css
└── view.tsx
これは Re-Ducks Pattern を参考にした構成です。
以前Re-Ducks Patternを使わない構成を取ったことがあるのですが、
その際に画面やコンポーネントのController、Presenterが別のディレクトリにあるために非常に探しづらいという問題がありました。
探すだけならWebStormなどのIDEの補完機能を使えばなんとかなるのですが、
随所に関連クラスが散りばめられているので複製したい場合が地味に大変でした。
Re-Ducks Patternを採用することでこの2つの問題に対処しています。
なので、上記の各レイヤの説明にも記述しましたが、各画面やコンポーネントに依存するControllerとPresenterはそれらのディレクトリ内に定義し、
interfaces/controllers
や interfaces/presenters
に定義するのは各画面やコンポーネント全体に渡って利用されるControllerやPresenterを定義することとしています。
基本的には基底クラスの定義がここに該当します。
次に、画面のファイル構成です。
例として LoginScreen という画面を定義すると、
src ディレクトリ構成中の interface/ui/screens
に以下のようなファイル構成で配置します。
LoginScreen
├── controller.ts
├── index.tsx
├── presenter.ts
├── props.ts
├── style.module.css
├── usecase.ts
└── view.tsx
コンポーネントとの違いはUseCaseの有無になります。
UseCaseもControllerとPresenterと同じで、画面依存のUseCaseは各画面のディレクトリ内に定義し、
applications/usecases
に定義するのは各画面で共通利用されるUseCaseを定義することとしています。
また、各画面で共通利用したいUseCaseについてはPresenterに依存したステートフルなものと、依存しないステートレスなものがあるため、
そこもわかるように applications/usecases/stateful
、applications/usecases/stateless
のようにディレクトリを分けて定義しています。
(ちなみに applications/usecases/base
は基底クラスです)
CSS設計について
CSS Modules採用の経緯
CSSについてはCSS Modulesを採用しています。
(*.module.css という命名になっているのはNext.jsでCSS Modules使うためにはこの命名ルールとなっているためです)
styled-componentsを採用せずにCSS Modulesを採用した経緯としてはいくつかあるのですが、
端的にいうとコードとスタイルを明確に分離して記述したかったという意図があります。
つまり、TypeScriptコード内にスタイルを記述させる余地を与えなくなかったという感じです。
あとは、CSS Modulesを使うとセレクタをエディタ補完で参照できるという点も利点の1つです。(WebStomやVSCodeでは可能)
他にも styled-components は動的にスタイル生成をするためにパフォーマンス的な懸念もあるというところでした。
もっともこの辺はあまり詳しくないのでこれ以上は割愛します。
PostCSSを使ったCSS Level 4の採用
CSSについてもSass(インデントベースのSass)やSugarSSからは完全に離れて、
postcss-preset-envを使ったCSS Level 4の勧告候補または草案となっているCSS機能で記述することにしています。
これについては stylelint との相性問題が一番の理由です。
私はインデントベースのSassが好きでずっと利用してきたのですが、 stylelint との相性が非常に悪く、
特に --fix
を使うと@importや@media記述したブロックが全部消えるケースがあるのと、
@media print の記述ですらエラーになるくらいにパーサーがダメすぎて使い物になりませんでした。(この記事の執筆時点では少なくともそんな状態です)
インデントベースのSassのパーサーである postcss-sass は3年くらい前に登場したにもかかわらずこの状態なのでこれ以上は期待できないと判断してのことです。
SugarSSについてはSassよりはまだ相性が良いのですが、エディタとの相性問題(WebStormではsyntax highlightに非対応)や、SugarSSそのものが2年ほど更新されていないという更新頻度の少なさからも候補から外しました。
SCSSは stylelint との相性も悪くはなかったのでそちらを使うという選択肢もあったのですが、
SCSSを使うくらいなら変数(cusotm properties)も使えるようになったCSS Level 4に準拠した書き方を採用してみようと考えました。
(AppleやFacebookなど、custom propertiesを使っているサイトを多くみかけるようになったのも理由の1つです)
とはいえ完全に標準の機能のみを採用したわけではなく、postcss-preset-env にて stage 1 から利用可能なものを採用しています。
stage 1からにした理由としては今までSassのmixinで利用してきたブレイクポイント判定用の記述を @custom-media
で代用したかったというのが理由です。
mixinとほぼ同等の機能である @apply も当初採用するかどうかを検討したのですが、この機能はもう非推奨(CSS Level 4では採用されない)となっていたため @custom-media
で出来ることに留めました。
これに加えて、どうしてもカスタム関数を利用したくなったときのために、 postcss-functions を導入しています。
また、インデントベースのSassが好きだった理由としては「誰が書いても同じインデント幅の記述を強制できる」「カッコやセミコロンがいらない」といったところなのですが、
この問題に対する対処としては Prettier でフォーマットすることである程度カバーしています。
コーディングルールとしてRSCSSを採用
RSCSSはコンポーネント設計を行う際のスタイリングとしては非常に相性が良いために採用しています。
特にコンポーネントがネストする際に発生しやすいスタイリングの問題(セレクタ名が被った時問題)を最も回避しやすいのがRSCSSであると考えたため採用しています。
CSS Modulesと合わせることでよりその問題を回避可能となります。
詳細についてはRSCSSのサイトにて詳しく解説されています。
これに加えて、セレクタの命名規則は Lower CamelCase を採用することにしています。
ハイフン区切りにしていない理由としては、CSS Modulesで利用する際の相性問題になります。
例を出すと、TypeScriptから import して利用する際にハイフン区切りであると以下のように記述しなければならないためです。
<CSS>
.default-button {
display: inline-block;
width: 200px;
height: 48px;
&.-active {
background-color: var(--primary-color-active);
}
}
<TypeScript>
import classNames from 'classnames';
import style from './style.module.css';
...(省略)...
return (
<div className={classNames(style['default-button'], style['-active'])}>
...(省略)...
</div>
);
要するに文字列表記となってしまうため、リネームなどのコード補完が出来なくなってしまいます。
また、WebStormだとハイフン区切りにしてもLower CamelCase前提でコード補完されてしまうという問題もあります。
VSCodeのプラグインではこの表記に対応したものもあるようですが、いずれにせよbracketsの記述が必要になるので少し冗長です。
そのため、命名規則としてはLower CamelCaseを採用しています。
RSCSSで利用するVariantsについても、本来は先頭がハイフンでなければいけませんがこれもハイフンを使ってしまうと同様の問題が発生してしまうため、
ハイフンではなくアンダースコアを利用するルールとしています。
しかし、アンダースコアは実はRSCSSの helpers で使う接頭語でもあります。
そのため基本的には helpers は使わない想定にするか、使う場合はアンダースコアx2 にするといった対策を取ります。
それらのルールを踏まえると、以下のように記述ができます。
<CSS>
.defaultButton {
display: inline-block;
width: 200px;
height: 48px;
&._active {
background-color: var(--primary-color-active);
}
}
<TypeScript>
import classNames from 'classnames';
import style from './style.module.css';
...(省略)...
return (
<div className={classNames(style.defaultButton, style._active)}>
...(省略)...
</div>
);
まとめ
Next.jsのアーキテクチャー設計として紹介させていただきましたが、
Reactを使ったアプリケーションであれば基本的にはどれでも応用は可能かと思います。
もちろんアプリケーションの規模によっては冗長な構成となるのでアプリの規模に応じて採用可否を検討したり、アレンジしてみると良いかもしれません。