App Router vs Pages Router
引言:一个框架,两种哲学
Next.js 13 引入 App Router 时,社区的反应是割裂的。一部分人欢呼"终于等到了",另一部分人则困惑"为什么要改变"。这种分歧不仅是技术偏好的差异,更反映了两种截然不同的 Web 开发哲学。
使用 Next.js 四年,经历了从 Pages Router 到 App Router 的完整迁移过程,我想分享一些真实的体验和思考。这不是一篇告诉你"该用哪个"的文章,而是帮助你理解两者的核心差异,以便做出适合自己项目的选择。
第一部分:架构层面的根本差异
1.1 渲染模型对比
这是两者最本质的区别:
┌─────────────────────────────────────────────────────────────┐
│ Pages Router 渲染模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 请求 → getServerSideProps → 页面组件 → HTML │
│ (服务端) (客户端 hydration) │
│ │
│ 特点: │
│ - 整个页面作为一个单元渲染 │
│ - 服务端数据获取与组件分离(props 传递) │
│ - 页面级别的渲染策略(SSR/SSG/ISR) │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ App Router 渲染模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 请求 → Layout(服务端) → Page(服务端) → Client Components │
│ ↓ ↓ ↓ │
│ HTML Suspense Hydration │
│ │
│ 特点: │
│ - 组件级别的渲染策略(Server/Client) │
│ - 嵌套布局,独立流式传输 │
│ - 数据获取与组件紧密结合 │
│ │
└─────────────────────────────────────────────────────────────┘
1.2 数据获取模式
Pages Router:集中式数据获取
// pages/products/[id].tsx
// 数据获取函数与组件分离
export async function getServerSideProps({ params }) {
const product = await fetchProduct(params.id);
const relatedProducts = await fetchRelatedProducts(params.id);
const reviews = await fetchReviews(params.id);
return {
props: {
product,
relatedProducts,
reviews
}
};
}
// 组件只负责渲染
export default function ProductPage({ product, relatedProducts, reviews }) {
return (
<div>
<ProductDetail product={product} />
<RelatedProducts products={relatedProducts} />
<Reviews reviews={reviews} />
</div>
);
}
// 问题:
// 1. 所有数据必须在页面级别获取
// 2. 无法并行获取子组件数据
// 3. props drilling 或需要状态管理
App Router:分布式数据获取
// app/products/[id]/page.tsx
// 页面组件可以是 async
export default async function ProductPage({ params }) {
// 数据获取在组件内部
const product = await fetchProduct(params.id);
return (
<div>
<ProductDetail product={product} />
{/* 子组件独立获取数据,并行执行 */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
</div>
);
}
// RelatedProducts 和 Reviews 可以各自获取数据
async function RelatedProducts({ productId }) {
const products = await fetchRelatedProducts(productId);
return <div>{/* 渲染 */}</div>;
}
// 优势:
// 1. 数据获取与组件共置(colocation)
// 2. 自动并行获取
// 3. 独立的加载状态
1.3 布局系统
Pages Router:_app.tsx 全局布局
// pages/_app.tsx
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
// pages/dashboard/index.tsx
export default function DashboardPage() {
return <div>Dashboard</div>;
}
// pages/dashboard/settings.tsx
export default function SettingsPage() {
return <div>Settings</div>;
}
// 问题:每次路由切换,整个页面重新渲染
// 即使 Layout 没有变化
App Router:嵌套布局
// app/layout.tsx - 根布局
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx - 仪表盘布局
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <div>Dashboard</div>;
}
// app/dashboard/settings/page.tsx
export default function SettingsPage() {
return <div>Settings</div>;
}
// 优势:dashboard 到 settings 的导航
// 只重新渲染 page.tsx,布局保持状态
第二部分:性能对比
2.1 首屏加载性能
// 场景:电商商品详情页
// Pages Router 的 JS Bundle
// - React runtime: ~40KB
// - Next.js runtime: ~80KB
// - 页面组件(包括所有客户端代码): ~200KB
// - 总计: ~320KB(需要完整 hydration)
// App Router 的优化
// - React runtime: ~40KB(仅需要的部分)
// - 页面组件: 服务端组件无 JS Bundle
// - 客户端组件: 仅交互部分 ~50KB
// - 总计: ~90KB(选择性 hydration)
实际性能数据对比(同一项目):
| 指标 | Pages Router | App Router | 改善 |
|---|---|---|---|
| FCP | 1.8s | 1.2s | -33% |
| LCP | 2.5s | 1.8s | -28% |
| TTI | 3.2s | 2.1s | -34% |
| JS Bundle | 320KB | 95KB | -70% |
2.2 导航性能
// Pages Router:客户端导航
// 1. 获取新页面的 JS bundle
// 2. 执行 getServerSideProps(如果有)
// 3. 渲染新页面
// 4. 完整 hydration
// App Router:流式导航
// 1. 服务端渲染新的 RSC payload
// 2. 流式传输到客户端
// 3. 增量更新 DOM
// 4. 仅 hydrate 客户端组件
// 代码层面的差异
// Pages Router - 预取整个页面
<Link href="/products/1" prefetch={true}>
Product 1
</Link>
// App Router - 预取 RSC payload,更轻量
<Link href="/products/1">
Product 1
</Link>
2.3 缓存策略
// App Router 的多层缓存
// 1. Request Memoization(请求去重)
async function ProductPage({ id }) {
// 这两个调用会自动去重,只发一次请求
const product = await fetchProduct(id);
const sameProduct = await fetchProduct(id);
}
// 2. Data Cache(数据缓存)
async function fetchProduct(id) {
const res = await fetch(`/api/products/${id}`, {
next: {
revalidate: 3600, // 1小时后重新验证
tags: ['product'] // 标签,用于按需重新验证
}
});
return res.json();
}
// 3. Full Route Cache(完整路由缓存)
// 静态路由在构建时渲染并缓存
// 4. Router Cache(客户端路由缓存)
// 已访问的路由在客户端缓存
// Pages Router 的缓存
// 主要依赖 ISR,缓存层级相对简单
export async function getStaticProps() {
return {
props: { /* ... */ },
revalidate: 3600 // ISR
};
}
第三部分:开发体验对比
3.1 代码组织
Pages Router 的文件结构:
pages/
├── _app.tsx
├── _document.tsx
├── index.tsx
├── products/
│ ├── index.tsx
│ └── [id].tsx
└── api/
└── products/
└── [id].ts
components/
├── Layout.tsx
├── Header.tsx
└── ProductCard.tsx
lib/
├── api.ts
└── utils.ts
App Router 的文件结构:
app/
├── layout.tsx
├── page.tsx
├── loading.tsx
├── error.tsx
├── products/
│ ├── page.tsx
│ ├── [id]/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── @modal/ # 并行路由
│ │ └── (.)edit/ # 拦截路由
│ │ └── page.tsx
│ └── _components/ # 私有组件
│ └── ProductCard.tsx
└── api/
└── products/
└── [id]/
└── route.ts
components/ # 共享组件
├── ui/
└── providers/
3.2 心智负担对比
Pages Router 需要理解的概念:
- getStaticProps / getStaticPaths
- getServerSideProps
- getInitialProps(不推荐但存在)
- ISR(Incremental Static Regeneration)
- 客户端数据获取(SWR/React Query)
App Router 需要理解的概念:
- Server Components / Client Components
- 'use client' / 'use server' 指令
- Suspense 和流式渲染
- 四层缓存机制
- 并行路由和拦截路由
- Server Actions
// App Router 常见的困惑点
// 1. 什么时候用 'use client'?
// ❌ 错误理解:需要 hooks 就加
// ✅ 正确理解:需要浏览器 API 或事件处理才加
// 2. Server Components 能做什么?
'use server' // 这是 Server Action,不是 Server Component!
// Server Component 是默认的,不需要声明
export default async function Page() {
// 可以直接 await
const data = await db.query(...);
return <div>{data}</div>;
}
// 3. 数据获取的 revalidate 在哪设置?
// 不在组件里,在 fetch 选项或路由段配置里
export const revalidate = 3600; // 路由段配置
3.3 调试体验
// Pages Router 调试相对简单
// - 服务端代码在 getServerSideProps 中
// - 客户端代码在组件中
// - 边界清晰
// App Router 调试复杂一些
// 1. 服务端错误可能不明显
async function ProductList() {
const products = await fetchProducts(); // 这里报错
// 错误可能只显示在终端,页面可能显示空白或 error.tsx
}
// 2. hydration 错误更常见
// Server/Client 渲染结果不一致会报错
function TimeDisplay() {
// ❌ 会导致 hydration 错误
return <div>{new Date().toLocaleString()}</div>;
}
// ✅ 正确做法
'use client'
function TimeDisplay() {
const [time, setTime] = useState<string>();
useEffect(() => {
setTime(new Date().toLocaleString());
}, []);
return <div>{time ?? 'Loading...'}</div>;
}
第四部分:迁移考量
4.1 从 Pages Router 迁移到 App Router
// 迁移策略:渐进式
// 1. 两者可以共存
// pages/ 和 app/ 可以同时存在
// 相同路由 app/ 优先
// 2. 迁移顺序建议
// - 先迁移静态页面(简单,风险低)
// - 再迁移动态页面
// - 最后迁移复杂交互页面
// 3. 数据获取迁移
// Before (Pages Router)
export async function getServerSideProps() {
const data = await fetchData();
return { props: { data } };
}
export default function Page({ data }) {
return <div>{/* 使用 data */}</div>;
}
// After (App Router)
export default async function Page() {
const data = await fetchData();
return <div>{/* 使用 data */}</div>;
}
// 4. API Routes 迁移
// Before (pages/api/users.ts)
export default function handler(req, res) {
if (req.method === 'GET') {
res.json({ users: [] });
}
}
// After (app/api/users/route.ts)
export async function GET() {
return Response.json({ users: [] });
}
4.2 迁移陷阱
// 陷阱 1:过早使用 'use client'
// 很多人看到 useState/useEffect 就加 'use client'
// 但可能整个组件都可以改为服务端组件
// Before
'use client'
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
return <div>{products.map(...)}</div>;
}
// After - 可能不需要客户端组件
async function ProductList() {
const products = await fetchProducts();
return <div>{products.map(...)}</div>;
}
// 陷阱 2:忽视缓存行为变化
// App Router 默认更积极的缓存
// 可能导致数据不更新的困惑
// 陷阱 3:布局状态丢失
// App Router 布局不会在导航时重新渲染
// 但 Pages Router 的条件渲染可能依赖这个
第五部分:选型决策框架
5.1 选择 Pages Router 的情况
## 适合 Pages Router 的场景
1. **团队经验**
- 团队对 React 基础不够扎实
- 需要快速上手,学习曲线要求低
- 已有大量 Pages Router 项目经验
2. **项目特点**
- 简单的内容网站
- 已有成熟的 Pages Router 项目
- 不需要流式渲染和 Suspense
- 时间紧迫,不想踩新坑
3. **生态依赖**
- 使用的库还不支持 Server Components
- 依赖的 CMS 集成基于 Pages Router
5.2 选择 App Router 的情况
## 适合 App Router 的场景
1. **项目类型**
- 新项目,无历史负担
- 复杂的交互应用
- 对性能有较高要求
- 需要流式渲染体验
2. **团队状态**
- 团队愿意学习新技术
- 有时间消化新概念
- 追求最佳实践
3. **技术需求**
- 需要嵌套布局
- 需要细粒度的加载状态
- 想要更小的 JS Bundle
- 需要 Server Actions
5.3 决策流程图
┌─────────────────┐
│ 新项目还是 │
│ 现有项目? │
└────────┬────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ 新项目 │ │ 现有项目 │
└────┬─────┘ └────┬─────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ 团队有 RSC │ │ 有迁移预算和 │
│ 学习意愿? │ │ 时间吗? │
└───────┬────────┘ └────────┬─────────┘
│ │
┌──────┴──────┐ ┌───────┴───────┐
▼ ▼ ▼ ▼
是的 不是 是的 不是
│ │ │ │
▼ ▼ ▼ ▼
App Router Pages Router 渐进迁移 保持 Pages
到 App Router Router
第六部分:混合使用策略
6.1 共存方案
// next.config.js
module.exports = {
// 两个路由可以共存
// app/ 目录启用 App Router
// pages/ 目录保持 Pages Router
};
// 路由优先级:
// 1. app/ 中的路由优先
// 2. pages/ 中的路由作为后备
// 推荐的共存策略:
// - 新功能用 App Router
// - 现有功能保持 Pages Router
// - 逐步迁移
6.2 渐进迁移计划
## 12 周迁移计划示例
**第 1-2 周:准备阶段**
- 升级 Next.js 到最新版本
- 升级依赖,检查兼容性
- 团队学习 RSC 概念
**第 3-4 周:基础设施迁移**
- 创建 app/ 目录结构
- 迁移根布局
- 设置错误边界
**第 5-8 周:页面迁移**
- 按优先级迁移页面
- 从简单静态页面开始
- 逐步迁移动态页面
**第 9-10 周:API 迁移**
- 迁移 API Routes
- 评估是否使用 Server Actions
**第 11-12 周:清理和优化**
- 删除 pages/ 目录
- 性能优化
- 文档更新
结语:没有银弹,只有权衡
App Router 代表了 React 生态的发展方向——Server Components、流式渲染、更小的客户端 Bundle。但"更新"不等于"更好",至少不是在所有场景下。
Pages Router 久经考验,概念简单,生态成熟。对于很多项目来说,它仍然是务实的选择。
最终的选择应该基于:
- 你的团队能力和学习意愿
- 你的项目需求和约束
- 你的时间和资源预算
无论选择哪个,记住:路由只是工具,真正重要的是你用它构建的产品。


