Next.js: Parallel Routes 与拦截路由

最后更新:2026-08-26

平行路由就像多屏显示器——每个屏幕独立运作但又相互关联;拦截路由则像快递代收点——中途拦截包裹,处理完再继续配送。

1. 你将学到


2. 一个产品经理的真实故事

(1) 痛点:Feed 流中的弹窗体验差

Alice 正在开发一个内容平台的图片浏览功能,遇到了用户体验问题:

"用户在 Feed 流中点开一个图片,我们弹出了全屏 Modal,URL 也变成了 /photos/123。结果用户想返回 Feed 流,按了浏览器后退——直接退出了整个应用。用户投诉说'我只是想看张图,怎么就回不了首页了'。"

Alice 分析的问题:

问题 影响 用户评分
Modal 无法独立刷新 刷新 Modal 变成全页 2/5
后退按钮跳出应用 用户流断裂 1.5/5
侧边栏与主内容状态不同步 导航混乱 2.5/5
团队视图与 Dashboard 分离 管理效率低 3/5

(2) Parallel + Intercepting Routes 的解法

@modal 平行路由显示弹窗 + (.)photo 拦截路由在 Feed 流中拦截图片导航。

TEXT 📖 仅展示
src/app/
├── layout.tsx              # 主布局:@children + @modal
└── (feed)/
    ├── layout.tsx          # Feed 布局:@children + @sidebar
    ├── page.tsx            # Feed 流主页
    └── photos/
        ├── [id]/
        │   └── page.tsx    # 全页:/photos/123
        └── (.)[id]/
            └── page.tsx    # 拦截:在 Feed 中打开 Modal

(3) 收益

维度 之前(普通路由) 之后(Parallel + Intercepting)
图片浏览体验 全页跳转,打断浏览 弹窗预览,不离开当前页
后退按钮行为 退出应用 关闭 Modal,回到 Feed
页面可刷新 不支持 刷新显示全页,正常渲染
侧边栏独立 不独立 @sidebar 独立渲染,不影响主内容

3. Parallel Routes 平行路由

(1) 概念与用法

100%
graph TB
    subgraph "URL: /dashboard"
        A[layout.tsx] --> B[children<br/>主内容]
        A --> C[@modal<br/>弹窗插槽]
        A --> D[@sidebar<br/>侧边栏插槽]
        A --> E[@team<br/>团队插槽]
    end

    subgraph "渲染结果"
        F[主内容区] & G[弹窗区] & H[侧边栏区] & I[团队区]
    end

    style A fill:#cce5ff
    style B fill:#d4edda
插槽命名 目录前缀 URL 影响 使用场景
@children 无(默认) 标准 URL 主页面内容
@modal @modal/ 弹窗、对话框
@sidebar @sidebar/ 侧边栏面板
@team @team/ 团队视图

▶ 示例:基本 Parallel Routes

TSX
// ============================================
// Parallel Routes 基础:Dashboard 多插槽布局
// ============================================

// src/app/(dashboard)/layout.tsx — 平行路由布局
export default function DashboardLayout({
  children,
  modal,
  sidebar,
  team,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
  sidebar: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="flex h-screen">
      {/* 主内容区 */}
      <main className="flex-1 p-8 overflow-auto">
        {children}
      </main>

      {/* 侧边栏插槽(独立并行渲染) */}
      <aside className="w-72 bg-gray-50 p-4 border-l">
        {sidebar}
      </aside>

      {/* 团队插槽(独立并行渲染) */}
      <aside className="w-64 bg-gray-900 text-white p-4">
        {team}
      </aside>

      {/* Modal 插槽(条件性渲染) */}
      {modal}
    </div>
  );
}
TSX
// src/app/(dashboard)/@sidebar/default.tsx — 侧边栏默认状态
export default function SidebarDefault() {
  return (
    <div>
      <h3 className="font-bold text-lg mb-4">Sidebar</h3>
      <div className="space-y-2">
        <div className="p-3 bg-white rounded shadow-sm">
          <p className="font-medium">Recent Activity</p>
          <p className="text-sm text-gray-500">No recent activity</p>
        </div>
        <div className="p-3 bg-white rounded shadow-sm">
          <p className="font-medium">Notifications</p>
          <p className="text-sm text-gray-500">3 unread</p>
        </div>
      </div>
    </div>
  );
}
TSX
// src/app/(dashboard)/@team/default.tsx — 团队插槽默认状态
export default function TeamDefault() {
  return (
    <div className="p-4">
      <h3 className="font-bold mb-4">Team</h3>
      <div className="space-y-3">
        {["Alice", "Bob", "Charlie", "Diana"].map(name => (
          <div key={name} className="flex items-center gap-2">
            <div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm">
              {name[0]}
            </div>
            <span className="text-sm">{name}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /dashboard:
┌──────────────────────┬──────────┬──────────┐
│                      │          │          │
│   主内容区           │ 侧边栏   │  团队    │
│   Dashboard          │ Recent   │  Alice   │
│   Welcome back!      │ Activity │  Bob     │
│                      │ Notif(3) │  Charlie │
│                      │          │  Diana   │
└──────────────────────┴──────────┴──────────┘

三个区域独立渲染,互不影响

(2) default.tsx — 必备的默认状态

每个 @slot 目录必须包含 default.tsx,当没有匹配的路由时显示。

100%
graph TB
    A[用户导航] --> B{当前 URL<br/>匹配 slot 路由?}
    B -->|匹配| C[显示 slot 的 page.tsx]
    B -->|不匹配| D[显示 slot 的 default.tsx]

    style C fill:#d4edda
    style D fill:#f8d7da

▶ 示例:default.tsx 的重要性

TSX
// ============================================
// 没有 default.tsx 会发生 404
// 每个 @slot 都必须有 default.tsx
// ============================================

// src/app/@modal/default.tsx — Modal 默认不显示
export default function ModalDefault() {
  return null; // 没有 Modal 时不渲染任何内容
}

// src/app/(dashboard)/@sidebar/default.tsx
export default function SidebarDefault() {
  return (
    <div className="p-4">
      <h3 className="font-bold text-sm text-gray-500 uppercase">
        Quick Links
      </h3>
      <nav className="mt-3 space-y-2">
        <a href="/dashboard" className="block text-blue-600">Dashboard</a>
        <a href="/dashboard/projects" className="block text-blue-600">Projects</a>
        <a href="/dashboard/settings" className="block text-blue-600">Settings</a>
      </nav>
    </div>
  );
}

输出:

TEXT 📖 仅展示
访问 /dashboard:
- @modal 没有匹配路由 → 显示 ModalDefault (= null,不渲染)
- @sidebar 没有匹配路由 → 显示 SidebarDefault(快捷链接导航)
- @team 没有匹配路由 → 显示 TeamDefault(团队成员列表)

访问 /dashboard/photos/1(假设 @modal 有匹配):
- @modal 匹配 → 显示弹窗内容
- 其他 slot 显示各自的 default.tsx

4. Intercepting Routes 拦截路由

(1) 拦截匹配规则

100%
graph TB
    A[当前在 Feed 流] --> B{点击图片链接}
    B --> C[拦截 (.)photo]
    C --> D[在 Feed 中显示弹窗]
    D --> E[用户刷新页面]
    E --> F[跳过拦截<br/>显示全页]

    style C fill:#cce5ff
    style D fill:#d4edda
    style F fill:#f8d7da
语法 匹配层级 示例
(.) 同级别 feed/photos/(.)[id] 拦截 feed/photos/[id]
(..) 上一级 feed/(..)photos/[id] 拦截 photos/[id]
(..)(..) 上两级 feed/(..)(..)photos/[id] 拦截根级别的 photos/[id]
(...) 根级别 feed/(...)photos/[id] 拦截 app/photos/[id]

▶ 示例:图片浏览拦截

TSX
// ============================================
// Intercepting Route + Parallel Route
// Feed 中点击图片 → 在当前页弹窗显示
// ============================================

// 目录结构:
// app/
//   layout.tsx                    # 根布局(含 @modal)
//   (feed)/
//     page.tsx                    # Feed 流
//     photos/
//       [id]/page.tsx             # 全页: /photos/1
//   @modal/
//     default.tsx                 # 无 modal
//     (.)photos/
//       [id]/page.tsx             # 拦截: 在 Feed 中显示弹窗

// src/app/layout.tsx — 根布局(含 modal 插槽)
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

// src/app/(feed)/page.tsx — Feed 流页面
import Link from "next/link";

export default function FeedPage() {
  const photos = Array.from({ length: 12 }, (_, i) => ({
    id: i + 1,
    url: `https://picsum.photos/seed/${i + 1}/300/300`,
    title: `Photo ${i + 1}`,
  }));

  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Photo Feed</h1>
      <div className="grid grid-cols-3 gap-4">
        {photos.map(photo => (
          <Link
            key={photo.id}
            href={`/photos/${photo.id}`}
            className="block overflow-hidden rounded-lg hover:opacity-90 transition-opacity"
          >
            <img
              src={photo.url}
              alt={photo.title}
              className="w-full h-64 object-cover"
            />
            <p className="mt-2 text-sm font-medium text-center">{photo.title}</p>
          </Link>
        ))}
      </div>
    </div>
  );
}
TSX
// ============================================
// 拦截路由:在 Feed 中打开 Modal 弹窗
// 文件:src/app/@modal/(.)photos/[id]/page.tsx
// ============================================

'use client';

import { useRouter } from "next/navigation";

export default function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const router = useRouter();
  const { id } = params;

  return (
    // 背景遮罩
    <div
      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
      onClick={() => router.back()}
    >
      {/* Modal 内容 — 点击背景关闭 */}
      <div
        className="bg-white rounded-2xl overflow-hidden max-w-2xl w-full mx-4"
        onClick={e => e.stopPropagation()}
      >
        <img
          src={`https://picsum.photos/seed/${id}/800/600`}
          alt={`Photo ${id}`}
          className="w-full h-auto"
        />
        <div className="p-6">
          <div className="flex items-center justify-between">
            <div>
              <h2 className="text-xl font-bold">Photo #{id}</h2>
              <p className="text-gray-500 text-sm mt-1">
                Captured by photographer
              </p>
            </div>
            <button
              onClick={() => router.back()}
              className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"
            >
              ✕
            </button>
          </div>
          <div className="flex gap-2 mt-4">
            <span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">
              Nature
            </span>
            <span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">
              Landscape
            </span>
            <span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">
              HD
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}
TSX
// ============================================
// 全页:独立访问时显示完整页面
// 文件:src/app/photos/[id]/page.tsx
// ============================================

import Link from "next/link";

export default function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = params;

  return (
    <div className="max-w-4xl mx-auto p-8">
      <Link
        href="/"
        className="text-blue-600 hover:underline mb-4 inline-block"
      >
        &larr; Back to Feed
      </Link>
      <img
        src={`https://picsum.photos/seed/${id}/1200/800`}
        alt={`Photo ${id}`}
        className="w-full rounded-lg"
      />
      <div className="mt-6">
        <h1 className="text-3xl font-bold">Photo #{id}</h1>
        <p className="text-gray-500 mt-2">
          Full page view of photo {id}. This page is directly accessible
          and works even without JavaScript.
        </p>
        <div className="flex gap-4 mt-6">
          <Link
            href={`/photos/${Number(id) - 1}`}
            className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
          >
            ← Previous
          </Link>
          <Link
            href={`/photos/${Number(id) + 1}`}
            className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
          >
            Next →
          </Link>
        </div>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
场景 1:从 Feed 流点击图片
- 当前在 / 页面(Photo Feed 网格)
- 点击第 1 张图片
- 弹窗显示大图(地址栏变为 /photos/1)
- 点击背景遮罩 → router.back() → 回到 Feed 流
- 点击浏览器后退 → 关闭弹窗 → Feed 流保持不变

场景 2:直接访问 /photos/1
- 跳过拦截路由
- 显示完整页面(全宽大图 + Previous/Next 按钮)
- 可直接刷新、分享链接

场景 3:刷新弹窗页
- Feed 中的弹窗 → 按 F5 刷新
- 拦截路由不生效(不是从 Feed 导航)
- 显示完整的 /photos/1 页面

(2) 多级拦截

▶ 示例:多级拦截路由

TSX
// ============================================
// 多级拦截路由示例
// 目录结构:
// app/
//   photos/
//     [id]/page.tsx                    → /photos/1(完整页面)
//   (feed)/
//     page.tsx                         → /(Feed 流)
//     categories/
//       [cat]/page.tsx                 → /categories/nature(分类页)
//       (..)(..)photos/
//         [id]/page.tsx                → 拦截 /photos/1 → 在分类页弹窗
// ============================================

// 匹配规则说明:
// (feed)/categories/[cat] 的层级 = app/(feed)/categories/[cat]
// 目标: app/photos/[id] 的层级 = app/photos/[id]
// 需要上两级才能匹配 → (..)(..)

// app/(feed)/categories/(..)(..)photos/[id]/page.tsx
'use client';
import { useRouter } from "next/navigation";

export default function CategoryPhotoModal({ params }) {
  const router = useRouter();

  return (
    <div
      className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
      onClick={() => router.back()}
    >
      <div onClick={e => e.stopPropagation()} className="max-w-lg">
        <img
          src={`https://picsum.photos/seed/${params.id}/600/400`}
          alt=""
          className="rounded-lg"
        />
        <button
          onClick={() => router.back()}
          className="mt-2 px-4 py-2 bg-white rounded"
        >
          Close
        </button>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
从 /categories/nature 点击图片:
1. 弹窗显示大图(地址栏 /photos/1)
2. 点击 Close 或背景 → router.back() → 回到 /categories/nature
3. 直接访问 /photos/1 → 显示完整页面(不弹窗)
4. 分类页中的弹窗正常工作

5. Modal + Parallel Route 组合模式

(1) 架构设计

100%
graph TB
    subgraph "URL 驱动的 Modal 弹窗"
        A[用户操作] --> B{导航到 /photos/1}
        B --> C[Feed 中?]
        C -->|是| D[@modal 插槽<br/>匹配拦截路由]
        C -->|否| E[直接访问<br/>显示完整页面]
        D --> F[弹窗显示<br/>地址栏更新]
        F --> G[用户关闭]
        G --> H[router.back()]
        H --> I[回到 Feed<br/>Modal 消失]
    end

    style D fill:#cce5ff
    style E fill:#d4edda
    style G fill:#f8d7da
用户操作 URL 变化 Modal 状态 页面状态
点击 Feed 图片 //photos/1 显示弹窗 Feed 在背后保持
关闭弹窗 /photos/1/ 隐藏 Feed 不变
刷新 /photos/1 /photos/1 不变 无弹窗 显示完整页面
分享 /photos/1 可分享 无弹窗 接收者看到完整页面

▶ 示例:完整的 Modal 模式

TSX
// ============================================
// 完整的 Modal + Parallel Route + Intercepting Route
// 实现一个"可刷新、可分享、可后退"的弹窗系统
// ============================================

// src/app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

// src/app/@modal/default.tsx
export default function Default() {
  return null;
}

// src/app/@modal/(.)photos/[id]/page.tsx — 拦截弹窗
'use client';
import { useRouter } from "next/navigation";

export default function PhotoModal({ params }) {
  const router = useRouter();

  return (
    <div
      className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
      onClick={() => router.back()}
    >
      <div
        className="bg-white rounded-xl max-w-3xl w-full mx-4 shadow-2xl"
        onClick={e => e.stopPropagation()}
      >
        <div className="flex justify-end p-2">
          <button
            onClick={() => router.back()}
            className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded-full"
          >
            ✕
          </button>
        </div>
        <img
          src={`https://picsum.photos/seed/${params.id}/800/600`}
          alt=""
          className="w-full"
        />
        <div className="p-6">
          <h2 className="text-2xl font-bold">Photo #{params.id}</h2>
          <div className="flex gap-4 mt-4">
            <a
              href={`/photos/${params.id}`}
              className="text-sm text-blue-600 hover:underline"
              onClick={() => router.push(`/photos/${params.id}`)}
            >
              Open in full page →
            </a>
          </div>
        </div>
      </div>
    </div>
  );
}

// src/app/(feed)/page.tsx — Feed 流
import Link from "next/link";

export default function Feed() {
  return (
    <div className="max-w-6xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Photo Gallery</h1>
      <div className="grid grid-cols-4 gap-4">
        {[1, 2, 3, 4, 5, 6, 7, 8].map(id => (
          <Link
            key={id}
            href={`/photos/${id}`}
            className="block group"
          >
            <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
              <img
                src={`https://picsum.photos/seed/${id}/400/400`}
                alt=""
                className="w-full h-full object-cover group-hover:scale-105 transition-transform"
              />
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
用户流程:

1. 访问 / → 看到 8 张图片网格
2. 点击图片 #3 → 弹窗显示大图(URL: /photos/3)
3. 点击右上角 ✕ → 关闭弹窗(URL: /)
4. 再次点击图片 #5 → 弹窗(URL: /photos/5)
5. 按浏览器后退 → 关闭弹窗(URL: /)
6. 再次按后退 → 退出应用(正常行为)

刷新场景:
1. 在弹窗状态按 F5 → 整页刷新
2. 拦截路由不生效 → 显示 /photos/3 完整页面
3. 页面可正常使用,有导航链接返回

分享场景:
1. 复制 /photos/3 URL 发给朋友
2. 朋友打开 → 看到完整页面(不是弹窗)
3. 页面 SEO 正常,所有内容可索引

6. 条件渲染 Dashboard

(1) 按角色渲染不同视图

100%
graph TB
    A[Dashboard 布局] --> B{用户角色}
    B -->|admin| C[@admin 面板]
    B -->|editor| D[@editor 面板]
    B -->|viewer| E[@viewer 面板]

    style A fill:#cce5ff
    style C fill:#d4edda
    style D fill:#d4edda
    style E fill:#d4edda

▶ 示例:角色驱动的 Dashboard

TSX
// ============================================
// 条件渲染:根据角色显示不同的 Dashboard
// ============================================

// src/app/(dashboard)/layout.tsx — 角色路由布局
export default function DashboardLayout({
  children,
  admin,
  editor,
  viewer,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
  editor: React.ReactNode;
  viewer: React.ReactNode;
}) {
  // 模拟从 Cookie/Session 获取角色
  const role = "admin";

  return (
    <div className="flex h-screen">
      {/* 主内容 */}
      <main className="flex-1 p-8">
        {children}
      </main>

      {/* 根据角色渲染不同插槽 */}
      <aside className="w-80 border-l p-4">
        {role === "admin" && admin}
        {role === "editor" && editor}
        {role === "viewer" && viewer}
      </aside>
    </div>
  );
}
TSX
// src/app/(dashboard)/@admin/default.tsx — 管理员面板
export default function AdminPanel() {
  return (
    <div className="space-y-4">
      <h3 className="font-bold text-lg">Admin Controls</h3>
      <div className="bg-red-50 border border-red-200 rounded-lg p-4">
        <p className="font-medium text-red-700">System Health</p>
        <div className="mt-2 space-y-2">
          <div className="flex justify-between text-sm">
            <span>CPU Usage</span>
            <span className="text-green-600">45%</span>
          </div>
          <div className="flex justify-between text-sm">
            <span>Memory</span>
            <span className="text-yellow-600">72%</span>
          </div>
          <div className="flex justify-between text-sm">
            <span>Active Users</span>
            <span className="text-blue-600">1,234</span>
          </div>
        </div>
      </div>
      <div className="bg-white rounded-lg border p-4">
        <p className="font-medium">Pending Approvals</p>
        <p className="text-2xl font-bold text-orange-600 mt-2">12</p>
      </div>
      <button className="w-full p-2 bg-blue-600 text-white rounded-lg">
        View All Settings
      </button>
    </div>
  );
}

// src/app/(dashboard)/@editor/default.tsx — 编辑者面板
export default function EditorPanel() {
  return (
    <div className="space-y-4">
      <h3 className="font-bold text-lg">Editor Tools</h3>
      <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
        <p className="font-medium text-blue-700">Draft Count</p>
        <p className="text-3xl font-bold mt-2">8</p>
      </div>
      <div className="bg-white rounded-lg border p-4">
        <p className="font-medium">Recent Edits</p>
        <div className="mt-2 space-y-2 text-sm">
          <p>• Updated homepage hero</p>
          <p>• Fixed typo in about page</p>
          <p>• Added new blog post</p>
        </div>
      </div>
      <button className="w-full p-2 bg-green-600 text-white rounded-lg">
        Create New Post
      </button>
    </div>
  );
}

// src/app/(dashboard)/@viewer/default.tsx — 查看者面板
export default function ViewerPanel() {
  return (
    <div className="space-y-4">
      <h3 className="font-bold text-lg">Overview</h3>
      <div className="bg-gray-50 border rounded-lg p-4">
        <p className="font-medium">Your Dashboard</p>
        <p className="text-sm text-gray-500 mt-2">
          You have read-only access. Contact admin for editing permissions.
        </p>
      </div>
      <div className="bg-white border rounded-lg p-4">
        <p className="font-medium">Quick Links</p>
        <div className="mt-2 space-y-2 text-sm">
          <a href="/docs" className="block text-blue-600">Documentation</a>
          <a href="/reports" className="block text-blue-600">Reports</a>
          <a href="/help" className="block text-blue-600">Help Center</a>
        </div>
      </div>
    </div>
  );
}

输出:

TEXT 📖 仅展示
管理员(role = "admin"):
┌────────────────────────────┬──────────────────────┐
│                            │  Admin Controls       │
│    Dashboard               │  ┌────────────────┐  │
│    Welcome back, Alice!    │  │ CPU: 45% ✅    │  │
│                            │  │ Memory: 72% ⚠️  │  │
│    Project Summary         │  │ Users: 1,234   │  │
│    12 Active Projects      │  └────────────────┘  │
│    48 Pending Tasks        │  Pending Approvals    │
│    8 Team Members          │  12                   │
│                            │  [View All Settings]  │
└────────────────────────────┴──────────────────────┘

编辑者(role = "editor"):
┌────────────────────────────┬──────────────────────┐
│                            │  Editor Tools         │
│    Dashboard               │  Draft Count: 8       │
│    Welcome back, Bob!      │  Recent Edits:        │
│                            │  • Updated homepage   │
│    My Projects             │  • Fixed typo         │
│    5 Active Projects       │  • Added blog post    │
│                            │  [Create New Post]    │
└────────────────────────────┴──────────────────────┘

查看者(role = "viewer"):
┌────────────────────────────┬──────────────────────┐
│                            │  Overview             │
│    Dashboard               │  Read-only access     │
│    Welcome, Charlie!       │  Quick Links:         │
│                            │  • Documentation      │
│    Team Activity           │  • Reports            │
│    10 team members online  │  • Help Center        │
│                            │                      │
└────────────────────────────┴──────────────────────┘

7. 完整示例:Feed + Modal + 条件 Dashboard

TSX
// ============================================
// 综合示例:内容平台完整路由系统
// 涵盖 Parallel Routes + Intercepting Routes + 条件 Dashboard
// ============================================

// src/app/layout.tsx — 根布局(含 modal 插槽)
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-50">
        <header className="bg-white shadow-sm sticky top-0 z-40">
          <div className="max-w-6xl mx-auto px-4 py-3 flex justify-between">
            <a href="/" className="text-xl font-bold text-blue-600">PhotoVault</a>
            <nav className="flex gap-4">
              <a href="/" className="hover:text-blue-600">Feed</a>
              <a href="/dashboard" className="hover:text-blue-600">Dashboard</a>
            </nav>
          </div>
        </header>
        {children}
        {modal}
      </body>
    </html>
  );
}

// src/app/@modal/default.tsx
export default function Default() {
  return null;
}

// src/app/@modal/(.)photos/[id]/page.tsx — 拦截弹窗
'use client';
import { useRouter } from "next/navigation";

export default function PhotoModal({ params }) {
  const router = useRouter();

  return (
    <div
      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
      onClick={() => router.back()}
    >
      <div
        className="bg-white rounded-xl max-w-2xl w-full mx-4 overflow-hidden shadow-2xl"
        onClick={e => e.stopPropagation()}
      >
        <img
          src={`https://picsum.photos/seed/${params.id}/800/600`}
          alt=""
          className="w-full"
        />
        <div className="p-4 flex justify-between items-center">
          <div>
            <h2 className="font-bold">Photo #{params.id}</h2>
            <p className="text-sm text-gray-500">Click background to close</p>
          </div>
          <button
            onClick={() => router.push(`/photos/${params.id}`)}
            className="text-sm text-blue-600 hover:underline"
          >
            Open Full Page
          </button>
        </div>
      </div>
    </div>
  );
}

// src/app/(feed)/page.tsx — Feed 流
import Link from "next/link";

export default function FeedPage() {
  const photos = Array.from({ length: 12 }, (_, i) => ({
    id: i + 1,
    url: `https://picsum.photos/seed/${i + 1}/400/400`,
    title: `Photo ${i + 1}`,
  }));

  return (
    <div className="max-w-6xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Photo Feed</h1>
      <div className="grid grid-cols-4 gap-4">
        {photos.map(photo => (
          <Link
            key={photo.id}
            href={`/photos/${photo.id}`}
            className="block aspect-square rounded-lg overflow-hidden bg-gray-100"
          >
            <img
              src={photo.url}
              alt={photo.title}
              className="w-full h-full object-cover hover:scale-105 transition-transform"
            />
          </Link>
        ))}
      </div>
    </div>
  );
}

// src/app/photos/[id]/page.tsx — 完整图片页面
export default async function PhotoPage({ params }) {
  const { id } = params;

  return (
    <div className="max-w-4xl mx-auto p-8">
      <a href="/" className="text-blue-600 hover:underline mb-4 inline-block">
        &larr; Back to Feed
      </a>
      <img
        src={`https://picsum.photos/seed/${id}/1200/800`}
        alt=""
        className="w-full rounded-lg shadow-lg"
      />
      <div className="mt-6">
        <h1 className="text-3xl font-bold">Photo #{id}</h1>
        <p className="text-gray-500 mt-2">
          This is the full-page view. Share this link directly with others.
        </p>
      </div>
    </div>
  );
}

// src/app/(dashboard)/layout.tsx — Dashboard 平行路由布局
export default function DashboardLayout({
  children,
  admin,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
}) {
  const role = "admin";

  return (
    <div className="max-w-6xl mx-auto p-8 flex gap-8">
      <div className="flex-1">{children}</div>
      <aside className="w-80">
        {role === "admin" && admin}
      </aside>
    </div>
  );
}

// src/app/(dashboard)/@admin/default.tsx
export default function AdminPanel() {
  return (
    <div className="bg-white rounded-xl shadow-sm border p-6 space-y-4">
      <h3 className="font-bold text-lg">Admin Panel</h3>
      <div className="space-y-2">
        <div className="flex justify-between">
          <span>Total Photos</span>
          <span className="font-bold">1,234</span>
        </div>
        <div className="flex justify-between">
          <span>Daily Uploads</span>
          <span className="font-bold text-green-600">+48</span>
        </div>
        <div className="flex justify-between">
          <span>Storage Used</span>
          <span className="font-bold">237 GB</span>
        </div>
      </div>
      <button className="w-full p-2 bg-blue-600 text-white rounded-lg">
        Manage Gallery
      </button>
    </div>
  );
}

// src/app/(dashboard)/page.tsx — Dashboard 首页
export default function DashboardPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <p className="text-gray-500 mt-2">Overview of your photo gallery</p>
      <div className="grid grid-cols-2 gap-4 mt-6">
        <div className="bg-white p-6 rounded-xl shadow-sm border">
          <p className="text-sm text-gray-500">This Week</p>
          <p className="text-3xl font-bold mt-1">342</p>
          <p className="text-sm text-green-600 mt-1">↑ 12% from last week</p>
        </div>
        <div className="bg-white p-6 rounded-xl shadow-sm border">
          <p className="text-sm text-gray-500">Total Views</p>
          <p className="text-3xl font-bold mt-1">89.4K</p>
          <p className="text-sm text-green-600 mt-1">↑ 8% from last month</p>
        </div>
      </div>
    </div>
  );
}

预期输出:

TEXT 📖 仅展示
1. 访问 / → 12 张图片网格
2. 点击图片 → 弹窗显示大图(URL: /photos/5)
3. 点击背景 → 关闭弹窗,回到 Feed
4. 刷新 → 显示完整 /photos/5 页面
5. 分享 /photos/5 → 朋友看到全页
6. 访问 /dashboard → 显示 Dashboard + Admin Panel
7. 从 Dashboard 点击图片 → 同样弹窗(拦截路由)

❓ 常见问题

Q 每个 @slot 都必须有 default.tsx 吗?
A 是的。如果没有 default.tsx,当当前 URL 没有匹配该 slot 的路由时,Next.js 会返回 404。即使你的 modal 插槽大多数时候不显示,也需要 export default function Default() { return null; }
Q Parallel Routes 的 slot 之间可以通信吗?
A 不能直接通信。每个 slot 是独立的 Server Component,无法共享状态。如果需要通信,可以通过 URL 参数(searchParams)传值,或在根 layout 中使用 Context Provider。
Q Intercepting Routes 的 (..) 是怎么计算层级的?
A (..) 基于文件系统的实际层级计算,但 Route Groups (group) 不消耗层级。例如 app/(feed)/photos/(.)[id]/page.tsx 拦截 app/(feed)/photos/[id]/page.tsx,因为 (feed) 不计算在内。
Q Parallel Routes 会影响页面性能吗?
A 会有一点影响,因为每个 slot 都是独立的 Server Component,需要独立渲染。但 Next.js 会自动并行化这些请求,比串行渲染快。建议:不要使用超过 3-4 个 slot,避免不必要的 slot。
Q 条件渲染应该用 Parallel Routes 还是 Client-side 判断?
A 推荐 Parallel Routes。虽然 Client-side 条件渲染(if (role === 'admin'))也能实现,但 Parallel Routes 让每个角色的视图文件独立、类型安全、便于测试和代码分割。

📖 小节


📝 作业

  1. 基础题(⭐):在项目中创建一个 @modal 插槽,实现"点击按钮 → 弹窗显示一段文本"的基本功能(使用 default.tsx 返回 null)。

  2. 进阶题(⭐⭐):创建一个图片浏览系统:Feed 流页面(/)显示图片网格,点击图片时弹窗显示(拦截 (.)photos/[id]),直接访问 /photos/1 时显示完整页面。验证后退按钮和刷新行为。

  3. 挑战题(⭐⭐⭐):实现一个角色驱动的 Dashboard 系统:创建 @admin@editor@viewer 三个插槽,每种角色显示不同的面板内容(管理员显示系统监控,编辑者显示草稿统计,查看者显示只读提示),通过切换 role 变量验证条件渲染效果。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏