在上一节课中,我们创建了第一个 Next.js 项目,并初步了解了项目的基本结构。在这一个部分,我们将深入探索 Next.js 的项目组织方式和文件系统路由机制。

Next.js 13 引入了全新的 App Router,这是基于 React Server Components 构建的路由系统。App Router 使用文件系统来定义路由,这意味着你的文件夹结构直接决定了你的应用的路由结构。这种设计哲学被称为"约定优于配置",它减少了我们需要编写的样板代码,让开发变得更加直观。
在 App Router 中,所有的路由都定义在 app 目录下。这个目录是特殊的,Next.js 会扫描这个目录下的文件,并根据文件结构自动生成路由。
让我们先看看一个典型的 Next.js 项目的完整目录结构。在项目根目录下,你会看到以下主要目录和文件:
|my-nextjs-app/ ├── app/ # App Router 的核心目录 │ ├── layout.tsx # 根布局 │ ├── page.tsx # 首页 │ ├── globals.css # 全局样式 │ ├── about/ # 关于页面路由 │ │ └── page.tsx │ └── blog/ # 博客路由组 │ ├── layout.tsx # 博客布局 │ └── [slug]/ # 动态路由 │ └── page.tsx ├── components/ # 可重用组件 │ ├── Button.tsx │ └── Header.tsx ├── lib/ # 工具函数和配置 │ └── utils.ts ├── public/ # 静态资源 │ ├── images/ │ └── favicon.ico ├── next.config.mjs # Next.js 配置 ├── package.json # 项目依赖 └── tsconfig.json # TypeScript 配置
让我们逐个了解这些目录和文件的作用。
app 目录是路由的核心。在这个目录下,每个文件夹代表一个路由段,每个 page.tsx 文件代表一个可访问的页面。这种设计让路由结构一目了然,你只需要查看文件夹结构就能知道应用有哪些页面。components 目录用于存放可重用的 React 组件。这些组件不直接参与路由,但可以被页面和布局使用。良好的组件组织可以让代码更加模块化和可维护。lib 目录通常用于存放工具函数、配置文件和第三方库的封装。比如,你可能在这里放置数据库连接、API 客户端、工具函数等。public 目录用于存放静态资源。放在这个目录下的文件可以通过 URL 直接访问,无需通过路由。比如,public/logo.png 可以通过 /logo.png 访问。在 App Router 中,路由是通过文件系统自动生成的。理解这个机制是掌握 Next.js 的关键。让我们从最简单的例子开始。
假设我们在 app 目录下创建了以下文件:
|app/ ├── page.tsx # 对应路由: / └── about/ └── page.tsx # 对应路由: /about
当我们访问 / 时,Next.js 会渲染 app/page.tsx。当我们访问 /about 时,Next.js 会渲染 app/about/page.tsx。
这个机制非常简单直观:文件夹名成为 URL 路径的一部分,page.tsx 文件定义了该路径下要渲染的内容。
让我们创建一个实际的例子。首先,我们在 app 目录下创建 about 文件夹:
|// app/about/page.tsx export default function AboutPage() { return ( <div> <h1>关于我们</h1> <p>这是关于页面</p> </div> ); }
保存文件后,访问 http://localhost:3000/about,你就会看到这个页面。
Next.js 支持嵌套路由,这意味着你可以在文件夹中创建子文件夹,形成多层级的路由结构。比如:
|app/ ├── page.tsx # / ├── about/ │ └── page.tsx # /about └── products/ ├── page.tsx # /products └── [id]/ └── page.tsx # /products/[id]
在这个结构中,/products 是一个路由,/products/[id] 是它的子路由。方括号 [id] 表示这是一个动态路由段,我们稍后会详细讲解。
让我们创建一个嵌套路由的例子。首先创建 products 文件夹和它的 page.tsx:
|// app/products/page.tsx export default function ProductsPage() { return ( <div> <h1>产品列表</h1> <p>这里显示所有产品</p> </div> ); }
然后,在 products 文件夹下创建 [id] 文件夹(注意方括号),并创建 page.tsx:
|// app/products/[id]/page.tsx export default function ProductDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ( <div> <h1>产品详情</h1> <
注意这里我们使用了 await params。在 Next.js 15+ 中,params 是一个 Promise,我们需要使用 await 来获取参数值。这是为了支持异步路由参数,我们会在后面的课程中详细讲解。
现在,访问 /products 会显示产品列表,访问 /products/123 会显示 ID 为 123 的产品详情。
page.tsx 是 App Router 中的特殊文件。只有包含 page.tsx 的文件夹才会成为可访问的路由。这意味着你可以创建其他文件(比如组件文件、工具文件)在同一个文件夹中,它们不会影响路由。
比如,这样的结构是合法的:
|app/ └── about/ ├── page.tsx # 路由: /about ├── Header.tsx # 组件,不影响路由 └── utils.ts # 工具函数,不影响路由
在这个例子中,只有 page.tsx 定义了路由,Header.tsx 和 utils.ts 只是普通的文件,可以被 page.tsx 导入使用,但不会创建新的路由。
page.tsx 文件必须导出一个默认的 React 组件。这个组件就是页面要渲染的内容。组件可以接收 params 和 searchParams 作为 props,我们会在后面的学习中详细讲解这些参数。
layout.tsx 是另一个特殊文件,它用于定义共享的 UI 结构。与 page.tsx 不同,layout.tsx 不会创建新的路由,而是为当前路由及其所有子路由提供共享的布局。
让我们通过一个例子来理解布局的作用。假设我们有这样的结构:
|app/ ├── layout.tsx # 根布局 ├── page.tsx # 首页 └── blog/ ├── layout.tsx # 博客布局 └── page.tsx # 博客首页
当我们访问 /blog 时,Next.js 会按照以下顺序渲染组件:
app/layout.tsx(根布局)app/blog/layout.tsx(博客布局)app/blog/page.tsx(博客页面)这些布局会嵌套在一起,形成最终的页面结构。这就像俄罗斯套娃一样,外层的布局包裹着内层的布局和页面。
让我们创建一个实际的例子。首先,我们修改根布局,添加一个导航栏:
|// app/layout.tsx export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="zh"> <body> <nav> <a href="/">首页</a>
然后,我们为博客创建一个专门的布局:
|// app/blog/layout.tsx export default function BlogLayout({ children, }: { children: React.ReactNode; }) { return ( <div className="blog-container"> <aside> <h2>博客分类</h2> <ul> <li
现在,当我们访问 /blog 时,页面会包含根布局的导航栏和博客布局的侧边栏,最后是博客页面的内容。这种嵌套布局让我们可以灵活地组织页面的结构。
loading.tsx 是用于显示加载状态的特殊文件。当 Next.js 正在加载页面内容时,它会自动显示 loading.tsx 中定义的组件。
让我们创建一个加载组件:
|// app/blog/loading.tsx export default function Loading() { return ( <div> <p>加载中...</p> </div> ); }
当用户访问 /blog 时,如果页面还在加载,Next.js 会显示这个加载组件。一旦页面加载完成,加载组件会被实际的页面内容替换。
我们可以让加载组件更加美观:
|// app/blog/loading.tsx export default function Loading() { return ( <div className="flex items-center justify-center min-h-screen"> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> </div> ); }
这个加载组件显示一个旋转的加载动画,给用户更好的视觉反馈。
error.tsx 用于处理错误情况。当页面或布局组件抛出错误时,Next.js 会显示 error.tsx 中定义的错误边界组件。
让我们创建一个错误处理组件:
|// app/blog/error.tsx 'use client'; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return ( <div> <h2>出错了!</
注意这里我们使用了 'use client' 指令。这是因为错误边界需要使用客户端功能(比如 onClick)。我们会在后面的学习中详细讲解客户端组件和服务器组件的区别。
error prop 包含错误信息,reset 函数可以尝试重新渲染组件。这给用户提供了一个恢复错误的机会。
not-found.tsx 用于显示 404 页面。当用户访问不存在的路由时,Next.js 会显示这个组件。
让我们创建一个 404 页面:
|// app/not-found.tsx export default function NotFound() { return ( <div> <h1>404 - 页面未找到</h1> <p>抱歉,您访问的页面不存在。</p> <a href="/">返回首页</a> </div> ); }
我们也可以在特定的路由下创建 not-found.tsx,这样只有该路由及其子路由会使用这个 404 页面:
|// app/blog/not-found.tsx export default function BlogNotFound() { return ( <div> <h1>博客文章未找到</h1> <p>抱歉,您要查找的博客文章不存在。</p> <a href="/blog">返回博客首页</a> </div> ); }
动态路由允许我们创建可以匹配不同 URL 模式的路由。在 App Router 中,我们使用方括号 [] 来创建动态路由段。
最简单的动态路由是单段动态路由:
|app/ └── products/ └── [id]/ └── page.tsx # 匹配 /products/123, /products/abc 等
在这个例子中,[id] 是一个动态段,它可以匹配任何值。访问 /products/123 时,id 的值是 "123"。访问 /products/abc 时,id 的值是 "abc"。
在页面组件中,我们可以通过 params 获取动态段的值:
|// app/products/[id]/page.tsx export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ( <div> <h1>产品 {id}</h1>
注意这里我们使用了 async 函数和 await params。在 Next.js 15+ 中,这是获取路由参数的标准方式。
我们也可以创建多个动态段:
|app/ └── shop/ └── [category]/ └── [product]/ └── page.tsx # 匹配 /shop/electronics/phone
在这个例子中,访问 /shop/electronics/phone 时,category 的值是 "electronics",product 的值是 "phone"。
在页面组件中获取多个参数:
|// app/shop/[category]/[product]/page.tsx export default async function ProductPage({ params, }: { params: Promise<{ category: string; product: string }>; }) { const { category, product } = await params; return ( <div>
有时候,我们希望某个路由段是可选的。我们可以使用双括号 [[...]] 来创建可选的路由段:
|app/ └── blog/ └── [[...slug]]/ └── page.tsx # 匹配 /blog, /blog/2024, /blog/2024/01 等
在这个例子中,slug 是一个可选的动态段。访问 /blog 时,slug 是 undefined。访问 /blog/2024 时,slug 是 ["2024"]。访问 /blog/2024/01 时,slug 是 ["2024", "01"]。
注意可选动态路由会返回一个数组,即使只有一个值也是如此。如果路由段不存在,则返回 undefined。
|// app/blog/[[...slug]]/page.tsx export default async function BlogPage({ params, }: { params: Promise<{ slug?: string[] }>; }) { const { slug } = await params; if (!slug) { return <div>博客首页</div>; }
Catch-all 路由使用 [...slug] 语法,它可以匹配多个路由段,但不是可选的:
|app/ └── docs/ └── [...slug]/ └── page.tsx # 匹配 /docs/a, /docs/a/b, /docs/a/b/c 等
与可选动态路由不同,catch-all 路由必须至少匹配一个段。访问 /docs 不会匹配这个路由。
|// app/docs/[...slug]/page.tsx export default async function DocsPage({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; return ( <div> <h1>文档路径: {slug.join('/'
路由组允许我们组织路由而不影响 URL 结构。路由组使用括号 () 来定义,括号内的文件夹名不会成为 URL 的一部分。
比如,我们可能有这样的需求:管理后台和用户前台使用不同的布局,但它们的 URL 不应该包含 admin 或 user 前缀。
|app/ ├── (marketing)/ │ ├── layout.tsx # 营销页面布局 │ ├── page.tsx # / (首页) │ └── about/ │ └── page.tsx # /about └── (shop)/ ├── layout.tsx # 商店页面布局 └── products/ └── page.tsx # /products
在这个结构中,(marketing) 和 (shop) 是路由组。它们不影响 URL,但允许我们为不同的路由组使用不同的布局。
访问 / 时,会使用 (marketing)/layout.tsx。访问 /products 时,会使用 (shop)/layout.tsx。
并行路由是 Next.js 的一个高级特性,它允许我们在同一个布局中同时渲染多个页面。并行路由使用 @folder 命名约定。
|app/ ├── layout.tsx ├── page.tsx ├── @analytics/ │ └── page.tsx └── @team/ └── page.tsx
在这个结构中,@analytics 和 @team 是并行路由槽。它们会在同一个布局中同时渲染,但不会影响主路由的 URL。
并行路由通常与路由组和条件渲染一起使用,用于创建复杂的布局结构,比如仪表板的不同视图。
当多个路由可能匹配同一个 URL 时,Next.js 会按照以下优先级选择:
比如,对于 URL /products/new:
app/products/new/page.tsx(静态路由)会优先匹配app/products/[id]/page.tsx(动态路由)会匹配app/products/[...slug]/page.tsx(catch-all 路由)会匹配这种优先级规则让我们可以灵活地组织路由,同时保持 URL 的清晰性。
Next.js 允许我们为路由定义元数据,比如页面标题、描述等。我们可以通过导出 metadata 对象或 generateMetadata 函数来定义元数据。
静态元数据:
|// app/about/page.tsx export const metadata = { title: '关于我们', description: '了解我们的团队和使命', }; export default function AboutPage() { return <div>关于页面</div>; }
动态元数据:
|// app/products/[id]/page.tsx export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const product = await fetchProduct(id); return { title: product.name, description: product.description,
我们会在后面的学习中详细讲解元数据的使用,不必急于一时。
当项目变得复杂时,良好的文件组织变得至关重要。以下是一些最佳实践:
首先,将可重用的组件放在 components 目录下,并按功能或类型组织:
|components/ ├── ui/ # 基础 UI 组件 │ ├── Button.tsx │ └── Input.tsx ├── layout/ # 布局组件 │ ├── Header.tsx │ └── Footer.tsx └── features/ # 功能组件 ├── ProductCard.tsx └── UserProfile.tsx
其次,将工具函数和配置放在 lib 目录下:
|lib/ ├── utils.ts # 通用工具函数 ├── api.ts # API 客户端 └── constants.ts # 常量定义
使用路由组来组织相关的路由,即使它们不需要不同的布局:
|app/ ├── (auth)/ │ ├── login/ │ └── register/ └── (dashboard)/ ├── profile/ └── settings/
最后,保持 app 目录的简洁,将复杂的逻辑提取到组件和工具函数中。
在这一节课中,我们探索了 Next.js 的项目结构和文件系统路由。我们学习了如何通过创建文件和文件夹来定义路由,了解了各种特殊文件的作用,掌握了动态路由、路由组、并行路由等高级特性。 文件系统路由是 Next.js 的核心特性之一,它让路由定义变得直观和可维护。
在接下来的一节课中,我们将学习页面和布局的使用,了解如何创建嵌套布局,如何使用模板,以及如何在不同页面之间共享 UI 结构,话不多说,让我们立即开始!