第 3 章

动态路由、路由分组与并行路由

第3章:动态路由、路由分组与并行路由

URL 中的可变部分、不影响 URL 的目录组织、同一页面中的多区域并行渲染——这三个路由特性组合使用,可以构建出极其灵活且性能卓越的路由结构。

本章核心问题:如何处理 URL 中的动态参数?如何在不影响 URL 的前提下组织布局?如何让页面的多个区域独立加载?

读完本章你将理解


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 中。例如:

如果用普通目录划分,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>
  );
}

三个区域的数据是并行获取的——childrenanalyticsactivity 同时开始渲染,互不等待。结合 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 时:

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 确保了路由不匹配时的优雅回退。这三个特性组合使用,可以构建出极其灵活且性能卓越的路由结构。

本章评分
4.8  / 5  (82 评分)

💬 留言讨论