Next.js App Router
App Router 是 Next.js 13 引入的新路由系统,基于 React Server Components 构建,提供了更强大的功能和更好的开发体验。本章将详细介绍 App Router 的核心概念和使用方法。
App Router 概述
主要特性
- 服务端组件 - 默认在服务端渲染,提升性能
- 嵌套布局 - 支持复杂的布局结构
- 流式渲染 - 渐进式加载页面内容
- 并行路由 - 同时渲染多个页面段
- 拦截路由 - 拦截和重写路由
- 更好的数据获取 - 简化的异步组件
目录结构
app/
├── layout.tsx # 根布局
├── page.tsx # 首页
├── loading.tsx # 加载 UI
├── error.tsx # 错误 UI
├── not-found.tsx # 404 页面
├── global-error.tsx # 全局错误处理
├── blog/
│ ├── layout.tsx # 博客布局
│ ├── page.tsx # 博客首页
│ ├── loading.tsx # 博客加载 UI
│ └── [slug]/
│ └── page.tsx # 博客文章页
└── dashboard/
├── layout.tsx # 仪表板布局
├── page.tsx # 仪表板首页
├── analytics/
│ └── page.tsx # 分析页面
└── settings/
└── page.tsx # 设置页面文件约定
特殊文件
| 文件名 | 用途 | 必需 |
|---|---|---|
layout.tsx | 布局组件 | ✅ |
page.tsx | 页面组件 | ✅ |
loading.tsx | 加载 UI | ❌ |
error.tsx | 错误 UI | ❌ |
not-found.tsx | 404 UI | ❌ |
global-error.tsx | 全局错误 UI | ❌ |
route.tsx | API 路由 | ❌ |
template.tsx | 模板组件 | ❌ |
动态路由
app/
├── [id]/ # 动态路由段
├── [...slug]/ # 捕获所有路由
├── [[...slug]]/ # 可选捕获所有路由
└── (group)/ # 路由组 (不影响 URL)布局系统
根布局
typescript
// app/layout.tsx
import './globals.css'
export const metadata = {
title: 'My App',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>
<header>
<nav>导航栏</nav>
</header>
<main>{children}</main>
<footer>页脚</footer>
</body>
</html>
)
}嵌套布局
typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard">
<aside className="sidebar">
<nav>
<a href="/dashboard">概览</a>
<a href="/dashboard/analytics">分析</a>
<a href="/dashboard/settings">设置</a>
</nav>
</aside>
<div className="content">
{children}
</div>
</div>
)
}布局组合
typescript
// app/blog/layout.tsx
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="blog-layout">
<div className="blog-header">
<h1>我的博客</h1>
</div>
<div className="blog-content">
{children}
</div>
<div className="blog-sidebar">
<h3>最新文章</h3>
{/* 侧边栏内容 */}
</div>
</div>
)
}服务端组件
默认服务端组件
typescript
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<div>
<h1>文章列表</h1>
{posts.map((post: any) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}并行数据获取
typescript
// app/dashboard/page.tsx
async function getUser() {
const res = await fetch('https://api.example.com/user')
return res.json()
}
async function getStats() {
const res = await fetch('https://api.example.com/stats')
return res.json()
}
async function getNotifications() {
const res = await fetch('https://api.example.com/notifications')
return res.json()
}
export default async function Dashboard() {
// 并行获取数据
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications()
])
return (
<div>
<h1>欢迎,{user.name}</h1>
<div className="stats">
<div>访问量: {stats.visits}</div>
<div>用户数: {stats.users}</div>
</div>
<div className="notifications">
{notifications.map((notif: any) => (
<div key={notif.id}>{notif.message}</div>
))}
</div>
</div>
)
}客户端组件
使用 'use client' 指令
typescript
// app/components/Counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>计数: {count}</p>
<button onClick={() => setCount(count + 1)}>
增加
</button>
</div>
)
}混合使用服务端和客户端组件
typescript
// app/blog/[slug]/page.tsx (服务端组件)
import CommentForm from './CommentForm'
import LikeButton from './LikeButton'
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`)
return res.json()
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* 客户端组件 */}
<LikeButton postId={post.id} />
<CommentForm postId={post.id} />
</article>
)
}typescript
// app/blog/[slug]/LikeButton.tsx (客户端组件)
'use client'
import { useState } from 'react'
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const [likes, setLikes] = useState(0)
const handleLike = async () => {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST'
})
const data = await response.json()
setLiked(!liked)
setLikes(data.likes)
}
return (
<button
onClick={handleLike}
className={`like-btn ${liked ? 'liked' : ''}`}
>
❤️ {likes}
</button>
)
}流式渲染和 Suspense
基本 Suspense 使用
typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
import RecentActivity from './RecentActivity'
export default function Dashboard() {
return (
<div>
<h1>仪表板</h1>
<Suspense fallback={<div>加载用户信息...</div>}>
<UserProfile />
</Suspense>
<Suspense fallback={<div>加载活动记录...</div>}>
<RecentActivity />
</Suspense>
</div>
)
}嵌套 Suspense
typescript
// app/blog/page.tsx
import { Suspense } from 'react'
async function FeaturedPosts() {
const posts = await fetchFeaturedPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<Suspense fallback={<div>加载评论...</div>}>
<Comments postId={post.id} />
</Suspense>
</article>
))}
</div>
)
}
async function Comments({ postId }: { postId: string }) {
const comments = await fetchComments(postId)
return (
<div>
{comments.map(comment => (
<div key={comment.id}>{comment.text}</div>
))}
</div>
)
}
export default function BlogPage() {
return (
<div>
<h1>博客</h1>
<Suspense fallback={<div>加载精选文章...</div>}>
<FeaturedPosts />
</Suspense>
</div>
)
}加载和错误处理
加载 UI
typescript
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="loading">
<div className="spinner"></div>
<p>加载博客内容...</p>
</div>
)
}错误处理
typescript
// app/blog/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="error">
<h2>出错了!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>
重试
</button>
</div>
)
}全局错误处理
typescript
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div className="global-error">
<h1>应用程序出错</h1>
<p>发生了意外错误</p>
<button onClick={() => reset()}>重试</button>
</div>
</body>
</html>
)
}路由组
组织路由结构
app/
├── (marketing)/ # 营销页面组
│ ├── about/
│ │ └── page.tsx
│ ├── contact/
│ │ └── page.tsx
│ └── layout.tsx # 营销布局
├── (shop)/ # 商店页面组
│ ├── products/
│ │ └── page.tsx
│ ├── cart/
│ │ └── page.tsx
│ └── layout.tsx # 商店布局
└── layout.tsx # 根布局typescript
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="marketing-layout">
<nav className="marketing-nav">
<a href="/about">关于我们</a>
<a href="/contact">联系我们</a>
</nav>
{children}
</div>
)
}并行路由
使用 @folder 语法
app/
├── dashboard/
│ ├── @analytics/ # 并行路由段
│ │ └── page.tsx
│ ├── @team/ # 并行路由段
│ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsxtypescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard">
<div className="main">{children}</div>
<div className="analytics">{analytics}</div>
<div className="team">{team}</div>
</div>
)
}拦截路由
使用 (.) 语法
app/
├── feed/
│ ├── (..)photo/ # 拦截 /photo
│ │ └── [id]/
│ │ └── page.tsx
│ └── page.tsx
└── photo/
└── [id]/
└── page.tsxtypescript
// app/feed/(..)photo/[id]/page.tsx
import Modal from '@/components/Modal'
export default function PhotoModal({ params }: { params: { id: string } }) {
return (
<Modal>
<img src={`/photos/${params.id}.jpg`} alt="Photo" />
</Modal>
)
}元数据 API
静态元数据
typescript
// app/blog/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '我的博客',
description: '分享技术和生活',
keywords: ['博客', '技术', 'Next.js'],
openGraph: {
title: '我的博客',
description: '分享技术和生活',
images: ['/og-image.jpg'],
},
}
export default function BlogPage() {
return <div>博客内容</div>
}动态元数据
typescript
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await fetchPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
export default async function BlogPost({ params }: Props) {
const post = await fetchPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}中间件集成
中间件配置
typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 检查认证
const token = request.cookies.get('token')
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// 添加自定义头
const response = NextResponse.next()
response.headers.set('X-Custom-Header', 'custom-value')
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*']
}性能优化
预加载策略
typescript
// app/components/PostLink.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export default function PostLink({ post }: { post: Post }) {
const router = useRouter()
return (
<Link
href={`/blog/${post.slug}`}
onMouseEnter={() => {
// 预加载页面
router.prefetch(`/blog/${post.slug}`)
}}
>
{post.title}
</Link>
)
}缓存配置
typescript
// app/api/posts/route.ts
export const revalidate = 3600 // 1小时重新验证
export async function GET() {
const posts = await fetchPosts()
return Response.json(posts)
}迁移指南
从 Pages Router 迁移
- 创建 app 目录
- 移动页面文件
- 更新布局结构
- 转换数据获取方法
- 更新 API 路由
typescript
// 旧: pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug)
return { props: { post } }
}
export default function BlogPost({ post }) {
return <div>{post.title}</div>
}
// 新: app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug)
return <div>{post.title}</div>
}最佳实践
1. 合理使用服务端和客户端组件
typescript
// 服务端组件用于数据获取
async function PostList() {
const posts = await fetchPosts()
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}
// 客户端组件用于交互
'use client'
function PostCard({ post }) {
const [liked, setLiked] = useState(false)
return (
<div>
<h3>{post.title}</h3>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
)
}2. 优化加载体验
typescript
// 使用 Suspense 提供更好的加载体验
export default function Page() {
return (
<div>
<h1>页面标题</h1>
<Suspense fallback={<PostsSkeleton />}>
<PostList />
</Suspense>
</div>
)
}3. 错误边界策略
typescript
// 为不同层级提供错误处理
// app/error.tsx - 应用级错误
// app/blog/error.tsx - 博客模块错误
// app/blog/[slug]/error.tsx - 文章页面错误总结
App Router 带来了许多改进:
- 更好的性能 - 服务端组件和流式渲染
- 更灵活的布局 - 嵌套布局和并行路由
- 更简单的数据获取 - 异步组件和并行请求
- 更好的用户体验 - 加载状态和错误处理
- 更强的类型安全 - 完整的 TypeScript 支持
App Router 是 Next.js 的未来方向,建议新项目使用 App Router,现有项目可以逐步迁移。