Skip to content

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.tsx404 UI
global-error.tsx全局错误 UI
route.tsxAPI 路由
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.tsx
typescript
// 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.tsx
typescript
// 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 迁移

  1. 创建 app 目录
  2. 移动页面文件
  3. 更新布局结构
  4. 转换数据获取方法
  5. 更新 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,现有项目可以逐步迁移。

本站内容仅供学习和研究使用。