Next.js App Router:文件约定与路由架构

深入解析 Next.js App Router 的核心设计:文件约定背后的理念、Layout 与 Template 的本质区别、Route Groups 和 Parallel Routes 的路由模型,以及为什么服务端组件是设计选择而非简单功能。

问题的起源:为什么需要新的路由系统

在 Next.js 9 引入的 Pages Router 中,文件系统即路由——pages/ 目录下的每个文件自动成为一条路由。这种设计简洁直观,但随着应用规模增长,问题逐渐显现:

  • 嵌套布局(nested layouts)难以表达:如果多个页面需要共享同一个布局,但布局之间没有父子路由关系,就只能靠 _app.js 手动管理,逻辑分散
  • 组件默认运行在客户端:页面文件在服务端正确定义,但组件树默认有 JavaScript 交互能力,意味着默认要做大量的客户端打包
  • 数据获取模式不统一getServerSidePropsgetStaticProps 是文件级别的特殊函数,混用时行为复杂

这些问题促使 Vercel 设计了全新的 App Router,在 Next.js 13 中正式发布,并成为 Next.js 14+ 的默认方案。


一、App Router vs Pages Router:本质区别

1.1 路由结构

Pages Router 的路由是扁平的:

pages/
├── index.js          → /
├── about.js          → /about
└── blog/
    ├── index.js      → /blog
    └── [id].js       → /blog/:id

App Router 引入了一层 Layer 概念,路由树可以包含嵌套的布局:

app/
├── layout.js         → 根布局,所有页面共享
├── page.js           → /
├── about/
│   └── page.js       → /about
└── blog/
    ├── layout.js     → /blog 及其子路由的共享布局
    ├── page.js       → /blog
    └── [id]/
        └── page.js   → /blog/:id

1.2 服务端默认(Server Components)

这是两者最核心的差异。Pages Router 中,默认情况下所有页面组件都是客户端组件(虽然可以标注 getServerSideProps 让它们在服务端执行数据获取)。App Router 则反了过来:默认所有组件都是服务端组件,只有在明确标注 'use client' 时才是客户端组件。

这个设计选择带来的实际影响:

  • 服务端组件可以直接执行数据库查询、文件系统访问、敏感密钥操作,无需通过 API Routes
  • 服务端组件的产物(HTML)到达客户端时已经是渲染结果,不需要额外的客户端 JavaScript 来生成
  • 组件树中可以有大量不需要交互的 UI 节点,它们不会增加 bundle 体积
// app/page.tsx — 默认是服务端组件
import { db } from '@/lib/db';
import { cache } from '@/lib/cache';

// 直接在服务端查询数据库,不需要 API
async function getPost(id: string) {
  return db.post.findUnique({ where: { id } });
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* LikeButton 需要交互,所以标注为客户端组件 */}
      <LikeButton postId={post.id} />
    </article>
  );
}

1.3 布局模型的本质差异

Pages Router 中,所有页面共享同一个根布局(_app.tsx),如果需要给特定路由组设置不同的布局,必须手动管理状态或使用 HOC。

App Router 的布局模型是基于树形结构的:每个路由段(route segment)可以有自己的 layout.js,子路由自动嵌套在父布局内。

// app/dashboard/layout.tsx
// /dashboard 下所有页面都使用这个布局
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

// app/dashboard/page.tsx — 自动嵌套在 DashboardLayout 内
export default function DashboardPage() {
  return <h1>Dashboard Home</h1>;
}

// URL: /dashboard → <DashboardLayout><DashboardPage /></DashboardLayout>

二、Layout、Template、Page、Loading、Error:文件约定详解

2.1 Layout(布局)

layout.js 文件定义了一个路由段的 UI 外壳。关键特性:

  • 持久性:导航时不会重新挂载,状态保持(这是与 Template 的核心区别)
  • 嵌套性:子路由的 layout 自动嵌套在父 layout 中
  • 接收 children:子路由的内容作为 children prop 传入
// app/users/layout.tsx
export default function UsersLayout({ children }) {
  const [selectedId, setSelectedId] = useState(null);
  // selectedId 在页面导航时保持,不会重置

  return (
    <div>
      <UserList selected={selectedId} onSelect={setSelectedId} />
      <div>{children}</div>
    </div>
  );
}

2.2 Template(模板)

template.js 在概念上与 Layout 相似,但有一个关键区别:每次导航时都会重新创建(remount)。这听起来像缺点,但在某些场景下是优点:

  • 当你需要每次路由变化时重置动画状态
  • 当你需要每次路由变化时重新初始化表单(与 useForm 配合)
  • 当你需要利用 useEffect 在路由变化时触发某些行为
// app/blog/[slug]/template.tsx
// 每次进入不同的 blog post 时,这个 template 会重新挂载
export default function BlogPostTemplate({ children }) {
  const { resetForm } = useBlogForm();

  useEffect(() => {
    resetForm();
  }, [resetForm]);

  return <AnimatedArticle>{children}</AnimatedArticle>;
}

2.3 Page(页面)

page.js 是路由的叶子节点,代表一个具体的 URL。Page 同时也是默认的 loading.tsxerror.tsx 边界——如果同目录下没有这些文件,Next.js 会向上查找。

2.4 Loading(加载状态)

loading.tsx 与 React 的 Suspense 边界配合使用。当 loading.tsx 存在时,Next.js 会自动:

  1. 在服务端渲染页面
  2. 如果渲染时间超过一定阈值,展示 loading.tsx 的 UI
  3. 流式传输(streaming)完整的页面内容
// app/posts/loading.tsx
export default function PostsLoading() {
  return (
    <div className="posts-grid">
      {Array.from({ length: 6 }).map((_, i) => (
        <PostSkeleton key={i} />
      ))}
    </div>
  );
}

// app/posts/page.tsx
import { Suspense } from 'react';
import { getPosts } from '@/lib/posts';

async function PostsPage() {
  const posts = await getPosts(); // 可能耗时较长

  return <PostsGrid posts={posts} />;
}

export default PostsPage; // Next.js 会自动给这个组件包裹 Suspense 边界

2.5 Error(错误处理)

error.tsx 创建了一个 React Error Boundary,专门处理该路由段内的组件错误。特性:

  • 支持 useEffect 风格的 error 恢复
  • 错误在客户端和服务端分别有独立的 Error Boundary
  • 可以用 reset() 函数让用户尝试恢复
// app/posts/error.tsx
'use client';

export default function PostsError({ error, reset }) {
  return (
    <div>
      <h2>加载文章失败</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

三、Route Groups 和 Parallel Routes

3.1 Route Groups(路由组)

圆括号 (folderName) 语法将文件夹组织起来,不影响 URL 路径。这是组织代码结构的工具,而不是路由结构的一部分。

app/
├── (marketing)/
│   ├── layout.tsx      → 不添加 URL 段
│   ├── page.tsx        → /
│   └── about/
│       └── page.tsx   → /about
└── (app)/
    ├── layout.tsx      → 不添加 URL 段
    ├── dashboard/
    │   └── page.tsx    → /dashboard
    └── settings/
        └── page.tsx    → /settings

Route Groups 的典型用途:

  • 组织不同布局的页面:marketing 页面用一套布局,app 内部页面用另一套
  • 中间件匹配:可以对 Route Group 应用中间件,实现不同组别不同的认证逻辑

3.2 Parallel Routes(并行路由)

Parallel Routes 使用 @folderName 语法,允许同一个 URL 同时渲染多个(通常是独立的)页面,这些页面可以是完全不同的布局。例如,管理后台常见的 tab 页面:

// app/@analytics/
// 并行渲染 analytics tab 的内容
export default function AnalyticsTab() {
  return <AnalyticsDashboard />;
}

// app/@analytics/page.tsx → /analytics(并行)
// app/layout.tsx
export default function Layout({ children }) {
  return (
    <div>
      <Header />
      {/* @analytics 和 children 是并行渲染的 */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsTab />
      </Suspense>
      <main>{children}</main>
    </div>
  );
}

3.3 Intercepting Routes(拦截路由)

配合 Parallel Routes,拦截路由允许实现 Instagram 风格的模态路由——点击列表项时 URL 变化,但模态框覆盖在列表页上,而不是替换它。

// app/@feed/
// feed 是"被拦截"的路由
export default function Feed() {
  return (
    <ul>
      <li><Link href="/photo/1">Photo 1</Link></li>
      <li><Link href="/photo/2">Photo 2</Link></li>
    </ul>
  );
}

// app/photo/[id]/
// photo/[id] 页面既可以作为独立页面 /photo/1,也可以在 @feed 中作为模态框被拦截渲染
export default function PhotoModal({ params }) {
  return (
    <dialog open>
      <img src={`/photos/${params.id}.jpg`} alt="" />
    </dialog>
  );
}

实现拦截的核心逻辑:当用户从 @feed 内导航到 /photo/1 时,Next.js 在同一布局内渲染 photo/[id] 作为模态框,而不是完全导航。当用户直接访问 /photo/1 时,则正常渲染独立的页面。


四、服务端组件默认:为什么是设计选择而非简单功能

理解为什么 Next.js 团队选择"服务端组件默认"而非"可选服务端组件",需要理解这个决定对整个应用架构的影响。

4.1 服务端组件优先(Server-First)的思维模型

传统 React 开发中,"服务端"和"客户端"是分裂的:要么通过 SSR 做服务端渲染(只是 HTML),要么通过 API 获取数据(仍然是客户端渲染内容)。

服务端组件优先改变了这个模型:组件可以选择它的执行环境,但默认在服务端。这意味着:

  • 数据获取离数据源更近,减少网络往返
  • 大量的 UI 组件(展示型组件)天然应该运行在服务端
  • 只有真正需要交互(useStateuseEffect、事件处理)的组件才需要明确标注为客户端组件
// 服务端组件:数据获取 + 渲染
async function ProductList() {
  const products = await db.product.findMany(); // 直接查询,离数据源近

  return (
    <ul>
      {products.map(p => (
        <ProductItem key={p.id} product={p} />
      ))}
    </ul>
  );
}

// 客户端组件:明确需要交互
'use client';
function AddToCartButton({ productId }) {
  const [quantity, setQuantity] = useState(1);

  return (
    <button onClick={() => addToCart(productId, quantity)}>
      加入购物车
    </button>
  );
}

4.2 产物视角:更小的客户端 Bundle

如果一个页面有 100 个组件,其中只有 5 个需要交互能力,在 Pages Router 中这 100 个组件都会被包含在客户端 bundle 中(即使大部分只是展示)。在 App Router 中,95 个纯展示组件可以保持为服务端组件,永远不会进入客户端 bundle。

这个设计选择让 Next.js 应用默认有更好的性能表现,而不是需要开发者做优化才有。

4.3 服务端组件的限制(必须理解)

服务端组件有明确的限制,这些限制定义了它的使用边界:

  • 不能使用浏览器 API:没有 windowdocumentlocalStorage
  • 不能使用 React 状态和生命周期useStateuseEffect 不存在
  • 不能使用事件处理onClickonChange 等不存在
  • Props 必须是可序列化的:函数、类实例等不能作为 props 传递(但可以传递组件本身作为 slot)

理解这些限制后,服务端组件的"不能做什么"反而成了很好的约束提示——你几乎不需要主动记住哪些 API 是客户端专属,因为如果用了,TypeScript 或运行时就会告诉你。


五、路由段配置:metadata 和 generateStaticParams

5.1 静态元数据

每个 page 可以导出 metadata 对象来自定义 <head> 内容:

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: '文章标题',
  description: '这是文章的描述',
  openGraph: {
    title: 'OG 标题',
    images: ['/og-image.jpg'],
  },
};

export default function Page() { /* ... */ }

5.2 动态路由段

对于 [slug] 这样的动态段,可以导出 generateStaticParams 来指定预渲染的路径:

// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await db.post.findMany();
  return posts.map(post => ({ slug: post.slug }));
}

export default function PostPage({ params }: { params: { slug: string } }) {
  return <div>{params.slug}</div>;
}

六、面试高频问题

Q1: Layout 和 Template 的核心区别是什么?

答案是 remount 行为。Layout 在路由切换时保持挂载(状态保留),Template 每次路由切换都会重新创建(状态重置)。实践中,Template 适合需要每次进入都重新初始化的 UI(如动画重置、表单重置)。

Q2: 什么时候用 Route Groups?

Route Groups 不影响 URL 结构,只影响文件组织。当你有多套布局系统(例如 marketing 站点和 app 内页),或者想对特定路由组应用中间件时,Route Groups 是合适的工具。

Q3: Parallel Routes 和 Intercepting Routes 的关系?

Parallel Routes 定义了同一时刻在布局中并行渲染的多个"槽位"(slot),Intercepting Routes 定义了当一个槽位内的路由被导航时的行为(是拦截到当前页的模态框,还是完全替换导航)。

Q4: 服务端组件能否看到 cookie 或 session?

能。服务端组件运行在服务端,有完整的请求上下文,可以访问 cookies()headers() 等,也可以在服务端执行数据库查询获取用户 session 对应的数据。


七、与其他主题的关联

关联主题 关系
nextjs-rendering-strategies App Router 的渲染策略与 Pages Router 不同:服务端组件默认 ssr,客户端组件默认 csr
nextjs-server-actions Server Actions 只能在服务端组件或客户端组件的事件处理中调用
nextjs-caching 服务端组件的查询结果会被 Data Cache 缓存,layout 持久化影响缓存行为
react-ecosystem/react-server-components App Router 是 RSC 规范在 Next.js 中的实现

延展阅读