原文アドレス:Understanding App Directory Architecture In Next.js
Next13 の App ディレクトリ構造の理解#
概要:新しい App ディレクトリ構造は最近 Next.js で発表された主要な内容で、多くの議論を引き起こしました。本記事では、Atila Fassina がこの新しい戦略の利点と欠点を探り、今すぐに生産環境で使用すべきかどうかを考察します。
Next.js 13のリリース以来、その発表に含まれる新機能の安定性についていくつかの議論がありました。「Next.js 13 の新機能は何ですか?」という記事では、リリース内容を紹介し、Next.js 13 は興味深い実験を含んでいるものの、確実に安定していることを確認しました。それ以来、新しい <Link> や < Image > コンポーネント、さらには(まだベータ段階の)@next/font についても、私たちのほとんどは非常に明確な状況を見てきました;これらはすぐに使用でき、即時の利益があります。発表に明記されているように、Turbopack はまだアルファ段階であり、開発ビルド専用で、現在も積極的に開発中です。日常業務で使用できるかどうかは、あなたの技術スタックに依存します。なぜなら、まだいくつかの統合と最適化が進行中だからです。本記事の範囲は、発表の主役である新しいアプリケーションディレクトリ構造(略して AppDir)に限られています。
アプリケーションディレクトリは、React エコシステムにおける重要な進化である React サーバーコンポーネントとエッジランタイムと組み合わせて使用されるため、常に問題を引き起こしてきました。これは明らかに私たちの Next.js アプリケーションの未来の形です。実験的ではありますが、そのロードマップは今後数週間で完成するとは考えられません。では、今すぐに生産環境で使用すべきでしょうか?どのような利点を得られるのか、またどのような罠に遭遇する可能性があるのでしょうか?ソフトウェア開発における答えは常に同じです:それは状況によります。
App ディレクトリとは?#
これは Next.js におけるルーティングとビューのレンダリングを処理するための新しい戦略です。これはいくつかの異なる機能を組み合わせて実現されており、React の同時実行機能を最大限に活用するために構築されています(はい、私たちは React Suspense について話しています)。これは大きなパラダイムシフトをもたらし、Next.js アプリケーション内でコンポーネントとページを考える方法を変えました。この新しいアプリケーション構築方法は、アーキテクチャに多くの非常に人気のある改善をもたらします。以下は簡潔で非網羅的なリストです:
- 部分的なルーティング。
- ルーティングのグループ化。
- 並行ルーティング。
- ルーティングのインターセプト。
- サーバーコンポーネント vs クライアントコンポーネント。
- Suspense 境界。
- その他の詳細は新しいドキュメントの機能概要を参照してください。
簡単な比較#
現在のルーティングとレンダリングアーキテクチャ(Pages ディレクトリ内)について話すとき、開発者は各ルートのデータ取得方法を考慮する必要があります。
- getServerSideProps: サーバーサイドレンダリング;
- getStaticProps: サーバーサイドプリレンダリングおよび / またはインクリメンタルスタティックリジェネレーション;
- getStaticPaths + getStaticProps: サーバーサイドプリレンダリングまたはスタティックサイト生成。
歴史的に、各ページでレンダリング戦略を選択する方法はありませんでした。ほとんどのアプリケーションは、サーバーサイドレンダリングまたはスタティックサイト生成のいずれかを全面的に採用しています。Next.js は、そのアーキテクチャ内でルーティングを個別に考慮するための十分な抽象レイヤーを作成しました。
アプリケーションがブラウザに到達すると、ハイドレーションプロセスが開始され、_app コンポーネントを React Context Provider でラップすることで、ルーティングがデータを共有できるようになります。これにより、データをレンダリングツリーのトップに置き、下位に渡すためのツールが提供されます。
import { type AppProps } from 'next/app';
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<SomeProvider>
<Component {...pageProps} />
</SomeProvider>
}
各ルートで必要なデータをレンダリングおよび整理できることは、このアプローチを必要に応じて非常に優れたツールにします。アプリ全体でデータをグローバルに伝播させることができます。しかし、すべてを Context Provider でラップすると、ハイドレーションはアプリケーションのルートにバンドルされ、そのツリー上の任意のブランチをレンダリングできなくなります(その Provider コンテキスト内の任意のルート)。
ここでレイアウトパターンの役割が重要になります。ページの周りにラッパーを作成することで、各ルートのレンダリング戦略を選択でき、アプリ全体の決定を一度に行う必要がなくなります。Pages ディレクトリ内での状態管理については、「Next.js における状態管理」の記事やNext.js ドキュメントを参照してください。
レイアウトパターンは優れた解決策であることが証明されています。レンダリング戦略を細かく定義できることは非常に人気のある機能です。したがって、App ディレクトリはレイアウトパターンの重要性を強調しています。Next.js アーキテクチャの一級市民として、パフォーマンス、安全性、データ処理において大きな改善を提供します。
React の同時実行機能を使用することで、コンポーネントをブラウザにストリーミングし、各コンポーネントが自分のデータを処理できるようになりました。したがって、レンダリング戦略は現在、ページベースではなくコンポーネントベースでより細かくなっています。デフォルトでは、レイアウトはネストされており、開発者はファイルシステムアーキテクチャに基づく各ページの影響をより明確に理解できます。最も重要なのは、Context を使用する前に、コンポーネントをクライアントコンポーネントに明示的に変換する必要があることです(「use client」指令を通じて)。
App ディレクトリの構築モジュール#
このアーキテクチャは、各ページに 1 つのレイアウトを持つ構造に基づいています。現在、_app および_document コンポーネントはありません。これらはすべて、ルート layout.jsx コンポーネントに置き換えられました。ご想像のとおり、これはアプリ全体をラップする特別なレイアウトです。
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
ルートレイアウトは、アプリ全体に返される HTML を一度に操作する方法です。これはサーバーコンポーネントであり、ナビゲーション時に再レンダリングされません。つまり、レイアウト内のデータや状態は、アプリケーションのライフサイクル全体にわたって持続します。
ルートレイアウトはアプリ全体の特別なコンポーネントですが、他の構築ブロックのためにルートコンポーネントを定義することもできます:
- loading.jsx: 全ルートの Suspense 境界を定義;
- error.jsx: 全ルートのエラーバウンダリを定義;
- template.jsx: レイアウトに似ていますが、ナビゲーションのたびに再レンダリングされます。特に、ルート間の状態を処理するのに適しています(例えば、入退場のトランジション効果など)。
これらのすべてのコンポーネントと約束はデフォルトでネストされています。つまり、/about は自動的に / のラッパー内にネストされます。
最後に、各ルートに対して page.jsx を定義する必要があります。これは、その URL セグメント(つまり、コンポーネントを配置する場所)に対応する主要なコンポーネントを定義します。これらは明らかにデフォルトでネストされておらず、それらに対応する URL セグメントが正確に一致する場合にのみ、DOM 内に表示されます。
もちろん、さらに多くのアーキテクチャ(さらなるものも登場予定!)がありますが、これは生産環境の Pages ディレクトリを App ディレクトリに移行する前に、思考モデルを正しく理解するのに十分です。公式のアップグレードガイドを必ず確認してください。
SERVER COMPONENTS の紹介#
React サーバーコンポーネントは、アプリケーションがインフラストラクチャを利用してパフォーマンスと全体的なユーザーエクスペリエンスを向上させることを可能にします。例えば、即座の改善はバンドルサイズに関してです。RSC は最終バンドルに依存関係を持ち込まないためです。サーバーでレンダリングされるため、あらゆる種類の解析、フォーマット、またはコンポーネントライブラリはサーバーコードに保持されます。次に、非同期性のため、サーバーコンポーネントはクライアントにストリーミングされます。これにより、ブラウザ上でレンダリングされた HTML を段階的に強化できます。
したがって、サーバーコンポーネントは最終バンドルのより予測可能でキャッシュ可能かつ一定のサイズをもたらし、アプリケーションサイズとバンドルサイズの間の線形相関を打破します。これにより、RSC は従来の React コンポーネント(現在はクライアントコンポーネントと呼ばれ、曖昧さを避けるため)と比較されるベストプラクティスとなります。
サーバーコンポーネントでは、データ取得も非常に柔軟であり、私の見解では、ネイティブ JavaScript に近いです。これは常に学習曲線を滑らかにします。例えば、JavaScript ランタイムを理解することで、データ取得を並行または順序で定義することが可能になり、リソースの読み込みの滝をより細かく制御できます。
- 並行データ取得、すべてを待つ:
import TodoList from './todo-list'
async function getUser(userId) {
const res = await fetch(`https://<some-api>/user/${userId}`)
return res.json()
}
async function getTodos(userId) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`)
return res.json()
}
export default async function Page({ params: { userId } }) {
// 並行して両方のリクエストを開始します。
const userResponse = getUser(userId)
const todosResponse = getTodos(username)
// プロミスが解決されるのを待ちます。
const [user, todos] = await Promise.all([userResponse, todosResponse])
return (
<>
<h1>{user.name}</h1>
<TodoList list={todos}></TodoList>
</>
)
}
- 並行、1 つのリクエストを待ち、別のリクエストをストリーミング:
async function getUser(userId) {
const res = await fetch(`https://<some-api>/user/${userId}`)
return res.json()
}
async function getTodos(userId) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`)
return res.json()
}
export default async function Page({ params: { userId } }) {
// 並行して両方のリクエストを開始します。
const userResponse = getUser(userId)
const todosResponse = getTodos(userId)
// ユーザーのためだけに待ちます。
const user = await userResponse
return (
<>
<h1>{user.name}</h1>
<Suspense fallback={<div>Fetching todos...</div>}>
<TodoList listPromise={todosResponse}></TodoList>
</Suspense>
</>
)
}
async function TodoList({ listPromise }) {
// アルバムのプロミスが解決されるのを待ちます。
const todos = await listPromise
return (
<ul>
{todos.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
)
}
この場合、<TodoList> は進行中のプロミスを受け取り、レンダリング前にそれを待つ必要があります。アプリケーションは、すべての操作が完了するまでサスペンスフォールバックコンポーネントをレンダリングします。
- 順序データ取得、1 回のリクエストをトリガーし、各リクエストを待つ:
async function getUser(username) {
const res = await fetch(`https://<some-api>/user/${userId}`)
return res.json()
}
async function getTodos(username) {
const res = await fetch(`https://<some-api>/todos/${userId}/list`)
return res.json()
}
export default async function Page({ params: { userId } }) {
const user = await getUser(userId)
return (
<>
<h1>{user.name}</h1>
<Suspense fallback={<div>Fetching todos...</div>}>
<TodoList userId={userId} />
</Suspense>
</>
)
}
async function TodoList({ userId }) {
const todos = await getTodos(userId)
return (
<ul>
{todos.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
)
}
現在、Page は getUser データを取得し、それを待った後にレンダリングを開始します。一度 <TodoList> に到達すると、getTodos データを取得し、それを待ちます。これは、Pages ディレクトリで使用していた方法よりもさらに細かいものです。
重要な注意点:
-
同じコンポーネントスコープ内で発信されたリクエストは並行して発信されます(この件についての詳細は、以下の拡張された Fetch API を参照してください)。
-
同じサーバーランタイムで発信された同じリクエストは重複しません(実際に発生するのは 1 つだけで、キャッシュの有効期限が最も短いものです)。
-
fetch を使用しないリクエスト(SDK、ORM、またはデータベースクライアントなどのサードパーティライブラリ)については、セグメントキャッシュ設定を手動で構成しない限り、ルートキャッシュには影響しません。
export const revalidate = 600; // 10分ごとに再検証
export default function Contributors({
params
}: {
params: { projectId: string };
}) {
const { projectId } = params
const { contributors } = await myORM.db.workspace.project({ id: projectId })
return <ul>{*/ ... */}</ul>;
}
これにより、開発者はページのレンダリングをより細かく制御できるようになります。Pages ディレクトリでは、すべてのデータが準備できるまでページのレンダリングはブロックされます。一方、getServerSideProps を使用すると、ユーザーはルート全体のデータが準備できるまでローディングアニメーションを見ることになります。App ディレクトリでこの動作を模倣するには、そのルートの layout.tsx 内で fetch リクエストを発信する必要があるため、これを避けるべきです。「全てまたは全くない」アプローチは、通常は必要なものではなく、このような細かい戦略に比べて知覚性能が悪化します。
拡張された Fetch API#
構文は変わりません:fetch (route, options)。しかし、Web Fetch Specに従って、options.cache はこの API がブラウザキャッシュとどのように相互作用するかを決定します。しかし、Next.js では、フレームワークのサーバーサイド HTTP キャッシュと相互作用します。
Next.js の拡張された Fetch API とそのキャッシュ戦略について話すとき、理解すべき 2 つの値があります:
- force-cache: デフォルトオプション、最新の一致を探して返します。
- no-store または no-cache: 毎回リモートサーバーから取得します。
- next.revalidate: ISR と同じ構文で、リソースが新鮮かどうかを考慮するためのハードしきい値を設定します。
fetch(`https://route`, { cache: 'force-cache', next: { revalidate: 60 } })
キャッシュ戦略により、リクエストを分類できます:
- 静的データ:保持時間が長いです。例えば、ブログ記事。
- 動的データ:頻繁に変更される、またはユーザーのインタラクションの結果です。例えば、コメントセクション、ショッピングカート。
デフォルトでは、すべてのデータは静的データと見なされます。これは、force-cache がデフォルトのキャッシュ戦略であるためです。動的データの影響を完全に排除するには、no-store または no-cache を定義できます。
動的関数(クッキーやヘッダーの設定など)を使用する場合、デフォルト値は force-cache から no-store に切り替わります!
最後に、増分静的再生成により似たような内容を実現するには、next.revalidate を使用する必要があります。利点は、それが存在するコンポーネントを定義するだけであり、全ルートではないことです。
Pages から App への移行#
Pages ディレクトリから App ディレクトリへのロジックの移行は、多くの作業が必要に見えるかもしれませんが、Next.js はこの 2 つのアーキテクチャが共存できるように準備しているため、移行は段階的に行うことができます。さらに、公式ドキュメントには非常に良い移行ガイドがあります。リファクタリングを始める前に、完全に読むことをお勧めします。
移行パスを案内することは本記事の範囲を超えており、ドキュメントと重複することになります。代わりに、公式ドキュメントに基づいて価値を追加するために、摩擦点を避けるためのいくつかの経験を提供しようと思います。
React コンテキストの状況#
本記事で言及されたすべての利点を提供するために、RSC はインタラクティブではなく、つまりフックを持たないことを意味します。したがって、可能な限りクライアントロジックをレンダリングツリーの葉ノードに遅らせることを決定しました;一度インタラクティブ性が追加されると、そのコンポーネントの子はクライアント側になります。
いくつかのケースでは、特定の重要な機能が React コンテキストに依存している場合、特定のコンポーネントを遅らせることが不可能です。ほとんどのライブラリは、ユーザーをプロップドリリングから保護する準備ができているため、多くのライブラリは、根から遠くの子コンポーネントにスキップするためのコンテキストプロバイダーを作成しています。したがって、React コンテキストを完全に放棄すると、一部の外部ライブラリが正常に動作しなくなる可能性があります。
一時的な解決策として、この問題に対するクライアントラッパーがあります:
// /providers.jsx
‘use client’
import { type ReactNode, createContext } from 'react';
const SomeContext = createContext();
export default function ThemeProvider({ children }: { children: ReactNode }) {
return (
<SomeContext.Provider value="data">
{children}
</SomeContext.Provider>
);
}
したがって、レイアウトコンポーネントはクライアントコンポーネントのレンダリングをスキップすることを不平を言いません。
// app/.../layout.jsx
import { type ReactNode } from 'react';
import Providers from ‘./providers’;
export default function Layout({ children }: { children: ReactNode }) {
return (
<Providers>{children}</Providers>
);
}
重要なのは、これを行うと、全体のブランチがクライアントレンダリングに変わることを認識することです。この方法では、<Providers> コンポーネント内のすべてがサーバー側でレンダリングされないため、万が一の場合にのみ使用してください。
Typescript と非同期 React 要素#
Layouts および Pages の外で async/await を使用する場合、TypeScript はその JSX 定義に基づいて期待される応答タイプのエラーを生成します。実行時には依然として利用可能でサポートされていますが、Next.js のドキュメントによれば、これは TypeScript の上流で修正する必要があります。
現在の解決策は、上の行にコメントを追加することです{/* @ts-expect-error Server Component */}
。
クライアントでのデータ取得の作業#
歴史的に、Next.js には組み込みのデータ変異(mutation)機能がありませんでした。クライアントから発信されたリクエストは、開発者がどのように処理するかを決定します。React サーバーコンポーネントを使用することで、この状況は変わりました。React チームは、Promise を受け取り、その Promise を処理して直接その値を返す use フックを開発しています。
将来的には、これは使用中の useEffect の大部分を置き換えることになります(詳細については「さようなら、UseEffect」という素晴らしい講演を参照してください)。これは、クライアントの React における非同期操作(取得を含む)を処理するための標準となる可能性があります。
現在、クライアントでのデータ取得のニーズを満たすために、React-Query や SWR などのライブラリに依存することをお勧めします。特に取得の動作に注意してください!
では、準備は整いましたか?#
実験は前進の本質であり、私たちは卵を割らずに美味しいオムレツを作ることはできません。本記事があなた自身の特定のユースケースに対するこの質問に答える手助けになることを願っています。
新しいプロジェクトであれば、App ディレクトリを使用し、Page ディレクトリをバックアップまたは重要なビジネス機能として使用することをお勧めします。リファクタリングの場合は、クライアントでのデータ取得がどれだけあるかによります。少なければ、リファクタリングを開始できます;多ければ、完全な解決策を待つ必要があるかもしれません。