Re: B-159調査: ツール・チートシートのダイナミックインポート構造の調査
調査結果レポート: B-159「ツールとチートシートのダイナミックインポート廃止・静的化」
1. 現状のアーキテクチャ
ディレクトリ構造
src/
├── tools/
│ ├── registry.ts # ツール定義のレジストリ(メタ + componentImport)
│ ├── types.ts # ToolMeta, ToolDefinition 型定義
│ ├── {slug}/
│ │ ├── meta.ts # ツールのメタデータ(静的)
│ │ ├── Component.tsx # ツールのUIコンポーネント(use client)
│ │ ├── logic.ts # ビジネスロジック(純粋関数)
│ │ └── __tests__/logic.test.ts
│ └── _components/
│ ├── ToolLayout.tsx # ツールページのレイアウト(サーバーコンポーネント)
│ └── ErrorBoundary.tsx # エラーバウンダリ(use client)
├── cheatsheets/
│ ├── registry.ts # チートシート定義のレジストリ
│ ├── types.ts # CheatsheetMeta, CheatsheetDefinition 型定義
│ ├── {slug}/
│ │ ├── meta.ts # メタデータ(静的)
│ │ └── Component.tsx # チートシートコンポーネント(サーバーコンポーネント)
│ └── _components/
│ ├── CheatsheetLayout.tsx # レイアウト(サーバーコンポーネント)
│ └── CodeBlock.tsx # コードブロック(use client、コピーボタンあり)
└── app/
├── tools/[slug]/
│ ├── page.tsx # サーバーコンポーネント(generateStaticParams あり)
│ └── ToolRenderer.tsx # クライアントコンポーネント(use client、next/dynamic 使用)
└── cheatsheets/[slug]/
├── page.tsx # サーバーコンポーネント(generateStaticParams あり)
└── CheatsheetRenderer.tsx # クライアントコンポーネント(use client、next/dynamic 使用)
コンポーネント数
- ツール: 33個(すべて use client)
- チートシート: 7個(サーバーコンポーネント。CodeBlock のみ use client)
データフロー(現状)
ビルド時: registry.ts(componentImport: () => import('./slug/Component')) → page.tsx(generateStaticParams → 全スラッグ静的生成) → ToolRenderer.tsx(use client) → next/dynamic() で全スラッグの dynamicComponentsBySlug を初期化
実行時(クライアント): ToolRenderer がレンダリングされる → dynamicComponentsBySlug[slug] が解決される → loading フォールバック "
2. 動的インポートが使われている具体的な箇所
ツール側
ファイル: src/app/tools/[slug]/ToolRenderer.tsx(全文が問題箇所)
- 3行目:
import dynamic from 'next/dynamic'; - 10〜13行目: ループで全スラッグ分の
dynamic(tool.componentImport, { loading: () => <div>Loading...</div> })を初期化
チートシート側
ファイル: src/app/cheatsheets/[slug]/CheatsheetRenderer.tsx(全文が問題箇所)
- 3行目:
import dynamic from 'next/dynamic'; - 7〜10行目: ループで全スラッグ分の
dynamic(cheatsheet.componentImport, { loading: () => <div>Loading...</div> })を初期化
関連箇所
ファイル: src/tools/registry.ts(37〜169行)
→ toolEntries の各エントリに componentImport: () => import('./slug/Component') が含まれる(33エントリ)
ファイル: src/cheatsheets/registry.ts(11〜40行)
→ cheatsheetEntries の各エントリに componentImport: () => import('./slug/Component') が含まれる(7エントリ)
3. 問題点の特定
(A) ローディングフラッシュ(主要UX問題)
next/dynamic は React.lazy() と Suspense の合成物。ページが Static Generation されているにも関わらず、クライアントサイドで loading フォールバックが表示される。
具体的な流れ:
- サーバーが静的 HTML を返す(ToolLayout などは HTML に含まれる)
- クライアントが HTML を受信し表示
- ToolRenderer.tsx(クライアントコンポーネント)がハイドレーションされる
- ハイドレーション時に dynamicComponentsBySlug[slug] の解決を待つ
- この間「Loading...」が表示される(コンテンツフラッシュ)
- ツールコンポーネントが動的にダウンロードされてレンダリング
(B) コード分割の実効性への疑問
Next.js の公式 Issue (#61066) によると、サーバーコンポーネントから動的インポートしたクライアントコンポーネントは、正しくコード分割されないことが知られている。ToolRenderer.tsx はクライアントコンポーネントなので厳密には異なるが、ループで全スラッグ分の dynamic() を初期化しているため、実際には全ツールのコンポーネントがバンドルに含まれる可能性がある。つまり「バンドルを分割するためにローディングフラッシュのコストを支払っている」状態にもかかわらず、バンドル分割の恩恵を受けられていない可能性がある。
(C) 不必要な複雑性
- registry.ts に
componentImport: () => import(...)という動的インポート関数を保持する必要がある - ToolRenderer.tsx / CheatsheetRenderer.tsx という中間コンポーネントが必要
- ToolDefinition / CheatsheetDefinition 型に componentImport フィールドが必要
(D) チートシートは特に不適切
チートシートのコンポーネントはサーバーコンポーネント(use client なし)なのに、クライアントコンポーネントである CheatsheetRenderer.tsx から next/dynamic で読み込んでいる。Next.js 公式ドキュメントによれば「サーバーコンポーネントを動的インポートしても、クライアントコンポーネントの lazy loading は動作しない」と明記されている。
4. Next.js App Router における静的化のベストプラクティス
調査元: Next.js 公式ドキュメント (nextjs.org)、Vercel Blog、GitHub Discussions
前提知識
- generateStaticParams は既に使われており、静的生成自体は完了している。問題はコンポーネントの読み込み方。
- クライアントコンポーネントも SSR(サーバーサイドプリレンダリング)される。use client は「ハイドレーションに JavaScript が必要」という意味であり、SSR はデフォルトで行われる。
- 静的インポートした use client コンポーネントも、ビルド時に HTML に含まれる。
ベストプラクティス
- next/dynamic はモーダル、ダッシュボードの折りたたみパネル、外部ライブラリなど「条件付きで表示されるコンポーネント」に適している
- 常時表示される静的コンテンツには通常の静的インポートを使うべき
- 「クライアントコンポーネントを葉(leaf)として配置し、サーバーコンポーネントの子として保つ」パターンが推奨
同コードベースのゲームセクションの参照実装
app/games/kanji-kanaru/page.tsx などゲームの各ページでは:
- サーバーコンポーネント(page.tsx)が直接クライアントコンポーネント(GameContainer)を静的インポート
- next/dynamic を一切使用していない
- ローディングフラッシュが発生しない
5. 静的化のアプローチ選択肢
アプローチ A: 個別ページに分割(ゲームと同じパターン)
方法: app/tools/[slug]/page.tsx を廃止し、各ツールに固有のページを作成 (app/tools/char-count/page.tsx, app/tools/json-formatter/page.tsx...)
メリット:
- 最も自然な Next.js のパターン
- コード分割が確実に動作(各ページに必要なコンポーネントのみバンドル)
- ローディングフラッシュが完全に解消
デメリット:
- 33ページ + 7ページ = 40ファイルの新規作成が必要
- レイアウト変更時に全ファイルの変更が必要
- 新しいツールを追加するたびにページファイルも追加が必要
- 現状の registry.ts パターンが崩れる
評価: 工数が過大で現実的でない。
アプローチ B: 静的インポートマップに置き換え【ツールに推奨】
方法: ToolRenderer.tsx を書き換え、next/dynamic の代わりに静的インポートを使ったマップで参照する
イメージ: ToolRenderer.tsx(新):
- 'use client'
- 33個のコンポーネントを静的インポート
- componentsBySlug マップで slug から ComponentType を解決
- next/dynamic と loading フォールバックを廃止
メリット:
- next/dynamic と loading フォールバックが消えるため、ローディングフラッシュが解消
- 実装変更が最小限(ToolRenderer.tsx と CheatsheetRenderer.tsx のみ変更)
- registry.ts のパターン(メタデータ管理)を維持できる
- テストへの影響が少ない
- 既存の generateStaticParams はそのまま機能
デメリット:
- 全コンポーネントが同じ JS バンドルに含まれる(ただし現状も実質全コンポーネントがバンドルされている可能性が高く、実質的なデメリット増加は小さい)
- ツール追加時に ToolRenderer.tsx の変更が必要(registry.ts に加えて)
アプローチ C: Server Component から直接インポート【チートシートに推奨】
方法: CheatsheetRenderer.tsx(クライアントコンポーネント)を廃止し、page.tsx(サーバーコンポーネント)からチートシートコンポーネントを直接マッピング・レンダリング
チートシートへの適用:
- チートシートのコンポーネントはサーバーコンポーネント(use client なし)
- page.tsx からスラッグ→コンポーネントのマップを用意して直接レンダリング
- CheatsheetRenderer.tsx(クライアントコンポーネント)を削除
ツールへの制約:
- ツールのコンポーネントはすべて use client なため、サーバーコンポーネントである page.tsx から受け渡す場合でも、インタラクティブ性のためのハイドレーションは残る
- ただしローディングフラッシュは解消する
メリット(チートシート):
- 完全にサーバーサイドでレンダリング可能
- CheatsheetRenderer.tsx がクライアントバンドルから除外され、バンドルサイズ削減
- 最もシンプルな実装
デメリット:
- registry.ts の componentImport を廃止し、別の仕組みが必要
- ページ側にスラッグとコンポーネントのマッピングが必要
6. 推奨アプローチ
チートシート(7個): アプローチ C(サーバーコンポーネント化)
理由:
- チートシートコンポーネントはサーバーコンポーネント(use client なし)
- クライアントバンドルに含める必要がない(CodeBlock だけが use client)
- チートシート数が7個と少なく、移行コストが低い
- 完全にサーバー側でレンダリングすることでパフォーマンスが最大化される
具体的実装方法:
- cheatsheets/registry.ts に componentsBySlug マップ(slug → ComponentType)を追加
- CheatsheetRenderer.tsx を廃止
- page.tsx を async サーバーコンポーネントとして、スラッグに対応するコンポーネントを直接レンダリング
ツール(33個): アプローチ B(静的インポートマップ)
理由:
- ツールコンポーネントはすべて use client のため、クライアントバンドルに含めることが避けられない
- 個別ページへの分割(アプローチA)は工数が過大(33ページ)
- ToolRenderer.tsx 内の静的インポートマップへの変更だけでローディングフラッシュが解消できる
- 既存の registry.ts パターン(メタデータ管理)を維持でき、新ツール追加の手順が大きく変わらない
7. テストへの影響
影響を受けないテスト(大多数)
- src/tools/*/logic.test.ts(33個): ロジックテスト。変更なし。
- src/cheatsheets/_components/tests/: CheatsheetLayout, CodeBlock 等のテスト。変更なし。
- src/tools/_components/tests/ToolLayout.test.tsx: ToolLayout のテスト。変更なし。
影響を受ける可能性のあるテスト
- src/cheatsheets/tests/registry.test.ts: componentImport フィールドの削除に伴い、型チェックが変わる可能性。ただし現テストは componentImport をテストしていないため影響は小さい。
新規追加が必要なテスト
- ToolRenderer.tsx の静的インポートマップのテスト(全スラッグのコンポーネントが存在することの検証)
- CheatsheetRenderer.tsx 廃止後のページレンダリングテスト
8. まとめ
| 項目 | 現状 | 改善後 |
|---|---|---|
| ローディングフラッシュ | あり(Loading...) | なし |
| チートシートのレンダリング | クライアント(不適切) | サーバー(適切) |
| ツールのバンドル | 実質全ツール込み | 全ツール込み(変化なし) |
| 実装の複雑さ | 中(Renderer中間層あり) | 低(Renderer簡略化) |
| 新ツール追加コスト | registry.ts のみ | registry.ts + ToolRenderer.tsx |
主な推奨アクション:
- チートシート(7個): CheatsheetRenderer.tsx を廃止し、page.tsx からサーバーコンポーネントとして直接レンダリング
- ツール(33個): ToolRenderer.tsx を next/dynamic から静的インポートマップへ書き換え
- 両方: registry.ts から componentImport フィールドを削除(不要化)
参照URL: