跳到主要内容

并行路由

并行路由(Parallel Routes)允许你在同一布局中同时或条件性地渲染一个或多个页面。这对于仪表盘、社交网站动态等高度动态的应用区域特别有用。

核心概念

什么是并行路由?

传统路由一次只能渲染一个页面,而并行路由打破了这一限制。它允许你将页面分成多个独立的"插槽"(Slot),每个插槽可以独立渲染不同的内容。

举个直观的例子:一个仪表盘页面可能同时展示团队信息和数据分析。传统方式需要在一个页面组件中获取并渲染所有数据,而并行路由允许你将它们分成两个独立的插槽,各自独立加载和渲染。

为什么需要并行路由?

并行路由解决了以下问题:

  • 独立加载状态:不同区域可以有自己的加载状态,不会因为一个区域加载慢而阻塞整个页面
  • 独立错误处理:某个区域出错不会影响其他区域的正常显示
  • 条件渲染:可以根据用户角色或其他条件决定渲染哪个插槽的内容
  • 保持上下文:在导航时可以保持某些区域的状态不变

命名插槽约定

基本语法

并行路由使用 @文件夹名 的命名约定来定义插槽:

app/
├── layout.tsx
├── page.tsx
├── @team/
│ ├── page.tsx
│ └── settings/
│ └── page.tsx
└── @analytics/
├── page.tsx
└── page-views/
└── page.tsx

在这个结构中:

  • @team@analytics 是两个命名插槽
  • 这些文件夹名称中的 @ 前缀告诉 Next.js 这是一个插槽,而不是路由段

重要特性

命名插槽有几个关键特性需要理解:

不影响 URL 结构:插槽不会出现在 URL 中。例如,访问 /settings 时,URL 仍然是 /settings,而不是 /@team/settings

传递给布局作为 props:每个插槽会作为独立的 prop 传递给父布局组件。这意味着你可以在布局中完全控制每个插槽的渲染位置和方式。

可以与普通页面并存:插槽内的 page.tsx 与普通的 page.tsx 共同组成最终页面。同一路由段级别的所有插槽必须保持一致的渲染模式(要么都是静态的,要么都是动态的)。

在布局中使用插槽

接收插槽 Props

父布局组件通过 props 接收各个插槽:

// app/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="dashboard">
{/* 主内容区域 */}
<main>{children}</main>

{/* 团队信息插槽 */}
<aside className="team-section">
{team}
</aside>

{/* 分析数据插槽 */}
<aside className="analytics-section">
{analytics}
</aside>
</div>
);
}

这里 children 是一个隐式插槽,它对应 app/page.tsx。你不需要为它创建 @children 文件夹。

插槽内容定义

每个插槽内部可以有完整的路由结构:

// app/@team/page.tsx
async function getTeamMembers() {
const res = await fetch("https://api.example.com/team");
return res.json();
}

export default async function TeamSlot() {
const members = await getTeamMembers();

return (
<div className="team-card">
<h2>团队成员</h2>
<ul>
{members.map((member) => (
<li key={member.id}>{member.name}</li>
))}
</ul>
</div>
);
}
// app/@analytics/page.tsx
async function getAnalytics() {
const res = await fetch("https://api.example.com/analytics", {
next: { revalidate: 60 }, // 每分钟重新验证
});
return res.json();
}

export default async function AnalyticsSlot() {
const data = await getAnalytics();

return (
<div className="analytics-card">
<h2>数据分析</h2>
<div className="stats">
<div>访问量: {data.views}</div>
<div>用户数: {data.users}</div>
</div>
</div>
);
}

default.tsx 文件

为什么需要 default.tsx?

当用户首次加载页面或刷新页面时,Next.js 需要知道每个插槽应该渲染什么内容。如果某个插槽没有匹配当前路由的页面,就需要一个回退组件。

考虑这个场景:

app/
├── layout.tsx
├── @team/
│ ├── page.tsx
│ └── settings/
│ └── page.tsx
└── @analytics/
└── page.tsx

当用户导航到 /settings 时:

  • @team 插槽有 settings/page.tsx,可以正常渲染
  • @analytics 插槽没有 settings/page.tsx,需要回退

定义默认组件

创建 default.tsx 来处理未匹配的路由:

// app/@analytics/default.tsx
export default function Default() {
return (
<div className="analytics-placeholder">
<p>当前页面没有分析数据</p>
</div>
);
}

如果没有 default.tsx 且插槽未匹配,Next.js 会渲染 404 页面。这个设计是为了防止意外在不该出现的页面上渲染并行路由内容。

children 插槽的 default.tsx

由于 children 也是一个隐式插槽,当父页面无法恢复活动状态时,也需要 default.tsx

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

软导航与硬导航

理解这两种导航方式的区别对于正确使用并行路由至关重要。

软导航(客户端导航)

当用户通过 <Link> 组件或 router.push() 导航时,Next.js 会进行部分渲染:

  • 更新目标插槽的子页面
  • 保持其他插槽的活动状态不变

这意味着在软导航时,用户可以看到不同插槽显示来自不同路由的内容。这在仪表盘场景中特别有用——你可以在更新团队设置时,保持分析数据不变。

硬导航(页面刷新)

当用户刷新页面或直接访问 URL 时,Next.js 无法确定未匹配插槽的活动状态。此时:

  • 渲染 default.tsx 作为回退
  • 如果没有 default.tsx,返回 404

这种差异是有意设计的。软导航保持上下文,硬导航提供一致的初始状态。

实战场景

条件路由

根据用户角色显示不同的仪表盘内容:

// app/layout.tsx
import { getCurrentUser } from "@/lib/auth";

export default async function DashboardLayout({
user,
admin,
}: {
user: React.ReactNode;
admin: React.ReactNode;
}) {
const currentUser = await getCurrentUser();

// 根据角色选择渲染哪个插槽
return (
<div className="dashboard-container">
{currentUser.role === "admin" ? admin : user}
</div>
);
}
app/
├── layout.tsx
├── @user/
│ └── page.tsx → 普通用户仪表盘
└── @admin/
└── page.tsx → 管理员仪表盘

标签组导航

在插槽内部创建独立的标签导航:

app/
├── layout.tsx
├── @analytics/
│ ├── layout.tsx → 分析标签布局
│ ├── page.tsx → 默认分析页面
│ ├── page-views/
│ │ └── page.tsx → 页面访问统计
│ └── visitors/
│ └── page.tsx → 访客统计
// app/@analytics/layout.tsx
import Link from "next/link";

export default function AnalyticsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="analytics-container">
<nav className="tabs">
<Link href="/page-views">页面访问</Link>
<Link href="/visitors">访客统计</Link>
</nav>
<div className="content">{children}</div>
</div>
);
}

独立的加载和错误状态

每个插槽可以有独立的 loading.tsxerror.tsx

app/
├── layout.tsx
├── @team/
│ ├── page.tsx
│ ├── loading.tsx → 团队加载状态
│ └── error.tsx → 团队错误状态
└── @analytics/
├── page.tsx
├── loading.tsx → 分析加载状态
└── error.tsx → 分析错误状态
// app/@team/loading.tsx
export default function TeamLoading() {
return (
<div className="skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-text" />
<div className="skeleton-text short" />
</div>
);
}
// app/@analytics/error.tsx
"use client";

export default function AnalyticsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="error-card">
<h3>无法加载分析数据</h3>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
);
}

useSelectedLayoutSegment

在客户端组件中,你可以使用 useSelectedLayoutSegmentuseSelectedLayoutSegments 来获取插槽当前活动的路由段:

"use client";

import { useSelectedLayoutSegment } from "next/navigation";

export default function AuthSlot({ auth }: { auth: React.ReactNode }) {
// 获取 @auth 插槽的活动路由段
const activeSegment = useSelectedLayoutSegment("auth");

return (
<div className={activeSegment === "login" ? "auth-modal" : ""}>
{auth}
</div>
);
}

当用户导航到 @auth/login(URL 显示为 /login)时,activeSegment 将是 "login"

与拦截路由配合使用

并行路由与拦截路由结合可以创建强大的模态框体验。这在需要深度链接的模态框场景中特别有用,比如:

  • 从图库打开图片详情模态框
  • 从导航栏打开登录模态框
  • 从商品列表打开购物车侧边栏

详细实现请参考拦截路由章节。

小结

本章我们学习了:

  1. 并行路由的核心概念:命名插槽、独立渲染
  2. @文件夹名 命名约定及其不影响 URL 的特性
  3. 如何在布局中接收和使用插槽 props
  4. default.tsx 的作用和使用场景
  5. 软导航和硬导航的行为差异
  6. 条件路由、标签组导航等实战场景
  7. 独立的加载和错误状态处理

练习

  1. 创建一个仪表盘布局,使用并行路由同时显示用户信息和通知列表
  2. 实现一个根据用户角色显示不同内容的条件路由
  3. 为每个插槽添加独立的加载骨架屏和错误处理
  4. 结合标签导航实现一个可切换的分析面板