第 3 章:App Router 目录结构全解构

深入理解 Next.js App Router 的目录结构设计理念,掌握文件系统路由、源码组织原则、Layouts/Pages/Templates 的核心区别,以及嵌套路由、并行路由、拦截路由、Suspense、Error Boundary 等高级特性。

本章是 App Router 的核心章节。读完本章,你将深入理解 app/ 目录的设计哲学,掌握所有路由模式和特殊文件的使用方式。


3.1 app/ 目录的设计理念与文件系统路由

文件系统路由:文件即路由

App Router 的核心理念是 文件系统路由(File-system Routing):目录结构就是路由结构。

app/
├── page.tsx                    → /
├── about/
│   └── page.tsx                → /about
├── blog/
│   ├── page.tsx                → /blog
│   └── [slug]/
│       └── page.tsx            → /blog/:slug
├── dashboard/
│   ├── page.tsx                → /dashboard
│   ├── settings/
│   │   └── page.tsx            → /dashboard/settings
│   └── analytics/
│       └── page.tsx            → /dashboard/analytics
└── api/
    └── users/
        └── route.ts            → /api/users (API)

优势

  • 直观:文件路径 = URL 路径,无需手写路由配置
  • 可预测:看到文件结构就知道路由结构
  • 可维护:新增页面 = 新增文件,删除页面 = 删除文件

特殊文件约定

App Router 定义了一系列特殊文件名,每个文件有不同的职责:

app/dashboard/
├── page.tsx           # 页面(必须)
├── layout.tsx         # 布局(可选)
├── loading.tsx        # 加载状态(可选)
├── error.tsx          # 错误边界(可选)
├── not-found.tsx      # 404 页面(可选)
├── template.tsx       # 模板(可选)
└── route.ts           # API 路由(可选,与 page.tsx 互斥)

特殊文件职责表

文件名职责是否必须渲染位置
page.tsx路由的页面内容路由必须有客户端 + 服务端
layout.tsx布局,包裹子路由根布局必须服务端
loading.tsx加载状态 UI可选客户端
error.tsx错误边界 UI可选客户端
not-found.tsx404 页面可选客户端
template.tsx模板,每次导航重新挂载可选客户端
route.tsAPI 路由处理器可选服务端

注意page.tsxroute.ts 不能同时存在于同一路由段。page.tsx 是页面,route.ts 是 API。

路由段(Route Segment)

每个文件夹代表一个 路由段(Route Segment),对应 URL 的一个路径段:

app/dashboard/settings/analytics/page.tsx
    ↓           ↓          ↓
 /dashboard  /settings  /analytics

每个路由段可以有自己独立的 layout.tsxloading.tsxerror.tsx


3.2 源码组织原则:按功能模块 vs 按类型分层

策略 1:按类型分层(传统方式)

my-app/
├── app/                    # 页面和路由
│   ├── page.tsx
│   ├── about/
│   ├── blog/
│   └── dashboard/
├── components/             # 所有组件
│   ├── Button.tsx
│   ├── Card.tsx
│   ├── Header.tsx
│   └── Footer.tsx
├── lib/                    # 工具函数
│   ├── utils.ts
│   ├── api.ts
│   └── db.ts
├── hooks/                  # 自定义 Hooks
│   ├── useAuth.ts
│   └── useTheme.ts
└── types/                  # TypeScript 类型
    ├── user.ts
    └── post.ts

优点

  • 结构清晰,按职责分类
  • 适合小型项目

缺点

  • 功能分散:一个功能(如博客)的代码分布在多个目录
  • 难以复用:blog 模块的代码和 dashboard 模块的代码混在一起

策略 2:按功能模块组织(推荐)

my-app/
├── app/                    # 页面和路由
│   ├── page.tsx
│   ├── (marketing)/        # Route Group:营销页面
│   │   ├── about/
│   │   └── pricing/
│   └── (dashboard)/        # Route Group:仪表盘
│       ├── dashboard/
│       ├── settings/
│       └── analytics/
├── features/               # 功能模块
│   ├── blog/               # 博客模块
│   │   ├── components/     # 博客专用组件
│   │   ├── lib/            # 博客专用工具函数
│   │   ├── hooks/          # 博客专用 Hooks
│   │   └── types/          # 博客专用类型
│   ├── auth/               # 认证模块
│   │   ├── components/
│   │   ├── lib/
│   │   └── hooks/
│   └── analytics/          # 分析模块
├── shared/                 # 共享代码
│   ├── components/         # 通用组件(Button、Card)
│   ├── lib/                # 通用工具函数
│   ├── hooks/              # 通用 Hooks
│   └── types/              # 通用类型
└── public/                 # 静态资源

优点

  • 高内聚:一个功能的所有代码在一个目录
  • 低耦合:模块之间通过 shared/ 共享代码
  • 易于维护:修改博客功能只需要关注 features/blog/
  • 易于测试:每个模块可以独立测试

缺点

  • 初期需要更多规划
  • 需要明确"共享"和"专用"的边界

Route Groups(路由组)

使用括号 () 创建 Route Group,组织代码但不影响 URL:

app/
├── (marketing)/            # 营销页面组
│   ├── about/
│   │   └── page.tsx        → /about
│   ├── pricing/
│   │   └── page.tsx        → /pricing
│   └── layout.tsx          # 营销页面专用布局
├── (dashboard)/            # 仪表盘组
│   ├── dashboard/
│   │   └── page.tsx        → /dashboard
│   ├── settings/
│   │   └── page.tsx        → /settings
│   └── layout.tsx          # 仪表盘专用布局
└── layout.tsx              # 根布局(全局)

Route Group 的优势

  1. 组织代码:将相关页面分组,不影响 URL
  2. 独立布局:每个组可以有独立的 layout.tsx
  3. 代码分割:不同组的布局不会互相影响

示例:营销页面和仪表盘使用不同的布局

// app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <header className="bg-blue-600 text-white p-4">
        <h1>MyApp - 营销页面</h1>
      </header>
      <main>{children}</main>
      <footer className="bg-gray-800 text-white p-4">
        © 2025 MyApp
      </footer>
    </div>
  );
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-64 bg-gray-900 text-white p-4">
        <nav>
          <ul>
            <li><a href="/dashboard">Dashboard</a></li>
            <li><a href="/settings">Settings</a></li>
            <li><a href="/analytics">Analytics</a></li>
          </ul>
        </nav>
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}

私有文件夹

使用下划线 _ 前缀创建 私有文件夹,不会被路由系统识别:

app/
├── _components/             # 私有组件目录(不影响路由)
│   ├── Button.tsx
│   └── Card.tsx
├── _utils/                 # 私有工具函数
│   └── helpers.ts
├── dashboard/
│   └── page.tsx            → /dashboard
└── page.tsx                → /

用途

  • 存放只在该路由段使用的组件
  • 避免组件目录被误认为路由

3.3 Layouts、Pages、Templates 三者的核心区别

Layout(布局)

定义layout.tsx 是布局组件,包裹子路由页面,在路由切换时保持挂载

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <header>Dashboard Header</header>
      <main>{children}</main>
      <footer>Dashboard Footer</footer>
    </div>
  );
}

特性

  • 持久化:路由切换时不重新挂载
  • 共享状态:可以在布局中使用 useState 保持状态
  • 性能优化:避免重复渲染 Header、Sidebar
  • 嵌套:子布局会嵌套在父布局中

根布局(必须)

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
    </html>
  );
}

注意app/layout.tsx 是必须的,它定义 <html><body> 标签。

Page(页面)

定义page.tsx 是路由的页面内容,每个路由段必须有 page.tsx

// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome to your dashboard!</p>
    </div>
  );
}

特性

  • 必须存在:没有 page.tsx 的路由段不可访问
  • 每次导航重新渲染:路由切换时重新挂载
  • 接收 props:可以接收 paramssearchParams
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
  searchParams,
}: {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  const post = await getPost(params.slug);
  return <article>{post.content}</article>;
}

Template(模板)

定义template.tsx 类似于 layout.tsx,但 每次导航都重新挂载

// app/dashboard/template.tsx
export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="animate-fade-in">
      {children}
    </div>
  );
}

特性

  • 每次导航重新挂载:路由切换时重新创建组件实例
  • 重置状态useStateuseEffect 等状态会被重置
  • 重新执行副作用useEffect 会重新运行
  • 重新挂载 DOM:适合需要动画的场景

三者对比

特性LayoutPageTemplate
必须存在根布局必须路由必须有可选
路由切换时保持挂载重新挂载重新挂载
状态保持✅ 保持❌ 重置❌ 重置
副作用不重新执行重新执行重新执行
嵌套支持嵌套不支持支持嵌套
使用场景共享 UI(Header、Sidebar)页面内容动画、状态重置

实战示例:Layout vs Template

场景:Dashboard 页面需要在路由切换时显示动画。

使用 Layout(不推荐)

// app/dashboard/layout.tsx
"use client";

import { useState } from 'react';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      <div className="animate-fade-in">
        {children}
      </div>
    </div>
  );
}

问题:从 /dashboard 导航到 /dashboard/settingscount 状态保持不变,动画不会重新触发。

使用 Template(推荐)

// app/dashboard/template.tsx
"use client";

import { useState } from 'react';

export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      <div className="animate-fade-in">
        {children}
      </div>
    </div>
  );
}

效果:每次导航,count 重置为 0,动画重新触发。

渲染顺序

<Layout>
  <Template>
    <Page />
  </Template>
</Layout>

完整示例

app/
├── layout.tsx              # 根布局
├── page.tsx                # 首页
└── dashboard/
    ├── layout.tsx          # Dashboard 布局
    ├── template.tsx        # Dashboard 模板
    ├── page.tsx            # Dashboard 页面
    └── settings/
        ├── layout.tsx      # Settings 布局
        └── page.tsx        # Settings 页面

访问 /dashboard/settings 的渲染顺序:

<RootLayout>
  <DashboardLayout>
    <DashboardTemplate>
      <SettingsLayout>
        <SettingsPage />
      </SettingsLayout>
    </DashboardTemplate>
  </DashboardLayout>
</RootLayout>

3.4 路由进阶:嵌套路由、并行路由、拦截路由

嵌套路由(Nested Routes)

定义:通过目录嵌套实现路由嵌套,子路由会嵌套在父路由的布局中。

app/
├── layout.tsx              # 根布局
├── page.tsx                → /
└── dashboard/
    ├── layout.tsx          # Dashboard 布局
    ├── page.tsx            → /dashboard
    └── settings/
        ├── layout.tsx      # Settings 布局
        └── page.tsx        → /dashboard/settings

渲染结构

访问 /dashboard/settings:

<RootLayout>
  <DashboardLayout>
    <SettingsLayout>
      <SettingsPage />
    </SettingsLayout>
  </DashboardLayout>
</RootLayout>

示例

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-64 bg-gray-900 text-white p-4">
        <h2>Dashboard</h2>
        <nav>
          <ul>
            <li><a href="/dashboard">Overview</a></li>
            <li><a href="/dashboard/settings">Settings</a></li>
            <li><a href="/dashboard/analytics">Analytics</a></li>
          </ul>
        </nav>
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}
// app/dashboard/settings/layout.tsx
export default function SettingsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Settings</h1>
      <div className="border-t pt-4">{children}</div>
    </div>
  );
}

动态路由(Dynamic Routes)

定义:使用方括号 [] 创建动态路由段。

app/
└── blog/
    └── [slug]/
        └── page.tsx        → /blog/:slug

示例

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

访问 URL

  • /blog/hello-worldparams.slug = "hello-world"
  • /blog/nextjs-guideparams.slug = "nextjs-guide"

捕获所有路由(Catch-all Routes)

定义:使用 [...slug] 捕获所有子路径。

app/
└── docs/
    └── [...slug]/
        └── page.tsx        → /docs/*

示例

// app/docs/[...slug]/page.tsx
export default function Docs({
  params,
}: {
  params: { slug: string[] };
}) {
  return (
    <div>
      <h1>Documentation</h1>
      <p>Path: {params.slug.join(' / ')}</p>
    </div>
  );
}

访问 URL

  • /docs/getting-startedparams.slug = ["getting-started"]
  • /docs/api/users/createparams.slug = ["api", "users", "create"]

可选捕获所有路由(Optional Catch-all Routes)

定义:使用 [[...slug]] 创建可选的捕获所有路由。

app/
└── categories/
    └── [[...slug]]/
        └── page.tsx

访问 URL

  • /categoriesparams.slug = undefined
  • /categories/electronicsparams.slug = ["electronics"]
  • /categories/electronics/phonesparams.slug = ["electronics", "phones"]

并行路由(Parallel Routes)

定义:使用 @ 前缀创建 Slot,在同一布局中同时渲染多个页面。

app/
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── @analytics/         # Analytics Slot
    │   └── page.tsx
    └── @team/              # Team Slot
        └── page.tsx

布局中使用 Slot

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="grid grid-cols-2 gap-4">
        <div>{analytics}</div>
        <div>{team}</div>
      </div>
      <div>{children}</div>
    </div>
  );
}

渲染结果

访问 /dashboard:

<DashboardLayout>
  <AnalyticsPage />  → @analytics Slot
  <TeamPage />       → @team Slot
  <DashboardPage />  → children
</DashboardLayout>

使用场景

  • 模态框:在页面中显示模态框,同时保持原页面可见
  • 分割视图:同时显示多个独立的内容区域
  • 条件渲染:根据条件显示不同的 Slot

模态框示例

app/
└── feed/
    ├── layout.tsx
    ├── page.tsx
    └── @modal/
        ├── default.tsx     # 默认空内容
        └── photo/[id]/
            └── page.tsx    # 模态框内容
// app/feed/layout.tsx
export default function FeedLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      {children}
      {modal}
    </div>
  );
}
// app/feed/@modal/default.tsx
export default function Default() {
  return null;
}
// app/feed/@modal/photo/[id]/page.tsx
export default function PhotoModal({
  params,
}: {
  params: { id: string };
}) {
  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
      <div className="bg-white p-8 rounded-lg">
        <h2>Photo {params.id}</h2>
        <img src={`/photos/${params.id}.jpg`} alt="Photo" />
      </div>
    </div>
  );
}

拦截路由(Intercepting Routes)

定义:使用 (.)(..)(...) 前缀拦截路由,在当前布局中显示另一个路由的内容。

前缀含义

  • (.):拦截同级路由
  • (..):拦截上一级路由
  • (..)(..):拦截上两级路由
  • (...):拦截根路由

场景:点击照片链接时,在当前页面显示模态框,而不是导航到新页面。

app/
├── feed/
│   ├── page.tsx            # Feed 页面
│   └── (.)photo/[id]/      # 拦截 /photo/[id]
│       └── page.tsx        # 模态框内容
└── photo/
    └── [id]/
        └── page.tsx        # 完整照片页面

Feed 页面

// app/feed/page.tsx
import Link from 'next/link';

export default function Feed() {
  return (
    <div>
      <h1>Feed</h1>
      <ul>
        <li>
          <Link href="/photo/1">View Photo 1</Link>
        </li>
        <li>
          <Link href="/photo/2">View Photo 2</Link>
        </li>
      </ul>
    </div>
  );
}

拦截路由(模态框)

// app/feed/(.)photo/[id]/page.tsx
export default function PhotoModal({
  params,
}: {
  params: { id: string };
}) {
  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
      <div className="bg-white p-8 rounded-lg">
        <h2>Photo {params.id} (Modal)</h2>
        <img src={`/photos/${params.id}.jpg`} alt="Photo" />
      </div>
    </div>
  );
}

完整页面(直接访问 URL)

// app/photo/[id]/page.tsx
export default function PhotoPage({
  params,
}: {
  params: { id: string };
}) {
  return (
    <div>
      <h1>Photo {params.id}</h1>
      <img src={`/photos/${params.id}.jpg`} alt="Photo" />
      <p>This is the full photo page.</p>
    </div>
  );
}

行为

  • /feed 点击链接 → 显示模态框(拦截路由)
  • 直接访问 /photo/1 → 显示完整页面

3.5 Suspense 边界与 Streaming 流式渲染

Suspense 基础

定义<Suspense> 是 React 的组件,用于在子组件加载时显示 fallback UI。

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

Streaming 流式渲染

定义:Next.js 支持 流式渲染(Streaming),服务端分段发送 HTML,客户端渐进渲染。

工作原理

服务端                          客户端
  │                              │
  ├─ 发送 <html>、<body>         │
  ├─ 发送 <header>               ├─ 渲染 <header>
  ├─ 发送 <main>(Suspense)     ├─ 显示 fallback
  ├─ ... 等待数据 ...            │
  ├─ 发送 <main> 真实内容        ├─ 替换 fallback
  ├─ 发送 <footer>               ├─ 渲染 <footer>
  └─ 发送 </body>、</html>       └─ 渲染完成

优势

  • 更快的首屏:用户立即看到部分内容,不需要等待整个页面
  • 更好的用户体验:渐进式加载,减少白屏时间
  • 更好的 TTFB:Time to First Byte 更短

实战:Suspense + Streaming

场景:Dashboard 页面,Header 立即显示,数据组件延迟加载。

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<CardSkeleton />}>
          <AnalyticsCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard />
        </Suspense>
      </div>
    </div>
  );
}
// app/dashboard/analytics-card.tsx
async function getAnalytics() {
  // 模拟慢速 API
  await new Promise(resolve => setTimeout(resolve, 2000));
  return { views: 1234, visitors: 567 };
}

export default async function AnalyticsCard() {
  const analytics = await getAnalytics();
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h2>Analytics</h2>
      <p>Views: {analytics.views}</p>
      <p>Visitors: {analytics.visitors}</p>
    </div>
  );
}
// app/dashboard/revenue-card.tsx
async function getRevenue() {
  // 模拟更慢的 API
  await new Promise(resolve => setTimeout(resolve, 4000));
  return { total: 9876, growth: 12 };
}

export default async function RevenueCard() {
  const revenue = await getRevenue();
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h2>Revenue</h2>
      <p>Total: ${revenue.total}</p>
      <p>Growth: {revenue.growth}%</p>
    </div>
  );
}
// app/dashboard/card-skeleton.tsx
export default function CardSkeleton() {
  return (
    <div className="bg-white p-6 rounded-lg shadow animate-pulse">
      <div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
      <div className="h-8 bg-gray-200 rounded w-2/3 mb-2"></div>
      <div className="h-8 bg-gray-200 rounded w-1/2"></div>
    </div>
  );
}

效果

  1. 页面立即显示 Header
  2. 两个 Card 显示 Skeleton 加载态
  3. 2 秒后,AnalyticsCard 替换为真实内容
  4. 4 秒后,RevenueCard 替换为真实内容

loading.tsx 自动触发 Suspense

定义loading.tsx 会自动包裹在 <Suspense> 中,无需手动添加。

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="grid grid-cols-2 gap-4">
        <div className="h-32 bg-gray-200 rounded"></div>
        <div className="h-32 bg-gray-200 rounded"></div>
      </div>
    </div>
  );
}

效果:导航到 /dashboard 时,自动显示 loading.tsx,直到 page.tsx 加载完成。

多个 Suspense 边界

可以在同一页面中使用多个 <Suspense>,实现细粒度的流式渲染:

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>
      
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<CardSkeleton />}>
          <AnalyticsCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <UsersCard />
        </Suspense>
        
        <Suspense fallback={<CardSkeleton />}>
          <OrdersCard />
        </Suspense>
      </div>
    </div>
  );
}

效果:每个 Card 独立加载,先完成的先显示。


3.6 Error Boundary 错误处理与 loading.tsx 加载态

error.tsx 错误边界

定义error.tsx 是错误边界组件,捕获子组件的运行时错误。

// app/dashboard/error.tsx
"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Something went wrong!
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={() => reset()}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        Try again
      </button>
    </div>
  );
}

特性

  • 必须是 Client Component:需要 "use client" 声明
  • 捕获子组件错误:包裹在同一路由段的子组件中
  • 提供 reset 函数:点击按钮重新尝试渲染

示例

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const data = await fetchData();
  
  // 如果 fetchData 抛出错误,error.tsx 会捕获
  return <div>{data.content}</div>;
}

嵌套错误边界

每个路由段可以有独立的 error.tsx

app/
├── error.tsx               # 根错误边界
├── page.tsx
└── dashboard/
    ├── error.tsx           # Dashboard 错误边界
    ├── page.tsx
    └── settings/
        ├── error.tsx       # Settings 错误边界
        └── page.tsx

错误冒泡

  • 如果 settings/page.tsx 抛出错误,settings/error.tsx 捕获
  • 如果 settings/error.tsx 也抛出错误,dashboard/error.tsx 捕获
  • 如果 dashboard/error.tsx 也抛出错误,error.tsx(根)捕获

global-error.tsx 全局错误边界

定义global-error.tsx 捕获根布局的错误,包括 <html><body> 标签。

// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen flex items-center justify-center bg-red-50">
          <div className="text-center">
            <h1 className="text-4xl font-bold text-red-600 mb-4">
              Critical Error
            </h1>
            <p className="text-gray-600 mb-8">{error.message}</p>
            <button
              onClick={() => reset()}
              className="bg-red-600 text-white px-6 py-3 rounded-lg"
            >
              Try again
            </button>
          </div>
        </div>
      </body>
    </html>
  );
}

使用场景:根布局或全局配置错误。

not-found.tsx 404 页面

定义not-found.tsx 在调用 notFound() 函数时显示。

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
        <p className="text-xl text-gray-600 mb-8">Page not found</p>
        <Link
          href="/"
          className="bg-blue-600 text-white px-6 py-3 rounded-lg"
        >
          Go home
        </Link>
      </div>
    </div>
  );
}

触发方式

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  
  if (!post) {
    notFound(); // 触发 not-found.tsx
  }
  
  return <article>{post.content}</article>;
}

loading.tsx 加载态

定义loading.tsx 在页面加载时显示,自动包裹在 <Suspense> 中。

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="p-8">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-8 animate-pulse"></div>
      
      <div className="grid grid-cols-2 gap-4">
        {[1, 2, 3, 4].map(i => (
          <div key={i} className="h-32 bg-gray-200 rounded animate-pulse"></div>
        ))}
      </div>
    </div>
  );
}

触发时机

  • 导航到该路由段时
  • page.tsx 正在加载(如 await fetch()
  • 子组件正在加载

与 Suspense 的关系

// 手动使用 Suspense
<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

// 自动使用 loading.tsx
// Next.js 自动包裹:
<Suspense fallback={<LoadingFile />}>
  <Page />
</Suspense>

组合使用:loading.tsx + error.tsx + not-found.tsx

app/
└── blog/
    └── [slug]/
        ├── page.tsx         # 页面内容
        ├── loading.tsx      # 加载状态
        ├── error.tsx        # 错误边界
        └── not-found.tsx    # 404 页面

完整示例

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  
  if (res.status === 404) {
    return null;
  }
  
  if (!res.ok) {
    throw new Error('Failed to fetch post');
  }
  
  return res.json();
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  
  if (!post) {
    notFound();
  }
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}
// app/blog/[slug]/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-2/3"></div>
    </div>
  );
}
// app/blog/[slug]/error.tsx
"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Failed to load post
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={() => reset()}
        className="bg-blue-600 text-white px-4 py-2 rounded"
      >
        Try again
      </button>
    </div>
  );
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold text-gray-900 mb-4">
        Post not found
      </h2>
      <p className="text-gray-600">
        The post you're looking for doesn't exist.
      </p>
    </div>
  );
}

行为

  1. 导航到 /blog/hello-world
  2. 显示 loading.tsx
  3. 如果成功 → 显示 page.tsx
  4. 如果 404 → 显示 not-found.tsx
  5. 如果其他错误 → 显示 error.tsx

本章小结

Key Takeaways

  1. 文件系统路由:目录结构 = 路由结构,无需手写路由配置
  2. 特殊文件约定page.tsx(页面)、layout.tsx(布局)、loading.tsx(加载态)、error.tsx(错误边界)、not-found.tsx(404)
  3. 源码组织:推荐按功能模块组织(features/),使用 Route Groups (group) 分组不影响 URL
  4. Layout vs Template:Layout 保持挂载(共享状态),Template 每次导航重新挂载(动画、状态重置)
  5. 嵌套路由:子布局嵌套在父布局中,共享 UI(Header、Sidebar)
  6. 并行路由:使用 @slot 同时渲染多个页面,适合模态框、分割视图
  7. 拦截路由:使用 (.) 前缀拦截路由,在当前页面显示模态框
  8. Suspense + Streaming:分段发送 HTML,渐进式渲染,提升首屏速度
  9. 错误处理error.tsx 捕获子组件错误,not-found.tsx 处理 404,支持嵌套错误边界

下一步

在下一章(卷 II:核心渲染模型),我们将深入理解 RSC、SSR、CSR 等渲染策略,掌握何时使用服务端组件、何时使用客户端组件。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页