[Next.jsのAppRouter] コロケーションパターンを実現し、eslintで依存の向きを強制する方法

2023/06/01に公開

背景

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/_componentssrc/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

以下で設定します

  1. npm i -D eslint-plugin-import-access
  2. .eslintrc.jsに以下を追加
  parserOptions: {
    project: "./tsconfig.json",
  },
  plugins: [
    "import-access",
    // ...
  ],
  rules: {
    "import-access/jsdoc": ["error"],
  }
  1. 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