next 精选推荐

App Router vs Pages Router:Next.js 路由架构深度对比与选型指南

HTMLPAGE 团队
16 分钟阅读

全面对比 Next.js App Router 与 Pages Router 的架构差异、性能表现、开发体验,结合实际项目场景分析各自优劣,帮助团队做出明智的技术选型决策。

#Next.js #App Router #Pages Router #React #技术选型

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 RouterApp Router改善
FCP1.8s1.2s-33%
LCP2.5s1.8s-28%
TTI3.2s2.1s-34%
JS Bundle320KB95KB-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 久经考验,概念简单,生态成熟。对于很多项目来说,它仍然是务实的选择。

最终的选择应该基于:

  • 你的团队能力和学习意愿
  • 你的项目需求和约束
  • 你的时间和资源预算

无论选择哪个,记住:路由只是工具,真正重要的是你用它构建的产品


参考资源