正在加载,请稍候…

Next.js App Router 完全指南:从 Pages 到 Server Components

掌握 Next.js 13/14/15 App Router:Server Components、Client Components、布局、加载状态、错误处理

Next.js App Router 完全指南:从 Pages 到 Server Components

为什么 App Router 改变了一切

当 Next.js 在版本 13 中引入 App Router 时,它不仅仅是一个新的文件系统约定——它是对 React 应用结构和渲染方式的根本性重新思考。两年后,随着 Next.js 15 稳定版发布,App Router 已成为默认和推荐的方法。

但思维模式的转变是真实的。如果你来自 Pages Router,Server Actions 会显得陌生,"use client" 指令似乎随意,新的数据获取方式打破了你所有的旧模式。

本指南将消除困惑。我们将涵盖 App Router 的实际工作原理、何时使用 Server 与 Client Components,以及可扩展的数据获取模式。

Next.js App Router 完全指南:从 Pages 到 Server Components 插图

核心概念:React Server Components

App Router 建立在 React Server Components (RSC) 之上。理解 RSC 是理解 App Router 的前提。

Server Components 在服务器端渲染,从不向客户端发送 JavaScript。它们可以:

  • 直接访问数据库、文件系统和 API
  • 在组件级别使用 async/await
  • 导入大型依赖而不影响客户端包大小
  • 不能使用 hooks、浏览器 API 或事件处理程序

Client Components 是你熟悉的传统 React 组件。它们:

  • 在浏览器中运行(也在 SSR 期间)
  • 可以使用 useState、useEffect、事件处理程序
  • 可以访问浏览器 API
  • 需要在文件顶部添加 "use client" 指令

关键点:在 App Router 中,Server Components 是默认的。你选择的是客户端行为,而不是服务器行为。

文件系统约定

app/
├── layout.tsx          # 根布局(始终是 Server Component)
├── page.tsx            # 首页
├── loading.tsx         # 加载 UI(Suspense 边界)
├── error.tsx           # 错误边界(必须是 Client Component)
├── not-found.tsx       # 404 页面
├── blog/
│   ├── layout.tsx      # 嵌套布局
│   ├── page.tsx        # /blog 路由
│   └── [slug]/
│       ├── page.tsx    # /blog/[slug] 路由
│       └── loading.tsx # 动态文章的加载状态
└── api/
    └── users/
        └── route.ts    # API 路由:GET/POST /api/users

特殊文件

文件 用途
page.tsx 路由 UI — 使路由公开可访问
layout.tsx 共享 UI — 在导航间持久存在(状态保留)
loading.tsx 使用 React Suspense 的即时加载 UI
error.tsx 路由段的错误边界
not-found.tsx notFound() 函数渲染
route.ts API 端点 — 不能与 page.tsx 共存
template.tsx 类似布局,但在导航时重新挂载
middleware.ts 在请求前运行(边缘运行时)

数据获取:新方式

忘记 getServerSidePropsgetStaticProps。在 App Router 中,数据获取直接在组件中进行:

// app/blog/[slug]/page.tsx
// 这是一个 Server Component — 默认异步

interface Props {
  params: { slug: string }
}

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 } // ISR:每小时重新验证
  })
  
  if (!res.ok) {
    notFound() // 渲染 not-found.tsx
  }
  
  return res.json()
}

export default async function BlogPost({ params }: Props) {
  const post = await getPost(params.slug) // 直接异步调用!
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

// 可选:在构建时生成静态参数
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  
  return posts.map((post: any) => ({
    slug: post.slug,
  }))
}

fetch() 缓存行为

Next.js 扩展了原生 fetch() API,增加了缓存选项:

// 无限缓存(静态 — 类似旧的 getStaticProps)
fetch(url, { cache: 'force-cache' }) // 默认行为

// 从不缓存(动态 — 类似旧的 getServerSideProps)
fetch(url, { cache: 'no-store' })

// ISR:N 秒后重新验证
fetch(url, { next: { revalidate: 60 } })

// 基于标签的重新验证
fetch(url, { next: { tags: ['posts', 'post-123'] } })

// 按标签重新验证(在 Server Action 或 Route Handler 中)
import { revalidateTag } from 'next/cache'
revalidateTag('posts') // 清除所有标记为 'posts' 的 fetch

Next.js App Router 完全指南:从 Pages 到 Server Components 插图

数据库查询(无需 fetch)

import { db } from '@/lib/database'

export default async function UsersPage() {
  // 直接数据库调用 — 无需 API!
  const users = await db.query('SELECT * FROM users ORDER BY created_at DESC')
  
  return (
    <ul>
      {users.rows.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  )
}

这就是 Server Components 的强大之处——你的数据库凭据永远不会离开服务器。

Server vs Client Components:决策框架

需要交互性(onClick、onChange)?  → Client Component
需要浏览器 API(localStorage、window)? → Client Component
需要 React hooks(useState、useEffect)?   → Client Component
需要基于类的动画?              → Client Component

获取数据?                → Server Component
访问后端资源?  → Server Component
敏感数据(API 密钥)?    → Server Component
大型依赖?           → Server Component(保持包体积小)
SEO 关键内容?         → Server Component

组合 Server 和 Client Components

// app/dashboard/page.tsx — Server Component
import UserStats from './UserStats'         // Server Component
import InteractiveChart from './InteractiveChart' // Client Component

export default async function Dashboard() {
  const stats = await fetchStats() // 服务器端数据获取
  
  return (
    <div>
      <UserStats data={stats} />
      {/* 将可序列化的数据作为 props 传递给 Client Component */}
      <InteractiveChart initialData={stats.chartData} />
    </div>
  )
}
// app/dashboard/InteractiveChart.tsx
'use client' // 必须位于文件顶部

import { useState } from 'react'
import { Chart } from '@/components/Chart'

interface Props {
  initialData: ChartData[]
}

export default function InteractiveChart({ initialData }: Props) {
  const [filter, setFilter] = useState('all')
  
  const filtered = filter === 'all' 
    ? initialData 
    : initialData.filter(d => d.category === filter)
  
  return (
    <div>
      <select onChange={e => setFilter(e.target.value)}>
        <option value="all">全部</option>
        <option value="revenue">收入</option>
        <option value="users">用户</option>
      </select>
      <Chart data={filtered} />
    </div>
  )
}

重要:你不能将 Server Component 导入 Client Component。但你可以将 Server Components 作为 children/props 传递:

// 这种模式有效 — Server Component 作为 children prop
'use client'

export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  
  return (
    <div>
      <button onClick={() => setOpen(!open)}>切换</button>
      {open && children} {/* children 可以是 Server Component */}
    </div>
  )
}

// 在 Server Component 中使用:
<ClientWrapper>
  <ServerComponent />  {/* 这是有效的! */}
</ClientWrapper>

布局和嵌套路由

布局包裹其子组件并在导航间保持状态(不重新挂载):

// app/layout.tsx — 根布局(必需)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body>
        <nav>/* 导航 */</nav>
        <main>{children}</main>
        <footer>/* 页脚 */</footer>
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx — 嵌套布局
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <aside>/* 侧边栏 */</aside>
      <section>{children}</section>
    </div>
  )
}

并行路由

在同一布局中同时渲染多个页面:

app/
└── dashboard/
    ├── layout.tsx
    ├── page.tsx
    ├── @analytics/     # 并行路由插槽
    │   └── page.tsx
    └── @team/          # 并行路由插槽
        └── page.tsx
// app/dashboard/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      {children}
      <div className="widgets">
        {analytics}
        {team}
      </div>
    </div>
  )
}

Next.js App Router 完全指南:从 Pages 到 Server Components 插图

Server Actions:无需 API 路由的表单

Server Actions 允许你直接从表单提交或事件处理程序运行服务器端代码:

// app/contact/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  message: z.string().min(10),
})

export async function submitContact(formData: FormData) {
  const parsed = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })
  
  if (!parsed.success) {
    return { error: '无效的表单数据', issues: parsed.error.issues }
  }
  
  // 直接数据库插入 — 无需 API
  await db.query(
    'INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)',
    [parsed.data.name, parsed.data.email, parsed.data.message]
  )
  
  revalidatePath('/admin/contacts')
  return { success: true }
}
// app/contact/page.tsx — 使用 Server Action 的 Server Component
import { submitContact } from './actions'

export default function ContactPage() {
  return (
    <form action={submitContact}> {/* 直接引用 Server Action */}
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">发送</button>
    </form>
  )
}

使用 useActionState 进行渐进增强:

'use client'

import { useActionState } from 'react'
import { submitContact } from './actions'

export default function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, null)
  
  return (
    <form action={action}>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">消息已发送!</p>}
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={pending}>
        {pending ? '发送中...' : '发送'}
      </button>
    </form>
  )
}

元数据和 SEO

// 静态元数据
export const metadata = {
  title: '博客',
  description: '阅读我们最新的文章',
}

// 动态元数据
export async function generateMetadata({ params }: Props) {
  const post = await getPost(params.slug)
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
    },
  }
}

从 Pages Router 迁移

Pages Router App Router
pages/index.tsx app/page.tsx
pages/_app.tsx app/layout.tsx
pages/_document.tsx app/layout.tsx (HTML shell)
getStaticProps async Server Component
getServerSideProps async Server Component + cache: 'no-store'
getStaticPaths generateStaticParams
pages/api/route.ts app/api/route/route.ts

路由处理程序(API 路由)

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = Number(searchParams.get('page') ?? 1)
  
  const users = await db.getUsers({ page, limit: 20 })
  
  return NextResponse.json({ users, page })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  
  const user = await db.createUser(body)
  
  return NextResponse.json(user, { status: 201 })
}

性能:流式传输和 Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
import RecentOrders from './RecentOrders'
import Analytics from './Analytics'

export default function Dashboard() {
  return (
    <div>
      {/* 快速 — 立即渲染 */}
      <h1>仪表盘</h1>
      
      {/* 每个 Suspense 边界独立流式传输 */}
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile /> {/* 异步 Server Component */}
      </Suspense>
      
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders /> {/* 准备就绪时流式传输 */}
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* 不阻塞其他内容 */}
      </Suspense>
    </div>
  )
}

这种流式传输方法意味着用户逐步看到内容——快速部分立即出现,而较慢的数据获取在后台完成。

常见陷阱

1. "use client" 传播:在文件中添加 "use client" 会使其所有导入也成为 Client Components。将 Client Components 保持在树的叶子节点。

2. 序列化:从 Server 传递到 Client Components 的数据必须是可序列化的(不能有函数,Date 会变成字符串等)。

3. Server Components 中的 Context:React context 在 Server Components 中不起作用。在 Client Component 包装器中使用它。

4. cookies() 和 headers():这些会使路由变为动态。仅在需要时调用它们。

// 这会使整个路由变为动态:
import { cookies } from 'next/headers'
const token = cookies().get('auth-token')

// 更好:从布局或中间件作为 prop 传递

5. Server Component 中的 Client Component:你不能在 Client Components 中导入仅服务器代码(如数据库客户端)——它会泄漏到浏览器包中。

App Router 有陡峭的学习曲线,但一旦 Server/Client Component 思维模式建立起来,构建 Next.js 应用程序将变得非常强大。仅消除大多数数据操作的 API 路由就显著简化了全栈开发。

→ 使用 URL 解析器 解析和分析 URL。