背景
ReactやTypeScriptで書かれたUIコンポーネント群を index.ts でまとめるBarrelファイル構成は広く使われている。利用側はフォルダ単位でimportでき、公開APIの境界もコードを読むだけで把握できる。
ただしこの構成は、コンポーネントが数十から数百規模に達すると、Tree Shakingの効きにくさ・テストやdev serverの初期化連鎖・AIコーディングエージェントによるコード追跡コストを生む。Barrelの利点である「入口の自明性」を保ったまま、この3つのコストを抑える設計を組めるか。
Barrelファイルは再exportを集約する入口である
Barrelファイルとは、index.ts などで同一ディレクトリ内のexportをまとめて再exportするファイルである。
export { ArticleCard } from './ArticleCard';
export { ArticleCardHeader } from './ArticleCardHeader';
利用者は @/components/UI/ArticleCard のようにフォルダ単位でimportする。
Barrelファイルは小規模では入口と公開APIを読みやすくする
Barrelファイルが広く使われてきた理由は、入口と公開範囲をまとめられることだ。
- import入口が自明である。利用者はフォルダ名でimportでき、内部のファイル名を意識しなくてよい。
- 公開APIの設計がLinterなしで伝わる。
index.ts に書かれたexportが公開API、書かれていないものは内部実装、という規約がコードを読むだけで把握できる。規約ドキュメントやlintルールがなくても新規参加者に直感的である。 - 内部構成の変更を隠蔽できる。内部のファイル分割やリネームを
index.ts で吸収すれば、利用側のimportパスは変わらない。 - import文の形式が揃う。利用側の
import { Foo } from '@/components/UI/Foo' が一定のフォーマットになり、人間にとって読みやすい。
小規模から中規模のコードベースでは、この利点は十分に機能する。問題は、コンポーネント数と依存関係が増えた後に顕在化する。
Barrel廃止は性能とコード追跡コストを下げるために行う
Barrelをやめる目的は、単に index.ts を削除することではない。狙いは次の3点である。
- Tree Shakingを妨げる条件を増やさない
- モジュール初期化の連鎖を断ち切る
- AIコーディングエージェントの追跡コストを下げる
Tree Shakingが効きにくい条件が増える
Barrelは複数モジュールの再exportを集約するため、バンドラが追跡するモジュールグラフを拡大させやすい。webpack の公式ドキュメントによれば、Tree ShakingはESM構文の静的構造・sideEffects 指定・production最適化の組み合わせに依存する。Barrel経由では追跡対象が増えるため、利用していないexportがバンドルに残る場面が出やすい。
モジュール初期化が連鎖する
import { ArticleCard } from '@/components/UI/ArticleCard' のようにBarrelに到達すると、Barrelで再exportされるモジュールが依存グラフに入り、実行環境や最適化設定によっては読み込み・評価対象になる。テスト時の初期化コストが膨らみ、dev serverの再評価範囲も広がる。Biomeの noBarrelFile ルールが lint/performance カテゴリに置かれている根拠もここにある。
AIコーディングエージェントの追跡性が落ちる
ランタイムだけでなく、コード解析の文脈でも同じ問題が出る。コーディングエージェントがシンボル定義をたどるとき、Barrel経由だと「Barrelファイルを開く→再export元を読み込む」の二段を踏む。コンテキストウィンドウに不要な再export文が含まれ、トークン消費と読み取りステップが増える。直接importであれば、エージェントは公開ファイル1つを読めば済む。
禁止・公開入口・依存境界をセットで決める
この3点を満たすには、次の設計をセットで導入する必要がある。
- Barrelファイルを禁止する
- 公開入口は同名ファイルにする
- 内部実装は同名ディレクトリに置く
- exportはnamed exportに寄せる
- BiomeでBarrel・default export・private importを制限する
- 厳密な依存境界はdependency-cruiserなどの専用ツールで補う
公開入口は同名ファイル、内部実装は同名ディレクトリに置く
公開入口を同名ファイルとし、内部実装を同名ディレクトリに置く。テストとStorybookは対象ファイルと同階層に置く。
src/components/UI/
ArticleCard.tsx # 公開入口
ArticleCard.test.tsx # 公開入口のテスト
ArticleCard.stories.tsx # Storybook
ArticleCard/
Header.tsx # 内部実装
Header.test.tsx # 内部実装のテスト
Excerpt.tsx
Tags.tsx
利用側はフォルダではなく公開ファイルを直接importする。
import { ArticleCard } from '@/components/UI/ArticleCard';
@/components/UI/ArticleCard は、paths とバンドラ側の拡張子なし解決が有効な環境では ArticleCard.tsx を解決する。ArticleCard/ 配下は内部実装であり、利用側からは触れない。
テストとStorybookの配置方針
テストとStorybookも同名ファイル・同名ディレクトリの規約に従う。
同階層に置く理由は、実装・利用例・検証を同じ変更単位として扱うためである。公開入口を変更するとき、対応するテストとstoryが隣にあれば、公開APIの破壊的変更や表示状態の更新漏れに気付きやすい。Storybook公式も、storyファイルをコンポーネントファイルの隣に置く構成を示している。Vitestもテストファイルの置き場所に唯一の正解はないとしつつ、ソースの隣に置く構成を例示している。
- 公開入口のテストは公開ファイルと同階層に置き、公開動作を検証する。
- 内部実装のテストは内部ファイルと同階層に置き、相対パス(
./Header など)でimportする。 - Storybookは公開コンポーネントに対してのみ作成し、公開ファイルと同階層に置く。内部実装は公開しないため、storyは書かない。
この配置はBarrel廃止後の設計と整合する。内部実装のテストは相対パスでimportするため、後述する noRestrictedImports の対象外になる。外部のテストファイルからは公開ファイルだけをimportするため、Barrel廃止後のimport規約と一致する。
Barrel廃止後は性能面で有利だが規約が必要になる
Barrel構成と廃止後の構成を比較すると、性能面と運用面のトレードオフが見える。
Barrel廃止後はTree Shaking・モジュール初期化・AI解析の3点で有利になりやすいが、利用者の迷いやルール強制の弱さを規約とlintで補う必要がある。
Toastコンポーネントでは公開入口と内部実装を分ける(実装例)
公開入口は同名ファイル Toast.tsx、内部実装は Toast/ 配下に置く。テストとStorybookも同階層に配置する。
src/components/UI/
Toast.tsx # 公開入口
Toast.test.tsx # 公開動作のテスト
Toast.stories.tsx # Storybook
Toast/
ToastView.tsx # 内部実装
useToastState.ts
useToastState.test.ts # hookの単体テスト
公開入口の Toast.tsx は、内部実装を組み立て、公開コンポーネントを返す。
// src/components/UI/Toast.tsx
import { ToastView } from './Toast/ToastView';
import { useToastState } from './Toast/useToastState';
export function Toast() {
const state = useToastState();
return <ToastView state={state} />;
}
利用側はnamed exportでimportする。
import { Toast } from '@/components/UI/Toast';
避けるべきimportは、内部実装への直接importである。
// 避ける: 公開APIを経由していない
import { ToastView } from '@/components/UI/Toast/ToastView';
このimportは公開APIを経由していないため、後から内部構成を変えにくくなる。ToastView を分割した瞬間、利用側のimportパスが破綻する。Barrelが暗黙に提供していた「内部構成の隠蔽」は、Barrel廃止後はBiomeの内部import制限で部分的に代替される。完全な隠蔽には別途dependency-cruiserなどで補強する必要がある(後述)。
環境
- Biome 2.4.14
- Next.js 16.2.4
なお、noRestrictedImports.options.patterns はBiome v2.2.0以降、overrides[].includes はBiome v2.xの記法であるため、Biome 1.xでは動作しない。
Biomeで制約を強制する
設計を口約束で運用するとすぐ崩れる。lintで強制する必要がある。
ESLintでも同等の制約は組めるが、次のように複数プラグインの組み合わせが必要になる。
設定が分散するため、単一のlinterで完結させられるBiomeをベースとする。
Biomeで以下のルールを有効化し、Barrel・default export・private importを構造的に制限する。
{
"linter": {
"rules": {
"performance": {
"noBarrelFile": "error",
"noNamespaceImport": "warn",
},
"style": {
"noDefaultExport": "error",
"useImportType": "warn",
"useExportType": "warn",
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["@/**/index", "@/**/index.*"],
"message": "index/barrel importは禁止です。公開ファイルを直接importしてください",
},
{
"group": ["@/components/UI/*/*", "@/components/UI/*/**", "!@/components/UI/Layout/*"],
"message": "UI moduleの内部ファイルを直接importしないでください",
},
],
},
},
},
},
},
}
各ルールの役割は以下の通りである。
noBarrelFile: 再exportのみのファイルを禁止するnoDefaultExport: default exportを禁止し、named exportに寄せるnoRestrictedImports: index.* への依存と、UI/*/* のような内部ファイルへの直接importを禁止するuseImportType / useExportType: 型のimport・exportを import type / export type に統一する
Next.jsの page.tsx や layout.tsx はdefault exportが前提のため、ファイル単位で例外を設定する。
{
"overrides": [
{
"includes": ["src/app/**/page.tsx", "src/app/**/layout.tsx"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off",
},
},
},
},
],
}
なお、noRestrictedImports のpatternsは @/... のようなエイリアス経由のimportに適用される。同一モジュール内からの相対パスでの内部import (例: ./Toast/ToastView 形式) は対象外であるため、Biomeだけでは相対パス経由の参照までは塞げない。相対importの境界制御や循環依存の検出が必要であれば、dependency-cruiserなどの専用ツールで補う。
まとめ
Barrelファイルはimport入口を自明にする点で有用だが、規模が大きくなると依存グラフを広げ、モジュール初期化を連鎖させ、AIコーディングエージェントの追跡コストを増やす。Barrelを廃止するなら、index.ts を削除するだけでは足りない。同名公開ファイル、named export、private import制限をセットで導入する必要がある。この組み合わせにより、コロケーションを維持しながら性能とコード追跡のコストを抑えられる。
参考