引言
Next.js 15 引入了革命性的 App Router 架构,作为 Next.js 成立以来最重要的架构变革,它带来了 React Server Components、流式渲染、嵌套布局等强大特性。然而,新架构也伴随着陡峭的学习曲线和不少"坑"。本文将结合实际项目经验,系统性地梳理 App Router 的核心差异与避坑策略,帮助开发者高效完成迁移。
Pages Router vs App Router 核心差异
目录结构与路由机制
Pages Router 使用文件系统路由,文件即路由:
pages/
├── index.tsx → /
├── about.tsx → /about
├── blog/[id].tsx → /blog/:id
└── api/
└── users.ts → /api/users
App Router 同样基于文件系统,但使用目录约定:
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 是默认的,这意味着:
// 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>;
}
布局系统对比
// 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 最容易出错的地方。记住这个决策树:
组件需要:
├── 访问服务器资源(数据库、文件系统)? → Server Component
├── 使用 React hooks(useState, useEffect)? → Client Component
├── 使用浏览器 API(window, document)? → Client Component
└── 处理用户交互(onClick, onChange)? → Client Component
常见错误 #1:Server Component 使用 hooks
// ❌ 错误:在 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
// ❌ 错误: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 的导入
// ❌ 错误: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:
// ✅ 正确:传递可序列化数据
// 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 中使用
// ❌ 错误: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 有四层缓存,理解它们至关重要:
┌─────────────────────────────────────────────────────────┐
│ Full Route Cache │ ← 整页缓存(生产环境)
├─────────────────────────────────────────────────────────┤
│ Router Cache │ ← 客户端内存缓存
├─────────────────────────────────────────────────────────┤
│ Data Cache │ ← 服务端请求缓存
├─────────────────────────────────────────────────────────┤
│ Instruction Cache │ ← 最小缓存单元
└─────────────────────────────────────────────────────────┘
常见错误 #5:忽视 Data Cache 默认行为
// ❌ 意外缓存: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 和动态路由
// ❌ 错误: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:客户端缓存失效问题
// ❌ 错误:使用 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
};
缓存失效策略
// 方法 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:表单处理的血泪史
// ❌ 错误: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 访问
// ❌ 错误:在 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:动态元数据处理
// ❌ 错误: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:图片优化配置
// ❌ 错误:忽略 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',
},
],
},
};
升级建议总结
迁移检查清单
组件边界梳理
- 识别所有使用 hooks 的组件 → 添加
'use client' - 识别所有使用浏览器 API 的组件 → 添加
'use client' - 提取纯展示组件 → 改为 Server Component
- 识别所有使用 hooks 的组件 → 添加
数据获取重构
- 移除
getStaticProps/getServerSideProps→ 改用 async Server Component - 评估 fetch 缓存策略 → 设置适当的
cache和revalidate - 整理 API Routes → 考虑迁移到 Server Actions
- 移除
布局迁移
-
_app.tsx→app/layout.tsx -
_document.tsx→app/layout.tsx(使用html和body标签) - 评估
_error.tsx→error.tsx
-
路由和导航
-
router.push()→ 检查是否需要router.refresh() -
router.replace()→redirect()(Server Component) -
useRouter→ 确认是否在 Client Component 中使用
-
中间件检查
-
pages/middleware.ts→middleware.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 中使用。
调试技巧
// 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 服务端渲染的核心原理。