本章是 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.tsx | 404 页面 | 可选 | 客户端 |
template.tsx | 模板,每次导航重新挂载 | 可选 | 客户端 |
route.ts | API 路由处理器 | 可选 | 服务端 |
注意:
page.tsx和route.ts不能同时存在于同一路由段。page.tsx是页面,route.ts是 API。
路由段(Route Segment)
每个文件夹代表一个 路由段(Route Segment),对应 URL 的一个路径段:
app/dashboard/settings/analytics/page.tsx
↓ ↓ ↓
/dashboard /settings /analytics
每个路由段可以有自己独立的 layout.tsx、loading.tsx、error.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 的优势:
- 组织代码:将相关页面分组,不影响 URL
- 独立布局:每个组可以有独立的
layout.tsx - 代码分割:不同组的布局不会互相影响
示例:营销页面和仪表盘使用不同的布局
// 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:可以接收
params和searchParams
// 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>
);
}
特性:
- ✅ 每次导航重新挂载:路由切换时重新创建组件实例
- ✅ 重置状态:
useState、useEffect等状态会被重置 - ✅ 重新执行副作用:
useEffect会重新运行 - ✅ 重新挂载 DOM:适合需要动画的场景
三者对比
| 特性 | Layout | Page | Template |
|---|---|---|---|
| 必须存在 | 根布局必须 | 路由必须有 | 可选 |
| 路由切换时 | 保持挂载 | 重新挂载 | 重新挂载 |
| 状态保持 | ✅ 保持 | ❌ 重置 | ❌ 重置 |
| 副作用 | 不重新执行 | 重新执行 | 重新执行 |
| 嵌套 | 支持嵌套 | 不支持 | 支持嵌套 |
| 使用场景 | 共享 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/settings,count 状态保持不变,动画不会重新触发。
使用 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-world→params.slug = "hello-world"/blog/nextjs-guide→params.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-started→params.slug = ["getting-started"]/docs/api/users/create→params.slug = ["api", "users", "create"]
可选捕获所有路由(Optional Catch-all Routes)
定义:使用 [[...slug]] 创建可选的捕获所有路由。
app/
└── categories/
└── [[...slug]]/
└── page.tsx
访问 URL:
/categories→params.slug = undefined/categories/electronics→params.slug = ["electronics"]/categories/electronics/phones→params.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>
);
}
效果:
- 页面立即显示 Header
- 两个 Card 显示 Skeleton 加载态
- 2 秒后,AnalyticsCard 替换为真实内容
- 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>
);
}
行为:
- 导航到
/blog/hello-world - 显示
loading.tsx - 如果成功 → 显示
page.tsx - 如果 404 → 显示
not-found.tsx - 如果其他错误 → 显示
error.tsx
本章小结
Key Takeaways
- 文件系统路由:目录结构 = 路由结构,无需手写路由配置
- 特殊文件约定:
page.tsx(页面)、layout.tsx(布局)、loading.tsx(加载态)、error.tsx(错误边界)、not-found.tsx(404) - 源码组织:推荐按功能模块组织(
features/),使用 Route Groups(group)分组不影响 URL - Layout vs Template:Layout 保持挂载(共享状态),Template 每次导航重新挂载(动画、状态重置)
- 嵌套路由:子布局嵌套在父布局中,共享 UI(Header、Sidebar)
- 并行路由:使用
@slot同时渲染多个页面,适合模态框、分割视图 - 拦截路由:使用
(.)前缀拦截路由,在当前页面显示模态框 - Suspense + Streaming:分段发送 HTML,渐进式渲染,提升首屏速度
- 错误处理:
error.tsx捕获子组件错误,not-found.tsx处理 404,支持嵌套错误边界
下一步
在下一章(卷 II:核心渲染模型),我们将深入理解 RSC、SSR、CSR 等渲染策略,掌握何时使用服务端组件、何时使用客户端组件。
参考资料
- Next.js 官方文档:File Conventions
- Next.js 官方文档:Routing
- React 官方文档:Suspense
- Next.js 官方文档:Streaming and Suspense
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。