动态路由、路由分组与并行路由
第3章:动态路由、路由分组与并行路由
URL 中的可变部分、不影响 URL 的目录组织、同一页面中的多区域并行渲染——这三个路由特性组合使用,可以构建出极其灵活且性能卓越的路由结构。
本章核心问题:如何处理 URL 中的动态参数?如何在不影响 URL 的前提下组织布局?如何让页面的多个区域独立加载?
读完本章你将理解:
[slug]、[...slug]、[[...slug]]三种动态路由段的区别与适用场景- 路由分组
(group)如何让不同功能区域拥有独立布局 - 并行路由
@slot的独立加载、独立错误处理与default.tsx的回退机制
Level 1 · 你需要知道的(1-3年经验)
动态路由段:参数化 URL
静态路由解决了已知路径的问题。但现实中,博客文章的 slug、用户的 ID、商品的 SKU——这些值在构建时是未知的,URL 需要能够匹配任意值。这就是动态路由段的用途。
[slug] — 单一动态段
在文件夹名中使用方括号,该段即成为动态参数:
app/
└── blog/
└── [slug]/
└── page.tsx # 匹配 /blog/hello-world、/blog/my-post 等
参数通过 params prop 访问。在 Next.js 15 中,params 是 Promise:
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.posts.findFirst({
where: { slug, published: true },
});
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<PostBody content={post.content} />
</article>
);
}
动态段可以嵌套,也可以有多个:
app/
└── shop/
└── [category]/
└── [productId]/
└── page.tsx # /shop/electronics/iphone-15
// params: { category: 'electronics', productId: 'iphone-15' }
interface Props {
params: Promise<{ category: string; productId: string }>;
}
[...slug] — 捕获所有剩余段
[...slug] 匹配一个路径段及其所有后续段。参数值是字符串数组:
app/
└── docs/
└── [...slug]/
└── page.tsx # 匹配 /docs/a、/docs/a/b、/docs/a/b/c 等
# 但不匹配 /docs(因为至少需要一段)
// app/docs/[...slug]/page.tsx
interface Props {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// /docs/getting-started → slug = ['getting-started']
// /docs/api/reference/hooks → slug = ['api', 'reference', 'hooks']
const docPath = slug.join('/'); // 重建为路径字符串
const doc = await getDoc(docPath);
if (!doc) notFound();
return <DocPage doc={doc} breadcrumbs={slug} />;
}
[[...slug]] — 可选捕获所有
双层方括号让捕获变为可选。与 [...slug] 的区别是它也匹配没有该段的路径:
app/
└── docs/
└── [[...slug]]/
└── page.tsx # 匹配 /docs(slug 为 undefined)
# 也匹配 /docs/a、/docs/a/b 等
// app/docs/[[...slug]]/page.tsx
interface Props {
params: Promise<{ slug?: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
if (!slug) {
// 渲染文档首页
return <DocsIndex />;
}
const docPath = slug.join('/');
const doc = await getDoc(docPath);
if (!doc) notFound();
return <DocPage doc={doc} />;
}
这在文档站点中特别有用:/docs 显示文档总览,/docs/getting-started/installation 显示具体页面,共用同一个布局和同一个 page 组件。
使用 generateStaticParams 进行静态预渲染
对于内容相对固定的动态路由(博客文章、文档页面),可以用 generateStaticParams 告诉 Next.js 在构建时预渲染哪些路径:
// app/blog/[slug]/page.tsx
// 在构建时调用,生成所有需要预渲染的路径
export async function generateStaticParams() {
const posts = await db.posts.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map(post => ({ slug: post.slug }));
}
// 对于未在 generateStaticParams 中列出的路径:
// - 默认:按需渲染(动态渲染)
// - 设置 dynamicParams = false 则返回 404
export const dynamicParams = true; // 默认值
export default async function PostPage({ params }: Props) {
// ...
}
Level 2 · 它是怎么运行的(3-5年经验)
路由分组:不影响 URL 的目录层级
随着应用增长,app/ 目录可能变得庞大。不同功能区域可能需要不同的布局,但你不希望这些分类出现在 URL 中。例如:
- 营销页面(
/、/about、/pricing)需要一个带导航栏的布局 - 应用页面(
/dashboard、/settings)需要一个带侧边栏的布局 - 认证页面(
/login、/register)需要简洁的布局,不要侧边栏
如果用普通目录划分,URL 会变成 /marketing/about、/app/dashboard——这不是想要的结果。
(group) — 括号创建分组,不产生 URL 段
用小括号包裹目录名,该目录成为路由分组,不出现在 URL 中:
app/
├── (marketing)/
│ ├── layout.tsx # 营销页面布局(导航栏)
│ ├── page.tsx # / (首页)
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
├── (app)/
│ ├── layout.tsx # 应用布局(侧边栏)
│ ├── dashboard/
│ │ └── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /settings
└── (auth)/
├── layout.tsx # 认证布局(极简)
├── login/
│ └── page.tsx # /login
└── register/
└── page.tsx # /register
访问 /about 时,Next.js 使用 (marketing)/layout.tsx 作为布局,而不是 (app)/layout.tsx。URL 是 /about,不是 /marketing/about。
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<MarketingNav />
{children}
<MarketingFooter />
</>
);
}
// app/(app)/layout.tsx
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
路由分组的另一个用途是在同一层级提供多个根布局。由于根 app/layout.tsx 是全局的,你可以用路由分组为不同区域创建独立的 <html>/<body> 结构(删除全局根布局,在每个分组中创建 layout.tsx)。
Level 3 · 规范怎么定义的(资深)
并行路由:同时渲染多个页面
并行路由允许在同一布局中同时渲染多个页面。这在视觉上类似于"插槽":布局定义了几个插槽,不同的路由分别填充这些插槽。
经典用例:仪表盘,同时显示用户统计、实时数据图表、活动记录三个独立的数据区域,每个区域都可以独立加载、独立错误处理。
@slot — 定义命名插槽
在布局同级目录下用 @ 前缀创建插槽目录:
app/
└── dashboard/
├── layout.tsx # 接收 children、@analytics、@activity
├── page.tsx # /dashboard 主内容
├── @analytics/
│ ├── page.tsx # 分析面板
│ └── loading.tsx
└── @activity/
├── page.tsx # 活动记录
└── loading.tsx
@analytics 和 @activity 目录名(去掉 @)会作为 props 传入同级的 layout.tsx:
// app/dashboard/layout.tsx
interface Props {
children: React.ReactNode; // dashboard/page.tsx
analytics: React.ReactNode; // @analytics/page.tsx
activity: React.ReactNode; // @activity/page.tsx
}
export default function DashboardLayout({ children, analytics, activity }: Props) {
return (
<div className="dashboard-grid">
<section className="main-content">{children}</section>
<aside className="analytics-panel">{analytics}</aside>
<aside className="activity-feed">{activity}</aside>
</div>
);
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsPanel() {
const stats = await fetchAnalytics(); // 独立的数据获取
return (
<div className="analytics">
<h2>本月统计</h2>
<StatCard label="访客" value={stats.visitors} />
<StatCard label="转化率" value={stats.conversionRate} />
</div>
);
}
三个区域的数据是并行获取的——children、analytics、activity 同时开始渲染,互不等待。结合 loading.tsx,哪个先完成就先显示,实现真正的渐进式渲染。
default.tsx — 未匹配插槽的回退
当用户直接导航到 /dashboard(而不是某个插槽的子路由)时,插槽需要渲染什么?这由 default.tsx 决定。
// app/dashboard/@modal/default.tsx
// 没有激活的弹窗时,渲染空内容
export default function ModalDefault() {
return null;
}
default.tsx 的存在确保了:即使某个插槽没有匹配当前 URL 的 page.tsx,布局也能正常渲染——它回退到 default.tsx,而不是崩溃。
并行路由与拦截路由的组合:模态导航模式
并行路由最强大的用法之一是配合拦截路由(Intercepting Routes)实现"同 URL 双视图":在当前页面以弹窗形式打开一个 URL,刷新页面则显示该 URL 的完整页面。这部分将在第15章详细讲解。
实战:完整仪表盘案例
综合运用本章内容,构建一个真实的仪表盘路由结构:
app/
├── layout.tsx # 根布局
└── (app)/
├── layout.tsx # 应用布局(侧边栏)
└── dashboard/
├── layout.tsx # 仪表盘布局(并行插槽容器)
├── page.tsx # /dashboard 主内容
├── @summary/
│ ├── default.tsx
│ ├── loading.tsx # 骨架屏
│ └── page.tsx # 数据总览卡片
├── @chart/
│ ├── default.tsx
│ ├── loading.tsx
│ └── page.tsx # 趋势图表
└── reports/
└── [reportId]/
└── page.tsx # /dashboard/reports/:reportId
// app/(app)/dashboard/layout.tsx
export default function DashboardLayout({
children,
summary,
chart,
}: {
children: React.ReactNode;
summary: React.ReactNode;
chart: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 grid-rows-2 gap-4 h-full p-4">
<div className="col-span-3">{children}</div>
<div className="col-span-1 row-span-1">{summary}</div>
<div className="col-span-2 row-span-1">{chart}</div>
</div>
);
}
这个结构中,访问 /dashboard/reports/2024-q1 时:
- 根布局渲染
<html>/<body> - 应用布局渲染侧边栏
- 仪表盘布局渲染 3 列网格
@summary和@chart插槽回退到default.tsx(或继续显示之前的状态)children位置渲染reports/[reportId]/page.tsx
Level 4 · 边界与陷阱(所有人)
陷阱一:[...slug] 与 [[...slug]] 的微妙区别
[...slug] 要求至少有一个路径段,不匹配父路径本身。如果你用 app/docs/[...slug]/page.tsx,/docs 这个 URL 不会匹配,需要单独的 app/docs/page.tsx。使用 [[...slug]] 则连 /docs 也能匹配(slug 为 undefined),但这意味着你不能在同目录再放一个 page.tsx。
陷阱二:并行路由忘记 default.tsx 导致 404
如果一个 @slot 没有 default.tsx,当该插槽没有匹配路由时,整个页面会变成 404。始终为每个并行路由插槽提供 default.tsx,即使只是返回 null。
陷阱三:路由分组中的首页冲突
如果两个路由分组都定义了 page.tsx(例如 (marketing)/page.tsx 和 (app)/page.tsx),它们都对应 / 这个 URL,会产生冲突。确保只有一个分组中的根 page.tsx 对应首页。
小结
动态路由([slug]、[...slug]、[[...slug]])处理 URL 中的可变部分,覆盖从简单参数到任意深度路径的所有场景。路由分组((group))解决了布局组织与 URL 结构之间的矛盾,让不同功能区域拥有独立布局而不影响 URL。并行路由(@slot)将单页面的多区域渲染提升为框架级特性,每个插槽独立获取数据、独立错误处理、独立加载状态,default.tsx 确保了路由不匹配时的优雅回退。这三个特性组合使用,可以构建出极其灵活且性能卓越的路由结构。