问题的起源:为什么需要新的路由系统
在 Next.js 9 引入的 Pages Router 中,文件系统即路由——pages/ 目录下的每个文件自动成为一条路由。这种设计简洁直观,但随着应用规模增长,问题逐渐显现:
- 嵌套布局(nested layouts)难以表达:如果多个页面需要共享同一个布局,但布局之间没有父子路由关系,就只能靠
_app.js手动管理,逻辑分散 - 组件默认运行在客户端:页面文件在服务端正确定义,但组件树默认有 JavaScript 交互能力,意味着默认要做大量的客户端打包
- 数据获取模式不统一:
getServerSideProps和getStaticProps是文件级别的特殊函数,混用时行为复杂
这些问题促使 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:子路由的内容作为childrenprop 传入
// 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.tsx 和 error.tsx 边界——如果同目录下没有这些文件,Next.js 会向上查找。
2.4 Loading(加载状态)
loading.tsx 与 React 的 Suspense 边界配合使用。当 loading.tsx 存在时,Next.js 会自动:
- 在服务端渲染页面
- 如果渲染时间超过一定阈值,展示
loading.tsx的 UI - 流式传输(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 组件(展示型组件)天然应该运行在服务端
- 只有真正需要交互(
useState、useEffect、事件处理)的组件才需要明确标注为客户端组件
// 服务端组件:数据获取 + 渲染
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:没有
window、document、localStorage - 不能使用 React 状态和生命周期:
useState、useEffect不存在 - 不能使用事件处理:
onClick、onChange等不存在 - 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 中的实现 |
延展阅读
- Next.js 官方文档 — App Router — 最权威的文档来源
- Next.js 官方博客 — React Server Components — 设计动机
- Lee Robinson — Understanding the Next.js App Router — 深度解析
- Sam Selikoff — Next.js App Router Rendering — 渲染行为详解