返回首页

Next.js 15 App Router 实战避坑指南

引言

Next.js 15 引入了革命性的 App Router 架构,作为 Next.js 成立以来最重要的架构变革,它带来了 React Server Components、流式渲染、嵌套布局等强大特性。然而,新架构也伴随着陡峭的学习曲线和不少"坑"。本文将结合实际项目经验,系统性地梳理 App Router 的核心差异与避坑策略,帮助开发者高效完成迁移。

Pages Router vs App Router 核心差异

目录结构与路由机制

Pages Router 使用文件系统路由,文件即路由:

text
pages/
├── index.tsx          → /
├── about.tsx          → /about
├── blog/[id].tsx      → /blog/:id
└── api/
    └── users.ts       → /api/users

App Router 同样基于文件系统,但使用目录约定:

text
app/
├── page.tsx           → /
├── about/
│   └── page.tsx       → /about
├── blog/
│   └── [id]/
│       └── page.tsx   → /blog/:id
└── api/
    └── users/
        └── route.ts   → /api/users

关键差异:App Router 中,page.tsx 是页面入口,而 layout.tsx 定义共享布局,支持嵌套。这使得布局复用比 Pages Router 更灵活。

服务端默认行为

特性 Pages Router App Router
默认渲染模式 客户端水合 (CSR) Server Component
API Routes pages/api/ app/*/route.ts
数据获取 getServerSideProps async Server Component
布局 _app.tsx 全局 layout.tsx 嵌套式

App Router 的 Server Component 是默认的,这意味着:

javascript
// app/page.tsx - 默认是 Server Component
// 无需 'use client',可以直接访问数据库
import { db } from '@/lib/db';

export default async function Page() {
  const posts = await db.post.findMany(); // ✅ 直接查询
  return <div>{posts.length} posts</div>;
}

布局系统对比

javascript
// Pages Router: 全局 _app.tsx
export default function App({ Component, pageProps }) {
  return <Layout><Component {...pageProps} /></Layout>;
}

// App Router: 嵌套 layout.tsx
// app/layout.tsx - 根布局
export default function RootLayout({ children }) {
  return (
    <html>
      <body><Header /><main>{children}</main><Footer /></body>
    </html>
  );
}

// app/blog/layout.tsx - 博客布局(嵌套)
export default function BlogLayout({ children }) {
  return <aside>{children}</aside>; // 继承 rootLayout + 博客侧边栏
}

Server Component vs Client Component 避坑

边界划分:核心原则

这是 App Router 最容易出错的地方。记住这个决策树:

text
组件需要:
├── 访问服务器资源(数据库、文件系统)? → Server Component
├── 使用 React hooks(useState, useEffect)? → Client Component
├── 使用浏览器 API(window, document)? → Client Component
└── 处理用户交互(onClick, onChange)? → Client Component

常见错误 #1:Server Component 使用 hooks

javascript
// ❌ 错误:在 Server Component 中使用 useState
// app/Counter.tsx
import { useState } from 'react'; // 运行时依赖,Server Component 禁止

export default function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount); // 运行时错误!
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// ✅ 正确:添加 'use client'
'use client';

import { useState } from 'react';

export default function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

常见错误 #2:Client Component 导入 Server Component

javascript
// ❌ 错误:Client Component 不能渲染 Server Component
'use client';

import { ServerComponent } from './ServerComponent'; // 编译错误!

export function Parent() {
  return <ServerComponent />; // ❌ 不允许
}

// ✅ 正确:Server Component 渲染 Client Component
// app/ServerParent.tsx (Server Component)
import { ClientChild } from './ClientChild';

export default function ServerParent() {
  return <ClientChild />; // ✅ 正确:Server → Client
}

// ❌ 错误:Server Component 作为 prop 传入 Client Component
export default function ServerParent() {
  const element = <ServerComponent />; // 这也会导致错误!
  return <ClientComponent comp={element} />;
}

常见错误 #3:混淆 Server 和 Client 的导入

javascript
// ❌ 错误:Server Component 内导入仅限客户端使用的库
// app/Dashboard.tsx
import moment from 'moment'; // heavy bundle impact
import { format } from 'date-fns'; // ✅ 推荐:轻量级

// ✅ 正确:使用 Server Component 可以直接导入
// 但要注意:这些库不会打包到客户端!
import { db } from '@/lib/db'; // Server-only
import fs from 'fs'; // Server-only

最佳实践:Props 穿越边界

如果必须将 Server Component 的数据传递给 Client Component,使用 serializable props

javascript
// ✅ 正确:传递可序列化数据
// app/UserList.tsx (Server Component)
import { UserCard } from './UserCard'; // Client Component

export default async function UserList() {
  const users = await db.user.findMany(); // 获取数据

  return (
    <div>
      {users.map(user => (
        // user 是普通对象,可序列化
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

// app/UserCard.tsx (Client Component)
'use client';

export function UserCard({ user }: { user: { id: string; name: string } }) {
  const [expanded, setExpanded] = useState(false);
  return <div onClick={() => setExpanded(!expanded)}>{user.name}</div>;
}

常见错误 #4:Context 在 Server Component 中使用

javascript
// ❌ 错误:Context 是客户端特性
import { ThemeContext } from './ThemeContext';

export default function ServerPage() {
  const theme = useContext(ThemeContext); // ❌ useContext 是 hooks
  return <div>{theme}</div>;
}

// ✅ 正确:Context 只在 Client Component 中使用
// app/ThemeProvider.tsx
'use client';

import { createContext } from 'react';

export const ThemeContext = createContext('light');

export function ThemeProvider({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  );
}

// app/layout.tsx
import { ThemeProvider } from './ThemeProvider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

缓存机制常见问题

Next.js 15 缓存层级

Next.js 15 有四层缓存,理解它们至关重要:

text
┌─────────────────────────────────────────────────────────┐
│                    Full Route Cache                     │ ← 整页缓存(生产环境)
├─────────────────────────────────────────────────────────┤
│                    Router Cache                          │ ← 客户端内存缓存
├─────────────────────────────────────────────────────────┤
│                    Data Cache                            │ ← 服务端请求缓存
├─────────────────────────────────────────────────────────┤
│                    Instruction Cache                     │ ← 最小缓存单元
└─────────────────────────────────────────────────────────┘

常见错误 #5:忽视 Data Cache 默认行为

javascript
// ❌ 意外缓存:fetch 默认缓存
async function getData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache' // 默认值!
  });
  return res.json();
}

// ✅ 明确不缓存:重新验证每请求
async function getFreshData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store' // 每次请求重新获取
  });
  return res.json();
}

// ✅ 使用 next.js 提供的 revalidate
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 } // 每小时重新验证
  });
  return res.json();
}

常见错误 #6:混用 generateStaticParams 和动态路由

javascript
// ❌ 错误:generateStaticParams 与动态数据
export async function generateStaticParams() {
  // 这会在构建时获取所有 posts
  const posts = await db.post.findMany();
  return posts.map(post => ({ id: post.id }));
}

export default async function PostPage({ params }) {
  // 如果数据在 build 后变化,页面内容会过时
  const post = await db.post.findUnique({ where: { id: params.id } });
  return <div>{post.title}</div>;
}

// ✅ 正确:使用 revalidate 保持新鲜
export const revalidate = 3600; // 或使用 dynamicParams = true

常见错误 #7:客户端缓存失效问题

javascript
// ❌ 错误:使用 router.push 后数据不更新
'use client';

import { useRouter } from 'next/navigation';

export function EditButton({ id }) {
  const router = useRouter();

  const handleUpdate = async () => {
    await updatePost(id, { title: 'New Title' });
    router.push(`/post/${id}`); // 可能显示旧数据
  };

  return <button onClick={handleUpdate}>Update</button>;
}

// ✅ 正确:使用 router.refresh() 强制重新获取
const handleUpdate = async () => {
  await updatePost(id, { title: 'New Title' });
  router.refresh(); // 触发服务端重新渲染当前 segment
};

缓存失效策略

javascript
// 方法 1: revalidatePath - 失效整个路由缓存
import { revalidatePath } from 'next/cache';

await updatePost(id, data);
revalidatePath('/blog'); // 刷新 /blog 页面

// 方法 2: revalidateTag - 失效带 tag 的缓存
import { revalidateTag } from 'next/cache';

fetch(url, { next: { tags: ['posts'] } });
revalidateTag('posts'); // 失效所有标记为 'posts' 的缓存

// 方法 3: router.refresh() - 客户端触发服务端重渲染
'use client';
import { useRouter } from 'next/navigation';

const router = useRouter();
router.refresh(); // 只刷新当前 segment

实际踩坑案例

案例 1:表单处理的血泪史

javascript
// ❌ 错误:Server Action 在非 'use server' 文件中使用
// app/actions.ts
export async function submitForm(formData: FormData) {
  // 这个文件没有 'use server' 标记
  await db.form.create({ data: Object.fromEntries(formData) });
  redirect('/success'); // 可能在客户端执行时出错
}

// ❌ 在 Client Component 中调用
'use client';
import { submitForm } from './actions';

export function ContactForm() {
  return (
    <form action={submitForm}>
      <input name="email" />
      <button type="submit">Submit</button>
    </form>
  );
}

// ✅ 正确:Server Action 必须在 'use server' 文件中
// app/actions.ts
'use server';

export async function submitForm(formData: FormData) {
  await db.form.create({ data: Object.fromEntries(formData) });
  redirect('/success'); // 现在 redirect 正常工作
}

案例 2:Cookie 和 Header 访问

javascript
// ❌ 错误:在 Client Component 中访问 cookies/headers
'use client';
import { cookies } from 'next/headers';

export function UserBadge() {
  const token = cookies().get('token'); // ❌ 运行时错误!
  return <div>{token ? 'Logged in' : 'Guest'}</div>;
}

// ✅ 正确:Server Component 访问
// app/components/UserBadge.tsx
import { cookies } from 'next/headers';

export default async function UserBadge() {
  const cookieStore = cookies();
  const token = cookieStore.get('token');
  return <div>{token ? 'Logged in' : 'Guest'}</div>;
}

// ✅ 或者通过 props 传递
// app/layout.tsx (Server Component)
import { cookies } from 'next/headers';

export default async function RootLayout({ children }) {
  const token = cookies().get('token');

  return (
    <html>
      <body>
        <UserProvider token={token?.value}>
          {children}
        </UserProvider>
      </body>
    </html>
  );
}

案例 3:动态元数据处理

javascript
// ❌ 错误:generateMetadata 在异步 Server Component 中使用不当
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);

  // ❌ 这个 metadata 函数不能是异步的
  export const metadata = {
    title: product.name, // ❌ 可能 undefined if product not loaded
  };

  return <ProductDetail product={product} />;
}

// ✅ 正确:使用 generateMetadata 异步函数
import { Metadata } from 'next';

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const product = await getProduct(params.id);

  if (!product) {
    return { title: 'Product Not Found' };
  }

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: [product.imageUrl],
    },
  };
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return <ProductDetail product={product} />;
}

案例 4:图片优化配置

javascript
// ❌ 错误:忽略 next/image 的域配置
// next.config.js
module.exports = {
  images: {
    // ❌ 缺少 domains 或 remotePatterns
    remotePatterns: [], // 空配置
  },
};

// ❌ 组件中使用
import Image from 'next/image';

export function Avatar({ src }) {
  return <Image src={src} alt="avatar" width={100} height={100} />;
  // Error: Invalid src prop
}

// ✅ 正确:配置信任的域
// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.amazonaws.com',
      },
      {
        protocol: 'https',
        hostname: 'avatars.githubusercontent.com',
      },
    ],
  },
};

升级建议总结

迁移检查清单

  1. 组件边界梳理

    • 识别所有使用 hooks 的组件 → 添加 'use client'
    • 识别所有使用浏览器 API 的组件 → 添加 'use client'
    • 提取纯展示组件 → 改为 Server Component
  2. 数据获取重构

    • 移除 getStaticProps/getServerSideProps → 改用 async Server Component
    • 评估 fetch 缓存策略 → 设置适当的 cacherevalidate
    • 整理 API Routes → 考虑迁移到 Server Actions
  3. 布局迁移

    • _app.tsxapp/layout.tsx
    • _document.tsxapp/layout.tsx (使用 htmlbody 标签)
    • 评估 _error.tsxerror.tsx
  4. 路由和导航

    • router.push() → 检查是否需要 router.refresh()
    • router.replace()redirect() (Server Component)
    • useRouter → 确认是否在 Client Component 中使用
  5. 中间件检查

    • pages/middleware.tsmiddleware.ts (根目录)
    • 检查 matcher 配置

性能优化建议

场景 推荐方案
大量静态内容 Server Component + generateStaticParams
高度交互 UI Client Component + Suspense 边界
频繁变化数据 revalidate + ISR
用户专属数据 Server Component + cookie 读取
实时数据 Client Component + SWR/React Query

避坑核心原则

原则 1:默认使用 Server Component,只在必要时使用 'use client'

原则 2:Server Component 可以渲染 Client Component,反之不行。

原则 3:Props 只能传递可序列化数据。

原则 4:缓存策略要明确,避免依赖默认行为。

原则 5:Hooks 和浏览器 API 只能在 Client Component 中使用。

调试技巧

javascript
// 1. 查看 RSC Payload(在 DevTools Network 中)
// 响应类型为 'text/x-component'

// 2. 使用 React DevTools
// Client/Server Component 有不同图标标识

// 3. 编译时错误提示
// Next.js 15 会明确指出边界违规

// 4. 缓存调试
import { cookies, headers } from 'next/headers';

export default async function DebugPage() {
  const cookieStore = cookies();
  const headerStore = headers();

  return (
    <pre>
      {JSON.stringify({
        cookies: cookieStore.getAll(),
        headers: Object.fromEntries(headerStore.entries()),
      }, null, 2)}
    </pre>
  );
}

结语

App Router 代表了 Next.js 的未来方向,其与 React Server Components 的深度整合带来了前所未有的开发体验和性能潜力。但迁移过程确实需要开发者转变思维,从"客户端优先"切换到"服务端优先"的思考模式。

本文梳理的避坑指南涵盖了迁移过程中最常见的问题类型。只要遵循组件边界划分原则、明确缓存策略、充分利用 Server Component 的优势,你就能充分发挥 App Router 的威力,构建出高性能、易维护的现代 Web 应用。

祝迁移顺利!


相关阅读:本文是 Next.js 15 系列的一部分,建议配合 《从零构建 React 19 SSR 应用:Streaming + RSC 实战》 一起阅读,深入理解 React 服务端渲染的核心原理。