お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
ライブラリ::Next.js開発::設計

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の部分だけは一応取れる。

理由としてはfireEventelement.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

やったこと

  1. storybook周りの最新化
npm i @storybook/xxx@latest && @storybook/yyy@latest ...
  1. sb devする
  2. エラーが出るので言われたとおりにnpx storybook@next automigrateを流す
  3. 適当に説明を読みながら進める
    1. 私のケースでは全部Yで問題ありませんでした
  4. storyファイルをs/storyName/name/で置換する
    1. なんか書式が変わったらしい
  5. .storybook/main.jsaddonsにある'@storybook/preset-scss'を削除
  6. ComponentStoryObjStoryObj に置換
  7. storybook devで起動確認
    1. storybook devで起動確認

トラブルシューティング

start-storybookが動かない

start-storybookが動かない

@storybook/cliを導入してstart-storybooksb devに置き換え

https://github.com/storybookjs/storybook/issues/18923#issuecomment-1214280920

sb devがコケる

sb devがコケる

書かれてる通りにnpx storybook@next automigrateを流す
何か色々変更点を教えてくれるので適宜読んで対処する

内容を読みつつ対処
内容を読みつつ対処

終わったらnpx storybook devを流せば起動します

Unexpected usage of "storyName" in "Example". Please use "name" instead.

storyNamenameに変える。

  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.jsaddons'@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

@storybook/nextjsに入ってるからなくて良くなったらしい

Next.js 11から12にしたらHMRが死んだので復活させるための設定を書いておく

確認環境

Env Ver
nginx Windows-1.21.4
Next.js 12.0.4

設定例

標準状態でnpx nextを叩いた状態を想定している

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;
    }
}