- 投稿日:
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;
}
}
- 投稿日:
SCSS Moduleを使ったNext.jsと連携させてるStorybookを6.5から7.0に上げたときの手順とトラブルシューティング
環境移行内容
moduleのバージョンをbeforeからafterにアップデート
module | before | after |
---|---|---|
@storybook/addon-actions | 6.5.13 | 7.0.24 |
@storybook/addon-essentials | 6.5.13 | 7.0.24 |
@storybook/addon-links | 6.5.13 | 7.0.24 |
@storybook/builder-webpack5 | 6.5.13 | 7.0.24 |
@storybook/manager-webpack5 | 6.5.13 | 6.5.16 |
@storybook/preset-scss | 1.0.3 | 1.0.3 |
@storybook/react | 6.5.13 | 7.0.24 |
やったこと
- storybook周りの最新化
npm i @storybook/xxx@latest && @storybook/yyy@latest ...
sb dev
する- エラーが出るので言われたとおりに
npx storybook@next automigrate
を流す - 適当に説明を読みながら進める
- 私のケースでは全部
Y
で問題ありませんでした
- 私のケースでは全部
- storyファイルを
s/storyName/name/
で置換する- なんか書式が変わったらしい
.storybook/main.js
のaddons
にある'@storybook/preset-scss'
を削除ComponentStoryObj
をStoryObj
に置換storybook dev
で起動確認
トラブルシューティング
start-storybook
が動かない
@storybook/cliを導入してstart-storybook
をsb dev
に置き換え
https://github.com/storybookjs/storybook/issues/18923#issuecomment-1214280920
sb dev
がコケる
書かれてる通りにnpx storybook@next automigrate
を流す
何か色々変更点を教えてくれるので適宜読んで対処する
終わったらnpx storybook dev
を流せば起動します
Unexpected usage of "storyName" in "Example". Please use "name" instead.
storyName
をname
に変える。
export const Example: Story = {
- storyName: 'ほげほげ',
+ name: 'ほげほげ',
}
sass-loader が動かない
ModuleBuildError: Module build failed (from ./node_modules/sass-loader/dist/cjs.js):
SassError: expected "{".
╷
2 │ import API from "!../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js";
.storybook/main.js
のaddons
に'@storybook/preset-scss'
がいたら消す
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
- '@storybook/preset-scss',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: true,
},
};
ゴミなので@storybook/preset-scss
を消しておく
npm un @storybook/preset-scss
- 投稿日:
条件分岐でコンポーネントの出し分けをしている時に正しくコンポーネントが出ているかどうかに使えるやつ
UIロジックのリグレッションテストで使える
分岐結果の出力を見てるだけなのでテストとして壊れづらく、運用しやすいと考えている
確認環境
Next.jsで確認してるけど素のReactでも同じだと思う
Env | Ver |
---|---|
@swc/core | 1.2.133 |
@swc/jest | 0.2.17 |
jest | 27.4.7 |
next | 12.0.8 |
react | 17.0.2 |
react-dom | 17.0.2 |
react-test-renderer | 17.0.2 |
typescript | 4.5.4 |
テスト対象
テストのためにコンポーネントを細かくexport
すると名前空間が汚染されるのが悩み…
type BaseProps = {
id: string;
};
type SwitchExampleProps = BaseProps & {
display: 'Foo' | 'Bar';
};
export const Foo = (props: BaseProps) => {
return (
<div id={props.id}>
<p>Foo</p>
</div>
);
};
export const Bar = (props: BaseProps) => {
return (
<div id={props.id}>
<p>Bar</p>
</div>
);
};
export const SwitchExample = (props: SwitchExampleProps) => {
if (props.display === 'Foo') {
return <Foo id={props.id} />;
} else {
return <Bar id={props.id} />;
}
};
テストコード
react-testing-libraryの.toHaveAttribute()
や.toHaveDisplayValue()
を書き連ねるより圧倒的に楽で保守性も良いと思う
import TestRenderer from 'react-test-renderer';
import { Bar, Foo, SwitchExample } from './SwitchExample';
type TestCase = {
name: string;
param: Parameters<typeof SwitchExample>[0];
actual: JSX.Element;
};
describe('SwitchExample', () => {
const testCaseItems: TestCase[] = [
{
name: 'Foo',
param: {
id: 'hoge',
display: 'Foo',
},
actual: <Foo id={'hoge'} />,
},
{
name: 'Bar',
param: {
id: 'piyo',
display: 'Bar',
},
actual: <Bar id={'piyo'} />,
},
];
testCaseItems.forEach((item) => {
// eslint-disable-next-line jest/valid-title
it(`switched condition ${item.name}`, () => {
const result = TestRenderer.create(
<SwitchExample id={item.param.id} display={item.param.display} />
);
const actual = TestRenderer.create(<>{item.actual}</>);
expect(result.toJSON()).toStrictEqual(actual.toJSON());
});
});
});