为什么需要 App Router?
如果你之前使用 Next.js 的 Pages Router,可能已经习惯了 getServerSideProps、getStaticProps 这些数据获取方式。但 Next.js 13 引入的 App Router 是一次架构级的升级:它基于 React Server Components,允许组件在服务端渲染,同时保持客户端的交互能力。这不仅减少了客户端 JavaScript 体积,还让数据获取和布局复用变得更自然。
然而,新范式也带来了新的心智负担:'use client' 该加在哪里?缓存为什么有时不生效?布局如何嵌套?本文将通过一个博客系统的实战,带你理清这些概念,并分享我在迁移过程中总结的最佳实践。
项目初始化与结构
首先,创建一个新的 Next.js 项目(确保 Node.js 18.17 以上):
npx create-next-app@latest my-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
选择 --app 启用 App Router,--src-dir 将代码放在 src 目录下。初始化后的目录结构如下:
src/
app/
layout.tsx
page.tsx
globals.css
components/
lib/
app 目录是 App Router 的核心,每个文件夹代表一个路由段。layout.tsx 定义共享布局,page.tsx 是路由的入口页面。
步骤一:设计嵌套布局
假设我们的博客有首页、文章列表页和文章详情页。我们希望所有页面共享头部和底部,但文章详情页需要额外的侧边栏。
在 app/layout.tsx 中定义根布局:
// src/app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="min-h-screen flex flex-col">
<header className="border-b p-4">
<nav>我的博客</nav>
</header>
<main className="flex-1 container mx-auto p-4">{children}</main>
<footer className="border-t p-4 text-center">© 2025</footer>
</body>
</html>
);
}
接着,为文章详情页创建嵌套布局。在 app/posts/[slug]/layout.tsx 中:
// src/app/posts/[slug]/layout.tsx
export default function PostLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex gap-8">
<article className="flex-1">{children}</article>
<aside className="w-64 hidden lg:block">
<h3>相关文章</h3>
{/* 侧边栏内容 */}
</aside>
</div>
);
}
这样,只有文章详情页会显示侧边栏,其他页面不受影响。布局可以无限嵌套,且默认是服务端组件。
💡 注意:根布局必须包含 和 标签,且不能是客户端组件(不能加 'use client')。
步骤二:服务端组件与客户端组件
App Router 默认所有组件都是服务端组件(Server Components)。它们可以直接访问后端资源,且不会打包到客户端。但如果你需要状态、事件处理或浏览器 API,就必须使用客户端组件。
规则:在文件顶部添加 'use client' 指令。
例如,一个点赞按钮需要交互:
// src/components/LikeButton.tsx
'use client';
import { useState } from 'react';
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<button
onClick={() => setLikes(likes + 1)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
👍 {likes}
</button>
);
}
然后在服务端组件中引入它:
// src/app/posts/[slug]/page.tsx
import LikeButton from '@/components/LikeButton';
export default function PostPage() {
// 假设从数据库获取文章
const post = { title: 'Hello', content: '...', likes: 42 };
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton initialLikes={post.likes} />
</div>
);
}
最佳实践:尽可能将客户端组件推到叶子节点,以减小客户端 bundle。例如,不要将整个页面标记为客户端组件,而是只把交互部分抽离。
💡 易错点:服务端组件不能使用 useState、useEffect 等 Hook,也不能传递函数给客户端组件(除非是 Server Actions)。
步骤三:数据获取与缓存
App Router 推荐在服务端组件中直接使用 async/await 获取数据。Next.js 扩展了 fetch API,提供了自动缓存和去重。
静态数据获取
默认情况下,fetch 在构建时执行并缓存结果。例如获取文章列表:
// src/app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
// 默认缓存,等同于 force-cache
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
动态数据获取
如果需要实时数据,可以设置 cache: 'no-store':
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});
或者使用增量静态再生(ISR):
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // 每60秒重新验证
});
使用 Server Actions 修改数据
App Router 引入了 Server Actions,允许你在服务端直接处理表单提交,无需创建 API 路由。
// src/app/posts/new/page.tsx
import { redirect } from 'next/navigation';
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
// 模拟保存到数据库
console.log('Creating post:', title);
redirect('/posts');
}
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required className="border p-2" />
<button type="submit" className="bg-blue-500 text-white p-2">
创建
</button>
</form>
);
}
💡 注意:Server Actions 必须定义在服务端组件或单独的文件中,并加上 'use server' 指令。
步骤四:处理动态路由与元数据
对于文章详情页,我们需要根据 slug 获取数据,并动态生成页面标题。
// src/app/posts/[slug]/page.tsx
import { Metadata } from 'next';
async function getPost(slug: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`);
return res.json();
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return { title: post.title, description: post.body.slice(0, 100) };
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return (
<div>
<h1 className="text-2xl font-bold">{post.title}</h1>
<p>{post.body}</p>
</div>
);
}
generateMetadata 会在服务端运行,并自动注入到 中。
常见坑与解决方案
1. 缓存导致数据不更新
在开发模式下,fetch 默认不缓存,但生产环境会缓存。如果你发现数据不更新,检查是否使用了默认缓存。解决方案:使用 revalidate 或 no-store。
2. 客户端组件中无法使用 async/await
客户端组件不能是 async 函数。如果你需要在客户端获取数据,使用 useEffect 或 SWR。
3. 布局与页面之间的数据传递
布局不能直接向页面传递 props。如果需要共享数据,可以使用 React Context(在客户端组件中)或通过 fetch 在布局和页面中分别获取(Next.js 会自动去重)。
4. 动态路由参数类型
在 TypeScript 中,params 和 searchParams 是 Promise 吗?在 Next.js 15 中,它们变成了 Promise,需要 await。例如:
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
请根据你的 Next.js 版本调整。
总结
App Router 通过服务端组件和嵌套布局,让全栈开发更直观。关键点:
- 默认使用服务端组件,仅在需要交互时添加
'use client'。 - 利用
fetch的缓存和revalidate控制数据新鲜度。 - 使用 Server Actions 处理表单,减少 API 路由。
- 动态路由配合
generateMetadata优化 SEO。
下一步,你可以探索并行路由、拦截路由等高级特性,或结合 Prisma 构建全栈应用。
延伸阅读:
- Next.js 官方文档:App Router
- React Server Components 规范
- Vercel 模板:Next.js 博客示例