[Next.jsのAppRouter] コロケーションパターンを実現し、eslintで依存の向きを強制する方法
背景
AppRouterは、新たにReact18で導入されたSuspenseを最大限に活用しています。
このため、Suspenseを活かすためのディレクトリ構造が必要となります。
Suspenseを用いる際には、データのフェッチをコンポーネントの近くで行うように設計するのが一般的です。
しかしこのアプローチは、アトミックデザインとは相性が悪く、アトミックデザインから大きく変化することが求められます。
そこで、Suspenseと相性が良いコロケーションパターンを採用し、AppRouterに適したディレクトリ構造を構築することを試みます。
また、このコロケーションパターンをチーム全体で維持するため、eslintを使用して依存関係の向きを強制する方法についても紹介します。
コロケーションとは
Place code as close to where it's relevant as possible
コードはできるだけ関連性のある場所に配置する(DeepL訳)
Colocation
この一言に詰まっています。
(blog)/blog/page.ts
というファイルがある場合、このページで使用するコンポーネント類は(blog)/blog/
内に配置するのが理想的です。
このように、コードをその関連性のある場所に配置することで、可読性と保守性が向上します。
関連するファイルがまとまって配置されていれば、必要なファイルを見つけるのが容易になり、また、コンポーネントの影響範囲も把握しやすくなります。
AppRotuerでコロケーションを実現する方法
AppRouterにおける最上位コンポーネントはルートセグメントに定義された専用のファイル群です。
例:src/app/(blog)/blog/page.ts
src/app/(blog)/blog/layout.ts
Building Your Application: Routing | Next.js
Getting Started: Project Structure | Next.js
コロケーションを実現するためには、ルートセグメントに定義された専用のファイル群と同じ階層にコンポーネントを置きたいところです。
AppRouterには、それを実現できるプライベートフォルダ機能があります。
Routing: Project Organization | Next.js
プライベートフォルダはフォルダに_
をつけることで作成できます。
フォルダに_
をつけることでプライベートフォルダとして認識され、そのフォルダとすべてのサブフォルダがルーティングから除外されます。
ルートセグメントに定義された専用のファイル群と同じ階層に例えば_components
フォルダをつくることで、コロケーションが実現できます。(フォルダ名は_が付与されれば何でもいいです)
コロケーションと依存関係
src/app/(blog)/blog/_components
フォルダはルートセグメントに定義された専用のファイル群を構成するために存在します。
そのため、以下の要件を満たす必要があります。
-
src/app/(blog)/blog/_components
はsrc/app/(blog)/blog/〇〇.ts
からのみ参照される -
src/app/(blog)/blog/_components
は別のルートセグメントから参照されてはいけない- 例:別階層のルートセグメント
src/app/(post)/post/〇〇.ts
- 例:別階層のルートセグメント
上記の条件を満たすためにuhyo/eslint-plugin-import-accessを使用します
eslint-plugin-import-accessを使用する
@package
でアノテーションしたファイルをフォルダ単位でカプセル化できる拡張機能です。
詳細は以下を御覧ください
eslint-plugin-import-accessではじめるディレクトリ単位カプセル化
uhyo/eslint-plugin-import-access
以下で設定します
npm i -D eslint-plugin-import-access
- .eslintrc.jsに以下を追加
parserOptions: {
project: "./tsconfig.json",
},
plugins: [
"import-access",
// ...
],
rules: {
"import-access/jsdoc": ["error"],
}
- tsconfig.jsonに以下を追加
{
"compilerOptions": {
"plugins": [{ "name": "eslint-plugin-import-access" }],
},
}
フォルダ単位でカプセル化したいファイルに@package
アノテーションを付与します。
// importをここで書く
/**
* @package
*/
export const PartsA1 = () => {
return <div>PartsA1</div>;
};
これでフォルダ単位でカプセル化を実現できます。
実例
実例をあげてみます。
./src/app/(colocation)/colocation-a/page.ts
./src/app/(colocation)/colocation-a/sub/page.ts
./src/app/(colocation)/colocation-b/page.ts
の3つのページがあり、
./src/app/(colocation)/colocation-a/_components
フォルダが存在しています。
このフォルダにはいくつかのファイルが格納されています。
フォルダ構造は以下の通りです。
./src/app
├── (colocation)
│ ├── colocation-a
│ │ ├── _components
│ │ │ ├── index.ts
│ │ │ ├── parts-a-1
│ │ │ │ ├── parts-a-1.stories.tsx
│ │ │ │ └── parts-a-1.tsx
│ │ │ ├── parts-a-2
│ │ │ ├── parts-a-2.stories.tsx
│ │ │ │ └── parts-a-2.tsx
│ │ │ └── parts-a-3
│ │ │ ├── parts-a-3.stories.tsx
│ │ │ └── parts-a-3.tsx
│ │ ├── page.tsx
│ │ └── sub
│ │ └── page.tsx
│ └── colocation-b
│ └── page.tsx
以下の2ファイルは./src/app/(colocation)/colocation-a/_components/parts-a-1
フォルダに格納されています。
parts-a-1.tsx
に@package
アノテーションを付与しているため、/parts-a-1
フォルダ内部であれば参照できます。
/parts-a-1/parts-a-1.tsx
/**
* @package
*/
export const PartsA1 = () => {
return <div>PartsA1</div>;
};
/parts-a-1/parts-a-1.stories.tsx
import { PartsA1 } from "@/app/(colocation)/colocation-a/_components";
export default {
title: "Components/PartsA1",
component: PartsA1,
};
const Template = () => <PartsA1 />;
export const Default = Template.bind({});
しかし、別ディレクトリにある./src/app/(colocation)/colocation-a/_components/parts-a-2
から参照することはできません。
import { PartsA1 } from "@/app/(colocation)/colocation-a/_components/parts-a-1/parts-a-1";
// ↑ ESLint: Cannot import a package-private export 'PartsA1'(import-access/jsdoc)
/**
* @package
*/
export const PartsA2 = () => {
return (
<div>
<h2>PartsA2</h2>
<PartsA1 />
</div>
);
};
また同様の理由で./src/app/(colocation)/colocation-a/page.ts
でもEslintのエラーが発生します。
./src/app/(colocation)/colocation-a/_components
で記述したファイルは./src/app/(colocation)/colocation-a
全体で使えるようにしたいところです。
これを実現するために、eslint-plugin-import-access
では2種類の抜け穴 (loophole)が提供されています。
デフォルトの抜け穴 (loophole)であるindex.ts
を使用します。
./src/app/(colocation)/colocation-a/_components/index.ts
に記述することで、隣接するフォルダでも参照することが可能になります。つまり、./src/app/(colocation)/colocation-a
全体での使用が可能になります。
/_components/index.ts
/**
* 再エクスポート
* @package
*/
export { PartsA1 } from "./parts-a-1/parts-a-1";
export { PartsA2 } from "./parts-a-2/parts-a-2";
export { PartsA3 } from "./parts-a-3/parts-a-3";
eslintのエラーが発生しなくなりました。
/colocation-a/page.ts
import {
PartsA1,
PartsA2,
PartsA3,
} from "@/app/(colocation)/colocation-a/_components";
import { Button } from "@/app/_components/ui/button";
export default async function Page() {
return (
<div>
<h1>Co-location A</h1>
<PartsA1 />
<PartsA2 />
<PartsA3 />
<Button />
</div>
);
}
同様の理由で/colocation-a/sub/page.ts
でもeslintのエラーが発生しなくなりました。
一方で、/colocation-b/page.ts
ではEsLintのエラーが発生します。
これは、/colocation-a
の中にないフォルダなためです。
import { PartsA1 } from "@/app/(colocation)/colocation-a/_components";
// ↑ ESLint: Cannot import a package-private export 'PartsA1'(import-access/jsdoc
import { Button } from "@/app/_components/ui/button";
export default async function Page() {
return (
<div>
<h1>Co-location B</h1>
<Button />
<PartsA1 />
</div>
);
}
結論
eslint-plugin-import-access
はいいぞ
Discussion