# 动态路由、并行路由与拦截路由

> Next.js 路由系统的三大高级特性——动态路由处理参数化 URL、并行路由在同一页面渲染多个独立视图、拦截路由实现"点击打开 Modal,刷新变完整页"的 Instagram 式体验。本章用大量 AI SaaS 实战场景逐一拆解。

## 1. 动态路由

### 1.1 基础动态段 `[param]`

方括号包裹的目录名表示**动态路由段**——匹配任意单个路径段:

```
app/chat/[id]/page.tsx     → /chat/abc      params.id = "abc"
app/blog/[slug]/page.tsx   → /blog/hello    params.slug = "hello"
app/user/[userId]/page.tsx → /user/123      params.userId = "123"
```

```tsx
// app/chat/[id]/page.tsx
type Props = {
  params: Promise<{ id: string }>
}

export default async function ChatDetailPage({ params }: Props) {
  const { id } = await params
  const chat = await db.query.chats.findFirst({
    where: eq(chats.id, id),
  })

  if (!chat) notFound()

  return <ChatDetail chat={chat} />
}
```

::: warning params 永远是 string
`params.id` 的类型永远是 `string`,即使 URL 看起来像数字(`/chat/123`)。需要数字时必须手动转换并校验:

```tsx
const numericId = Number(id)
if (Number.isNaN(numericId)) notFound()
```
:::

### 1.2 静态生成动态路由

对于可预知的动态路由(如博客文章),可以在构建时生成静态页面:

```tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await db.query.posts.findMany({
    columns: { slug: true },
  })

  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <PostContent post={post} />
}
```

构建时,Next.js 会为每个 `slug` 生成一个静态 HTML 文件,首屏加载极快。

### 1.3 多层动态段

```
app/workspace/[workspaceId]/project/[projectId]/page.tsx
→ /workspace/ws-1/project/proj-2
→ params = { workspaceId: "ws-1", projectId: "proj-2" }
```

```tsx
type Props = {
  params: Promise<{ workspaceId: string; projectId: string }>
}

export default async function ProjectPage({ params }: Props) {
  const { workspaceId, projectId } = await params
  // 校验用户对该 workspace 有访问权限
  await validateAccess(workspaceId)
  const project = await getProject(projectId)
  return <ProjectDetail project={project} />
}
```

## 2. Catch-All 与可选 Catch-All

### 2.1 Catch-All 路由 `[...slug]`

匹配**一个或多个**路径段:

```
app/docs/[...slug]/page.tsx

/docs/getting-started        → slug = ["getting-started"]
/docs/api/reference          → slug = ["api", "reference"]
/docs/api/reference/chat     → slug = ["api", "reference", "chat"]
/docs                        → ❌ 不匹配(至少需要一个段)
```

AI SaaS 中的典型用例——知识库文档路由:

```tsx
// app/docs/[...slug]/page.tsx
type Props = {
  params: Promise<{ slug: string[] }>
}

export default async function DocsPage({ params }: Props) {
  const { slug } = await params
  const path = slug.join('/')

  const doc = await getDocument(path)
  if (!doc) notFound()

  return (
    <div className="flex">
      <DocsSidebar currentPath={path} />
      <article className="prose">
        <h1>{doc.title}</h1>
        <MDXContent source={doc.content} />
      </article>
    </div>
  )
}
```

### 2.2 可选 Catch-All `[[...slug]]`

匹配**零个或多个**路径段(包括根路径):

```
app/shop/[[...slug]]/page.tsx

/shop                        → slug = undefined (或 [])
/shop/electronics            → slug = ["electronics"]
/shop/electronics/phones     → slug = ["electronics", "phones"]
```

适合"分类筛选"页面——根路径显示全部,子路径显示分类:

```tsx
// app/knowledge/[[...category]]/page.tsx
type Props = {
  params: Promise<{ category?: string[] }>
}

export default async function KnowledgePage({ params }: Props) {
  const { category } = await params
  const categoryPath = category?.join('/') ?? null

  const documents = categoryPath
    ? await getDocsByCategory(categoryPath)
    : await getAllDocs()

  return (
    <div>
      <h1>{categoryPath ?? '全部知识库'}</h1>
      <DocumentGrid documents={documents} />
    </div>
  )
}
```

### 2.3 对比总结

| 模式 | 语法 | 匹配 /chat | 匹配 /chat/a | 匹配 /chat/a/b |
|------|------|-----------|-------------|---------------|
| 动态段 | `[id]` | ❌ | ✅ `id="a"` | ❌ |
| Catch-All | `[...slug]` | ❌ | ✅ `["a"]` | ✅ `["a","b"]` |
| 可选 Catch-All | `[[...slug]]` | ✅ `undefined` | ✅ `["a"]` | ✅ `["a","b"]` |

## 3. 并行路由(Parallel Routes)

### 3.1 概念

并行路由允许在**同一个布局**中同时渲染**多个独立的页面**。每个并行路由是一个 "slot",用 `@slotName` 目录定义。

```
app/
├── layout.tsx          # 接收 children + @analytics + @team
├── page.tsx            # 默认 children
├── @analytics/
│   ├── page.tsx        # 分析面板
│   └── default.tsx     # 回退
└── @team/
    ├── page.tsx        # 团队面板
    └── default.tsx     # 回退
```

```tsx
// app/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      <div className="grid grid-cols-2 gap-4">
        {analytics}
        {team}
      </div>
      <div className="mt-4">
        {children}
      </div>
    </div>
  )
}
```

### 3.2 AI SaaS 实战:仪表盘多面板

```
app/(dashboard)/
├── layout.tsx
├── page.tsx                    # 主内容
├── @stats/
│   ├── page.tsx                # 使用统计(Token 消耗、对话数)
│   ├── loading.tsx             # 独立加载态
│   └── default.tsx
├── @recent/
│   ├── page.tsx                # 最近对话列表
│   ├── loading.tsx             # 独立加载态
│   └── default.tsx
└── @quickActions/
    ├── page.tsx                # 快捷操作面板
    └── default.tsx
```

```tsx
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
  children,
  stats,
  recent,
  quickActions,
}: {
  children: React.ReactNode
  stats: React.ReactNode
  recent: React.ReactNode
  quickActions: React.ReactNode
}) {
  return (
    <div className="space-y-6 p-6">
      {/* 三个面板独立加载,互不阻塞 */}
      <div className="grid grid-cols-3 gap-4">
        {stats}
        {recent}
        {quickActions}
      </div>
      {children}
    </div>
  )
}
```

关键优势:每个 slot **独立加载、独立报错**。`@stats` 的数据库查询很慢?它自己显示 loading,不影响 `@recent` 和 `@quickActions`。

### 3.3 条件渲染

并行路由的另一个强大用途——根据条件渲染不同的 slot:

```tsx
// app/layout.tsx
import { auth } from '@/lib/auth/config'

export default async function Layout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode
  admin: React.ReactNode
  user: React.ReactNode
}) {
  const session = await auth()
  const isAdmin = session?.user?.role === 'admin'

  return (
    <div>
      {isAdmin ? admin : user}
      {children}
    </div>
  )
}
```

### 3.4 default.tsx 的重要性

并行路由**必须**为每个 slot 提供 `default.tsx`,否则导航到子路由时会报错:

```tsx
// app/@analytics/default.tsx
export default function Default() {
  return null
}
```

为什么需要?当用户导航到一个子路由(比如 `/chat/123`),而 `@analytics` 目录下没有 `chat/[id]/page.tsx`,Next.js 需要知道这个 slot 显示什么。`default.tsx` 就是这个回退。

## 4. 拦截路由(Intercepting Routes)

### 4.1 概念

拦截路由实现了一个优雅的 UX 模式:

- **点击链接** → 在当前页面打开 Modal(不离开当前页面)
- **直接访问 URL / 刷新** → 显示完整独立页面

这正是 Instagram、Twitter 图片查看的交互模式。

### 4.2 拦截约定

```
(.)/folder   → 拦截同级路由
(..)/folder  → 拦截上一级路由
(..)(..)/folder → 拦截上两级
(...)/folder → 拦截根级路由
```

### 4.3 实战:AI 对话预览 Modal

目录结构:

```
app/(dashboard)/
├── layout.tsx            # 接收 children + @modal
├── @modal/
│   ├── default.tsx       # 默认不显示 modal
│   └── (.)chat/[id]/
│       └── page.tsx      # 拦截 /chat/:id → 显示为 modal
├── chat/
│   ├── page.tsx          # /chat 列表页
│   └── [id]/
│       └── page.tsx      # /chat/:id 完整页面(直接访问时)
```

```tsx
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}
```

```tsx
// app/(dashboard)/@modal/default.tsx
export default function Default() {
  return null // 没有 modal 时不渲染任何东西
}
```

```tsx
// app/(dashboard)/@modal/(.)chat/[id]/page.tsx — Modal 版本
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { ChatPreview } from '@/components/chat/chat-preview'

type Props = {
  params: Promise<{ id: string }>
}

export default async function ChatModal({ params }: Props) {
  const { id } = await params
  const chat = await getChat(id)

  return (
    <Dialog open>
      <DialogContent className="max-w-2xl">
        <ChatPreview chat={chat} />
      </DialogContent>
    </Dialog>
  )
}
```

```tsx
// app/(dashboard)/chat/[id]/page.tsx — 完整页面版本
type Props = {
  params: Promise<{ id: string }>
}

export default async function ChatPage({ params }: Props) {
  const { id } = await params
  const chat = await getChatWithMessages(id)

  return (
    <div className="mx-auto max-w-4xl">
      <ChatHeader chat={chat} />
      <ChatMessages messages={chat.messages} />
      <ChatInput chatId={id} />
    </div>
  )
}
```

用户体验:

```
在 /chat 列表页点击某个对话:
→ URL 变为 /chat/abc
→ 列表页不消失,上面弹出 Modal 显示对话预览
→ 用户可以关闭 Modal 回到列表

直接访问 /chat/abc(或刷新页面):
→ 显示完整的对话页面(不是 Modal)
→ 包含消息列表、输入框等完整功能
```

### 4.4 拦截路由 + 并行路由组合模式

这是 Next.js 路由系统最高级的组合——也是生产项目中最常用的 Modal 方案:

```
核心原理:
1. @modal 是并行路由(slot)
2. (.)chat/[id] 是拦截路由
3. 两者组合 = 点击链接打开 Modal,刷新变完整页
```

## 5. 实战场景速查

| 场景 | 路由模式 | 目录结构 |
|------|---------|---------|
| 对话详情 | 动态路由 | `chat/[id]/page.tsx` |
| 知识库文档 | Catch-All | `docs/[...slug]/page.tsx` |
| 分类筛选 | 可选 Catch-All | `knowledge/[[...category]]/page.tsx` |
| 仪表盘多面板 | 并行路由 | `@stats/` + `@recent/` + `@quickActions/` |
| 对话预览弹窗 | 拦截 + 并行 | `@modal/(.)chat/[id]/page.tsx` |
| 图片灯箱 | 拦截 + 并行 | `@modal/(.)photo/[id]/page.tsx` |
| 多租户 | 动态路由 | `[workspace]/chat/page.tsx` |
| API 版本化 | 动态路由 | `api/v[version]/chat/route.ts` |

## 本章小结

- **动态路由** `[param]`:匹配单个路径段,`params` 永远是 string
- **Catch-All** `[...slug]`:匹配一个或多个段;**可选 Catch-All** `[[...slug]]`:匹配零个或多个
- **并行路由** `@slot`:同一布局渲染多个独立页面,独立加载、独立报错,必须有 `default.tsx`
- **拦截路由** `(.)`:点击打开 Modal,刷新变完整页——与并行路由组合是最强 Modal 方案
- **`generateStaticParams`**:构建时预生成动态路由的静态页面

下一章讲中间件与路由守卫。