- 投稿日:
Next.jsのディレクトリ構成を、かれこれ3年くらい考えているのだが、ローカルのメモ帳に溜め込んでいても腐るだけなので一旦雑に吐き出してみる。
やりたいこと
大きくは基本の開発ルールを定めて、チームで混乱が生じないようにしたい。
- コードファイルの配置ルールを決め円滑な開発ができるようにする
- ディレクトリごとにスコープを作り、ある機能で使うコードは一か所にまとめる(コロケーション)
- ロジック・状態・ビューを分離し、コードの肥大化を抑制することで、見通しをよくする
- 大まかなルールとしてはロジックは
controller.ts
,util.ts
に置き、状態はstate.ts
に、ビューはview.tsx
に配置する
- 大まかなルールとしてはロジックは
- 共通部品と共通部品でないものを明確に分ける
- 命名規則を単純化して命名で悩んだり属人化することを防ぐ
大きなこと
基本的なデザインパターンはMVCをベースにしていて、controller.ts
にはイベントハンドラを、state.ts
には画面の状態を、view.tsx
にはTSXを持つという設計。controller, state, viewの定義は以下の通り。
- controller.ts
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state.ts
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view.tsx
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合は別コンポーネントに切り出す
props.isHoge ? <Hoge /> : < />
ただ、これだとstateはModelではなくVewModelになるので、MVVCみたいな構成になってしまうので、一般的なデザインパターンから外れてしまうのがやや懸念だ。但しModelがないことで、Modelを修正したら参照している全画面に影響が出てデグレしたみたいなのは回避できると思っている。MVCというよりClean Archtectureとかレイヤーアーキテクチャの方が近いかもしれない。
ディレクトリ・ファイル構成例
└─src
├─adaptors
│ ├─HogeRequest
│ │ ├─index.ts
│ ...
├─components
│ ├─Fields
│ │ ├─Checkbox
│ │ │ ├─controller.spec.ts
│ │ │ ├─controller.ts
│ │ │ ├─view.spec.ts
│ │ │ └─view.ts
│ │ ...
│ ├─Layouts
│ │ ├─HogeLayout
│ │ │ └─index.tsx
│ │ ...
│ └─Pages
│ ├─Dashboard
│ │ ├─ui-parts
│ │ │ ├─HogeSection
│ │ │ │ ├─controller.spec.ts
│ │ │ │ ├─controller.ts
│ │ │ │ ├─view.spec.ts
│ │ │ │ └─view.ts
│ │ │ ...
│ │ ├─controller.spec.ts
│ │ ├─controller.ts
│ │ ├─state.spec.ts
│ │ ├─state.ts
│ │ ├─view.spec.ts
│ │ ├─view.ts
│ │ ├─usecase.spec.ts
│ │ └─usecase.ts
│ ...
├─hooks
│ ├─ValueState
│ │ ├─index.spec.ts
│ │ └─index.ts
│ ...
├─pages
│ ├─Dashboard
│ │ ├─index.page.ts
│ │ ├─server.spec.ts
│ │ └─server.ts
│ ...
├─resources
│ ├─RoutingConfig.ts
│ ...
└─utils
├─HttpClient
│ ├─index.spec.ts
│ └─index.ts
...
各ディレクトリ・ファイルの役割
src/adaptors/**/*
API通信などのリクエストを投げる処理だけを置く場所。投げる処理以外は一切書かない。ここではパースや例外処理を行わなず、必要な処理がある場合は、呼び出し元に委任する。
これは呼び出し元によってハンドリングが変わるケースがあり、機能AとBでは最初同じハンドリングだったが、のちに機能Aだけハンドリングが変わりアダプタ側に手を入れるというケースを防ぐためだ。要するに開放閉鎖の原則を守るためである。
src/components/**/*
Pageコンポーネント以外の全てのUIコンポーネントと、それに付随する処理を配置する場所。MVC一式がここに入る。
Modelをここに入れることに関しては悩みがあるが、MVVMのViewModelとして考える場合はそこまで違和感がないように思う。
src/components/Fields/**/*
<input type="text" />
みたいなフォーム系の入力コンポーネントの部品置き場。
ここに配置されるコンポーネントは原則として状態を持たず、状態は親コンポーネントからprops経由で渡される。このため、viewとcontrollerのみがある。
src/components/Layouts/**/*
ページ全体のレイアウト置き場。大外のレイアウトを入れる想定、そんなに数は生まれないと思う。
例えば以下のコードで~Layout
となっているのがここに入る。基本的にはViewしかない想定だが、必要ならControllerなどを置いてもよい。Stateが存在することはない(props経由で差し込まれることはある)
<CommonLayout>
<FullWideViewLayout>
<ヘッダーコンポーネントとか />
</FullWideViewLayout>
<HalfWideViewLayout>
<ボディコンポーネントとか />
</HalfWideViewLayout>
<FullWideViewLayout>
<フッターコンポーネントとか />
</FullWideViewLayout>
</CommonLayout>
src/components/Pages/**/*
ページコンポーネントの実体置き場。この設計ではNext.jsのページコンポーネントはこのディレクトリにあるUsecaseを参照するための存在なので、ページ本体の実装はここに置く。
src/components/Pages/Hoge/ui-parts/**/*
このページコンポーネント内で使う細かいUI部品置き場。src/components/Fields/**/*
などにある共通UIコンポーネントや、巣のTSXの組み合わせ。stateは持たず、controllerとviewのみを持つ。
src/components/Pages/Hoge/*.{ts, tsx}
このページコンポーネントの本体。
- controller
- viewに差し込むイベントハンドラ群
useEffect
はここに書く
- state
- 画面で利用するstateを実装する
- 中には
usePageState()
という関数を一つだけ作り、これで画面の全状態を管理する- この関数の引数は状態の初期値のみを渡す
- この中にTSXや
useEffect
は書かない
- view
- controllerとstateを受け取るpropsを持つ
- 基本的にTSXだけが書かれている
- 表示非表示を切り替えるために以下のようなboolean分岐はあってよいが、ネストするなどで複雑になる場合はui-parts側に実装することが望ましい
props.isHoge ? <Hoge /> : < />
usecase
getServerSideProps
の結果を受け取るpropsを持つ- controller, state, viewの橋渡しをする場所
この階層でcontrollerにstateを差し込みラップした関数を作り、viewに差し込む
```tsx
import { sendHoge } from './controller';const ps = usePageState();
const onClickHoge = (ev: EventT) => { // これは中でHTTPリクエストを行っており、ローディングの状態を切り替えている
sendHoge(ps.hoge, ev.target.value);
}return
;
```
viewやcontrollerレベルで切り替わる場合は、切り替え先をcontroller, state, view, usecaseの単位で子にして、親側でラップする
src/hooks/**/*
共通的な状態操作用のHooks置き場。
src/pages/**/*
Next.jsのページコンポーネント置き場。*.page.tsx
にはusecaseコンポーネントの呼び出しのみを記述する。SSRする場合はserver.ts
に関数を実装し、*.page.tsx
側から参照して使う。
src/resources/**/*
定数置き場。
src/utils/**/*
共通処理置き場。全体的に利用する共通処理のみ配置する。局所的に使うものは置かない。
一定のドメインの範囲で利用するものをどこに置くかは考え切れていない。
- 投稿日:
以下のようなコードを書いたときにmockFnにイベント引数が渡らないが、これをどうにかして取る方法。結論から言うとまともに取れないが、試行錯誤した時のログとして残しておく
const mockFn = jest.fn();
render(<input onChange={mockFn} />);
fireEvent.change(inputElement, { target: { value: 'aaa' } });
確認環境
Env | Ver |
---|---|
@swc/core | 1.3.66 |
@swc/jest | 0.2.26 |
@testing-library/jest-dom | 5.16.5 |
@testing-library/react | 14.0.0 |
jest | 29.5.0 |
react | 18.2.0 |
サンプルコード
これは以下のように書くとfireEvent
の第二引数で指定したtarget.value
の部分だけは一応取れる。
理由としてはfireEvent
がelement.dispatchEvent
を読んでいると思われるためだ。余り深くは追っていないが、react-testing-libraryの実装上は多分そうなっていると思われる。
import { fireEvent, render } from '@testing-library/react';
it('test', () => {
const mockFn = jest.fn((ev) => {
console.log(ev);
});
const { container } = render(<input id="hoge" onChange={mockFn} value={'a'} />);
const element = container.querySelector('input');
if (element === null) throw new Error();
element.dispatchEvent = jest.fn();
fireEvent.change(element, {
target: {
value: 'bbb'
}
});
expect(element.dispatchEvent.mock.instances[0].value).toBe('bbb');
});
- 投稿日:
書いてなさ過ぎて忘れるので備忘録として書き出しておく。必要最低限の設定なので要件が他にある場合は追加が必要。
React(SPA)
SPAなのでパスがなければ全部index.htmlに飛ばす。原理はどれも同じなのでVueやAngularなどのSPAもこれで行けると思う。ケツの=404
がないと無限リダイレクトを起こす。
server {
listen 80;
location / {
root /path/to;
index index.html;
try_files $uri /index.html =404;
}
}
Next.js(SSR)
SSRなのでNext.jsのサーバープロセスにリバースプロキシする。別に直結してもいいが手前にルーティング機構があるほうが便利である。
map
ステートメントがあるのは、これがないとiOS Safariでアクセスできないため
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://localhost:3000;
}
}
Next.js(SSG)
SPAではないのでパスがなければ同名のhtmlファイルに飛ばす。ケツの=404
がないと無限リダイレクトを起こす。
server {
listen 80;
location / {
root /path/to;
index index.html;
try_files $uri $uri.html =404;
}
}