更新时间:2026 年 6 月 4 日
Parallel Routes(并行路由)和 Intercepting Routes(拦截路由)是 Next.js App Router 提供的两种高级路由模式:前者允许在同一布局中同时渲染多个独立的页面 slot(带各自的加载、错误状态),后者允许在不离开当前上下文的前提下"拦截"一个 URL 并以模态框等替代视图呈现它,刷新页面仍会回到完整页面形态。配合使用,它们能在不引入任何客户端路由库的情况下,原生实现仪表盘多视图、可分享的模态框、图片画廊详情等典型产品体验。本文以 Next.js 15.4 为基础,从底层文件约定一路讲到生产可用的实战代码与调试技巧。说实话,这两个特性我在最近一个 SaaS 仪表盘项目里踩过不少坑,下面会把这些经验也一并写进来。
- Parallel Routes 通过
@folder 命名约定为同一 layout 注入多个命名 slot,slot 之间的导航、加载和错误状态完全独立。
- Intercepting Routes 使用
(.)、(..)、(..)(..)、(...) 四种相对前缀,决定从哪个层级"截胡"目标路由。
- 两者经常组合使用:用 Intercepting 把
/photo/123 拦截进 Parallel slot 中渲染为模态框,同时保留直接访问该 URL 时的完整页面体验。
- 每个 slot 都需要一个
default.js,否则在硬刷新或不匹配场景下会渲染 404。
- Parallel Routes 与 React Suspense、
loading.tsx、error.tsx 天然融合,适合搭建条件式 UI 和 A/B 试验布局。
- Turbopack(Next.js 15.4 默认)对并行 slot 的 HMR 已稳定,是 2026 年生产配置的推荐选项。
什么是 Parallel Routes?
Parallel Routes 是 App Router 的一种文件系统约定,让你在 同一个 layout 中并行渲染多个独立的页面树。每个并行视图被称为一个 slot,使用以 @ 开头的目录命名,例如 @analytics、@team。slot 不会出现在 URL 中,它们只是 layout 组件接收的额外 props。
这与传统的"主内容 + 侧栏"布局相比有本质区别。每个 slot 拥有自己的 loading.tsx、error.tsx、not-found.tsx,甚至自己的 page.tsx。换句话说,分析卡片的加载错误不会影响团队列表,反之亦然。这种"独立故障域"在搭建管理后台、复杂报表、或基于角色的条件式布局时尤其有用。
来看一个最小目录结构。
app/
├── @analytics/
│ ├── loading.tsx
│ ├── error.tsx
│ └── page.tsx
├── @team/
│ ├── loading.tsx
│ └── page.tsx
├── layout.tsx
└── page.tsx
对应的 layout 接收两个具名 slot 作为 props,并自行决定如何排版:
// app/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12 gap-6">
<main className="col-span-8">{children}</main>
<aside className="col-span-4 space-y-6">
{analytics}
{team}
</aside>
</div>
);
}
Next.js 会基于 URL 自动为每个 slot 解析合适的 page.tsx。如果 URL 与某个 slot 的路由匹配(比如 @analytics 内部还有更深的 [campaign]/page.tsx),它就会切换;其他 slot 则保持当前状态。这就是"并行"的真正含义:多个独立的导航栈共存于同一 layout 中。
什么是 Intercepting Routes?
Intercepting Routes(拦截路由)允许你在当前 layout 内"截胡"一个本应跳转到独立页面的 URL,从而在不离开当前上下文的情况下渲染替代视图。最经典的例子:用户在订阅列表点击一张照片缩略图,照片以模态框形式覆盖在列表上,URL 同时变为 /photos/123;但如果用户直接把这个 URL 发给朋友,朋友打开时看到的是完整的照片详情页,而不是模态框。
拦截通过四个相对路径前缀实现。
| 约定 | 含义 | 典型用途 |
(.) | 拦截同一层级的路由 | 列表项的内联编辑模态框 |
(..) | 拦截上一层级的路由 | 分类页拦截详情页 |
(..)(..) | 拦截上上一层级的路由 | 深层嵌套场景 |
(...) | 从根 app 目录拦截 | 全局图片预览、登录弹窗 |
这些前缀是基于路由段而非物理文件路径的,这一点是初学者最容易踩坑的地方(我自己第一次也数错了层级)。Route Groups(圆括号目录如 (marketing))和私有文件夹(_components)都不计入层级。Next.js 团队在 Intercepting Routes 官方文档中明确指出了这一约定。
拦截只在客户端导航(<Link> 或 router.push)时生效。当用户硬刷新、直接粘贴 URL,或从外部链接打开时,Next.js 会绕过拦截、直接渲染目标路由本身。这是"可分享模态框"模式得以成立的根本机制。
Parallel Routes 与 Intercepting Routes 有什么区别?
两者经常被混淆,但解决的是不同的问题。Parallel Routes 解决的是"我希望同一个 layout 里有多个独立的内容区",它影响的是布局组成;Intercepting Routes 解决的是"我希望这个 URL 在不同来源下展示不同的 UI",它影响的是渲染来源选择。
真正强大的地方在于二者组合:
- Parallel Routes 提供一个名为
@modal 的 slot,挂在根 layout 上。
- Intercepting Routes 把
/photo/[id] 拦截进 @modal/(.)photo/[id]/page.tsx。
- 客户端导航时,
@modal 渲染为浮层;硬刷新时,@modal 渲染 default.tsx(通常为 null),而主 children 渲染完整的 /photo/[id]/page.tsx。
除了模态框,这种组合还能用来构建:抽屉式编辑器、登录/注册弹窗、命令面板(Command Palette)、图片画廊详情视图,以及多步骤向导中的"详情预览"层。
如何用 Parallel Routes 构建多视图仪表盘?
设想一个 SaaS 仪表盘。左侧是项目列表(主 children),右侧栏要同时展示团队成员状态和今日指标,两个右侧栏需要各自独立加载与缓存。下面是完整实现,配合数据获取与缓存策略中的 use cache 指令使用效果最佳。
// app/dashboard/@analytics/page.tsx
import { Suspense } from "react";
import { AnalyticsChart } from "@/components/analytics-chart";
async function getMetrics() {
"use cache";
const res = await fetch("https://api.example.com/metrics", {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error("Metrics unavailable");
return res.json();
}
export default async function AnalyticsSlot() {
const metrics = await getMetrics();
return (
<section className="rounded-xl border p-4">
<h2 className="text-lg font-semibold">今日指标</h2>
<AnalyticsChart data={metrics} />
</section>
);
}
// app/dashboard/@analytics/loading.tsx
export default function Loading() {
return <div className="h-48 animate-pulse rounded-xl bg-muted" />;
}
// app/dashboard/@analytics/error.tsx
"use client";
export default function Error({ reset }: { reset: () => void }) {
return (
<div className="rounded-xl border border-destructive p-4">
<p>指标加载失败</p>
<button onClick={reset}>重试</button>
</div>
);
}
关键点:@analytics 的 error.tsx 只会捕获该 slot 内部抛出的错误,团队 slot 和主内容都不受影响。这就是 Parallel Routes 的"故障隔离"红利。如果你曾用 React Router 或自建状态管理实现过类似效果,对比之下会发现 App Router 几乎不需要任何客户端代码。我之前在一个旧项目里用 React Router + Redux 拼凑过这种独立加载状态,那套方案大概有四百行胶水代码;换成 App Router 后,整体行数砍掉了一半还多。
如何用 Intercepting Routes 构建可分享的模态框?
下面把图片画廊场景完整跑通。目录结构如下:
app/
├── @modal/
│ ├── default.tsx // 返回 null
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx // 模态框形态
├── photo/
│ └── [id]/
│ └── page.tsx // 完整页面形态
├── layout.tsx
├── default.tsx // 主 children 的兜底
└── page.tsx // 列表
根 layout 渲染 children 与 modal 两个 slot:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="zh">
<body>
{children}
{modal}
<div id="modal-root" />
</body>
</html>
);
}
列表页使用普通的 <Link> 跳转,客户端导航会自动触发拦截。
// app/page.tsx
import Link from "next/link";
import { getPhotos } from "@/lib/photos";
export default async function GalleryPage() {
const photos = await getPhotos();
return (
<ul className="grid grid-cols-3 gap-4">
{photos.map((p) => (
<li key={p.id}>
<Link href={`/photo/${p.id}`} scroll={false}>
<img src={p.thumb} alt={p.title} />
</Link>
</li>
))}
</ul>
);
}
模态框形态(被拦截后渲染):
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from "@/components/modal";
import { getPhoto } from "@/lib/photos";
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.full} alt={photo.title} />
<h2>{photo.title}</h2>
</Modal>
);
}
完整页形态(硬刷新或直接访问时渲染):
// app/photo/[id]/page.tsx
import { getPhoto } from "@/lib/photos";
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<article className="mx-auto max-w-3xl py-8">
<img src={photo.full} alt={photo.title} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</article>
);
}
Modal 组件本身是一个客户端组件,使用 useRouter().back() 关闭浮层,从而让浏览器历史栈保持自然。这一切只用文件约定就能完成,无需任何全局 portal 管理库。
为什么我的 slot 在刷新后变成 404?
这是使用 Parallel Routes 最常见的问题(我第一次上线就栽在这里,调试了两小时才意识到根本原因)。Next.js 在客户端导航时会保留 slot 的当前状态("软导航"),但硬刷新(或首次访问)时,每个 slot 必须能从 URL 推断出渲染内容。如果当前 URL 与某个 slot 内的任何 page.tsx 都不匹配,Next.js 就会查找该 slot 的 default.tsx。找不到则整页 404。
解决方法是给每一个 slot(包括根 children)添加 default.tsx。对模态框这种"默认无内容"的 slot,让它返回 null 即可。
// app/@modal/default.tsx
export default function Default() {
return null;
}
对内容型 slot(如 @analytics),可以让 default.tsx 直接复用 page.tsx,以保证刷新后视图不变:
// app/dashboard/@analytics/default.tsx
export { default } from "./page";
与 Suspense、loading.tsx、error.tsx 协同
Parallel Routes 的 slot 是天然的 Suspense 边界。每个 slot 的 loading.tsx 本质上是一个被框架自动注入的 <Suspense fallback>。这意味着不同 slot 的数据请求会并发发起、独立流式返回。在 Next.js 15.4 中,配合 Parallel Routes 官方指南推荐的 PPR(Partial Prerendering),可以让静态外壳几乎瞬时呈现,动态 slot 通过 RSC payload 增量到达。
错误处理同理。每个 slot 的 error.tsx 是一个独立的 Error Boundary,重置时只重渲染该 slot。这与认证纵深防御中讨论的"权限相关 UI 隔离"思路完全一致:某个权限校验失败的 slot 可以独立报错,不会让整页都变成错误页。
如果你需要更细粒度的流式控制,可以在 slot 内部继续嵌套 <Suspense>,配合 React 19 的 use() Hook 把 Promise 推迟到组件树深处。
// app/dashboard/@team/page.tsx
import { Suspense } from "react";
import { TeamHeader } from "@/components/team-header";
import { TeamList } from "@/components/team-list";
import { getTeam } from "@/lib/team";
export default function TeamSlot() {
const teamPromise = getTeam(); // 不 await
return (
<section>
<TeamHeader />
<Suspense fallback={<p>加载成员...</p>}>
<TeamList promise={teamPromise} />
</Suspense>
</section>
);
}
常见陷阱与调试技巧
从我自己和团队成员的生产案例里,下面这几类问题最容易让人卡住。
1. 拦截路径写错了层级
记住前缀是基于路由段而非文件路径。例如 app/(shop)/products/page.tsx 想拦截 app/cart/page.tsx,因为 (shop) 是 Route Group 不算层级,所以应该用 (..)cart 而不是 (...)cart。判断方法:在脑中把所有圆括号目录折叠掉,再数 ..。
2. 用 <a> 而非 <Link>
Intercepting Routes 只在客户端导航生效。原生 <a> 会触发整页跳转,绕过拦截,体验立刻穿帮。任何"应该出现模态框"的入口必须使用 next/link 或 useRouter()。
3. 模态框内的表单提交后没回退
提交完成后调用 router.back(),浏览器会回到列表 URL 且 slot 恢复为 default.tsx。如果你用 router.push('/') 会丢失历史栈中的过滤参数。模态框内的服务端逻辑可以使用 Server Actions 实现,提交后用 revalidatePath 同步列表。
4. 在 slot 间共享状态
slot 不能直接互传 props。需要跨 slot 同步状态时,使用 URL search params 或服务端获取的共享数据。React 19 的 cache() 配合 React Server Components,可以让同一请求内的多个 slot 复用同一份数据获取结果。React 团队在 React cache() API 文档中给出了完整模式。
5. Turbopack vs Webpack 行为差异
Next.js 15.4 已将 Turbopack 设为开发默认。Parallel + Intercepting 的边缘场景(如带 catch-all 段的拦截)在早期 Turbopack 版本曾出现匹配差异,升级到 15.4 后已对齐 Webpack。生产构建仍建议先跑 next build 验证 slot 解析无警告。
常见问题
Parallel Routes 会增加客户端 bundle 大小吗?
不会显著增加。每个 slot 的页面组件遵循 RSC 的代码分割策略,只有进入交互态的客户端组件才会被打包到 bundle。slot 本身只是 layout 接收的 props,没有运行时开销。
Intercepting Routes 支持 SEO 吗?
支持。爬虫请求会被视为"首次访问",因此渲染的是完整页面形态(/photo/[id]/page.tsx),而不是模态框。这正是该模式相比纯客户端模态框的核心优势:可索引、可分享、可深链。
能在 Pages Router 项目里使用 Parallel Routes 吗?
不能。Parallel Routes 与 Intercepting Routes 都是 App Router 专属特性,依赖 RSC 与基于文件夹的路由约定。Pages Router 中需要靠状态管理或第三方模态库来近似实现。
如何在拦截路由中处理认证?
在 slot 的 page.tsx 内部按常规方式调用 auth() 或读取 session,未授权时抛错或 redirect()。由于每个 slot 有独立 error boundary,未授权 UI 不会污染主内容区。结合中间件做粗粒度拦截后,slot 层再做细粒度判断是推荐模式。
能在同一个 layout 里嵌套多层 Parallel Routes 吗?
可以。slot 内部可以继续定义自己的 layout,再在其中声明新的 @xxx slot。常见于"标签页内嵌侧栏"或"仪表盘内嵌报表对比"等场景。注意每一层都要补 default.tsx,否则深层路径刷新会 404。