拦截路由与平行路由:高级 UI 模式
第15章:拦截路由与平行路由:高级 UI 模式
点击图片弹出模态框,URL 变了但背景列表还在;刷新页面则全屏展示图片。这种“同 URL 双视图”模式完全在路由层面实现,无需客户端状态。
本章核心问题:拦截路由的 (.) 语法如何工作?平行路由的 @slot 如何实现独立加载?软导航与硬导航的行为有何区别?
读完本章你将理解:
- @slot 平行路由的独立加载、错误处理与 default.tsx 回退
- 拦截路由 (.) (..) (...) 的层级计算规则
- Instagram 风格图片模态框的完整实现
Level 1 · 你需要知道的(1-3年经验)
两种路由模式解决的本质问题
现代 Web 应用中有一类经典交互:点击图片列表中的某张图片,弹出一个模态框展示大图,而背景列表保持不变;如果用户刷新页面或直接访问图片 URL,则全屏展示该图片。Instagram、Pinterest、GitHub 的文件预览都采用这种模式。
这个看似简单的需求其实包含三个相互冲突的约束:URL 必须能分享(图片有独立 URL)、内容必须能直接访问(URL 打开是完整页面)、从列表进入时必须显示模态框(不是跳转到新页面)。传统路由方案要么牺牲 URL 分享能力,要么用复杂的全局状态管理来模拟。Next.js 的拦截路由与平行路由组合解决了这个问题,且完全在路由层面实现,无需客户端状态。
平行路由:@slot 语法
平行路由允许在同一个布局中同时渲染多个页面。语法是在文件夹名前加 @ 前缀,这样的文件夹称为 slot(插槽)。
app/
layout.tsx ← 接收所有 slot 作为 props
page.tsx
@modal/
default.tsx ← 当该 slot 无匹配路由时显示
(.)photos/
[id]/
page.tsx ← 拦截路由:显示模态框
photos/
[id]/
page.tsx ← 直接访问:显示完整页面
根布局接收每个 slot 作为独立的 prop:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
@modal 不是 URL 的一部分,它只是布局接收内容的通道。用户不会看到 URL 中出现 @modal。
default.tsx:未匹配时的占位符
平行路由的一个关键细节是 default.tsx 文件。当路由导航时,某个 slot 没有匹配的内容,Next.js 会查找该 slot 下的 default.tsx 并渲染它。如果连 default.tsx 也没有,Next.js 会渲染 404。
对于模态框场景,我们希望大多数时候 @modal slot 不显示任何内容:
// app/@modal/default.tsx
export default function ModalDefault() {
return null // 什么都不渲染
}
这使得 @modal 在常规页面访问时透明,只在拦截路由激活时才出现内容。
Level 2 · 它是怎么运行的(3-5年经验)
拦截路由:(.) (..) (...) 语法
拦截路由使用特殊括号前缀来声明"从哪个层级拦截":
| 语法 | 含义 |
|---|---|
(.)segment |
拦截同级路由 |
(..)segment |
拦截父级路由(上一层) |
(..)(..)segment |
拦截上两层的路由 |
(...)segment |
从根目录拦截 |
这里的"拦截"意思是:当用户通过客户端导航(点击链接)到达该路由时,显示拦截路由中定义的内容;当用户直接访问 URL(刷新、粘贴链接)时,显示正常路由中的内容。
在我们的案例中,@modal/(.)photos/[id]/page.tsx 中的 (.) 表示它拦截与自己同级(都在 app 根目录下)的 /photos/[id] 路由:
app/
@modal/
(.)photos/ ← 这里的 (.) 指向 app/ 根级别的 photos
[id]/
page.tsx
photos/ ← 被拦截的原始路由
[id]/
page.tsx
构建 Instagram 风格的图片模态框:完整实现
图片列表页
// app/photos/page.tsx
import Link from 'next/link'
import { getPhotos } from '@/lib/photos'
export default async function PhotosPage() {
const photos = await getPhotos()
return (
<div className="grid grid-cols-3 gap-4 p-8">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img
src={photo.thumbnailUrl}
alt={photo.title}
className="w-full aspect-square object-cover rounded-lg cursor-pointer
hover:opacity-80 transition-opacity"
/>
</Link>
))}
</div>
)
}
图片完整页面(直接访问 URL 时显示)
// app/photos/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ id: string }>
}
export default async function PhotoPage({ params }: Props) {
const { id } = await params
const photo = await getPhoto(id)
if (!photo) notFound()
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-black">
<img
src={photo.url}
alt={photo.title}
className="max-w-4xl max-h-screen object-contain"
/>
<h1 className="text-white mt-4 text-xl">{photo.title}</h1>
</div>
)
}
拦截路由中的模态框(客户端导航时显示)
// app/@modal/(.)photos/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
import { notFound } from 'next/navigation'
import { PhotoModal } from '@/components/PhotoModal'
interface Props {
params: Promise<{ id: string }>
}
export default async function PhotoModalPage({ params }: Props) {
const { id } = await params
const photo = await getPhoto(id)
if (!photo) notFound()
return <PhotoModal photo={photo} />
}
模态框组件(客户端组件,处理关闭逻辑)
// components/PhotoModal.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useCallback } from 'react'
import type { Photo } from '@/lib/photos'
interface PhotoModalProps {
photo: Photo
}
export function PhotoModal({ photo }: PhotoModalProps) {
const router = useRouter()
const handleClose = useCallback(() => {
router.back()
}, [router])
// ESC 键关闭
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
return (
<>
{/* 遮罩层 */}
<div
className="fixed inset-0 bg-black/80 z-40 cursor-pointer"
onClick={handleClose}
/>
{/* 模态框内容 */}
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<div
className="relative bg-white rounded-xl overflow-hidden max-w-3xl w-full
mx-4 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
className="absolute top-4 right-4 text-gray-500 hover:text-gray-900 z-10"
aria-label="关闭"
>
✕
</button>
<img
src={photo.url}
alt={photo.title}
className="w-full object-cover"
/>
<div className="p-6">
<h2 className="text-2xl font-bold">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
</div>
</>
)
}
关闭模态框时调用 router.back() 而不是 router.push('/') 的原因:我们希望回到用户打开模态框之前的那个页面,而不是硬编码跳转到首页。这样无论用户从哪个页面打开模态框,关闭时都能正确返回。
Level 3 · 规范怎么定义的(资深)
平行路由用于仪表盘布局
除了模态框,平行路由还适合需要同时展示多个独立内容区域的仪表盘场景。每个 slot 可以独立地加载、错误处理和 Suspense:
app/
dashboard/
layout.tsx
page.tsx
@analytics/
page.tsx ← 分析数据
loading.tsx ← 独立的 loading 状态
@revenue/
page.tsx ← 营收数据
loading.tsx
@notifications/
page.tsx ← 通知列表
error.tsx ← 独立的错误边界
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
revenue,
notifications,
}: {
children: React.ReactNode
analytics: React.ReactNode
revenue: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow px-8 py-4">
<h1 className="text-2xl font-bold">仪表盘</h1>
</header>
<main className="p-8 grid grid-cols-12 gap-6">
<div className="col-span-8">{children}</div>
<aside className="col-span-4 space-y-6">
{analytics}
{revenue}
{notifications}
</aside>
</main>
</div>
)
}
每个 slot 拥有独立的 loading.tsx 和 error.tsx。这意味着分析数据加载缓慢时,营收数据和通知列表不会被阻塞,用户可以先与已加载的内容交互。这比用单一 Suspense 包裹整个页面的用户体验好得多。
条件渲染:基于 Tab 的内容切换
平行路由还可以实现基于 URL 的条件内容渲染:
app/
dashboard/
@tab/
overview/
page.tsx
settings/
page.tsx
default.tsx ← 默认显示 overview 内容
通过检查当前路由来决定哪个 tab 高亮,完全不需要客户端状态:
// app/dashboard/layout.tsx
import Link from 'next/link'
export default function DashboardLayout({
children,
tab,
}: {
children: React.ReactNode
tab: React.ReactNode
}) {
return (
<div>
<nav className="flex gap-4 border-b pb-2 mb-6">
<Link href="/dashboard/overview">概览</Link>
<Link href="/dashboard/settings">设置</Link>
</nav>
{tab || children}
</div>
)
}
导航行为的细节
理解拦截路由和平行路由的导航行为对于避免 bug 至关重要:
软导航(客户端导航):用户点击 <Link> 或调用 router.push() 时,Next.js 只加载变化的部分。平行路由的其他 slot 保持当前状态。拦截路由生效。
硬导航(全页刷新 / 直接 URL 访问):Next.js 从服务器重新获取整个页面。所有 slot 都重新渲染。拦截路由不生效,显示原始路由内容。
这个区别解释了为什么图片模态框在刷新后消失——刷新触发了硬导航,@modal slot 没有匹配的路由,显示 default.tsx(即 null),而 photos/[id]/page.tsx 直接渲染完整页面。这正是我们想要的行为。
与 loading.tsx 和 error.tsx 的集成
每个平行路由 slot 都可以拥有自己的 loading.tsx 和 error.tsx,这是细粒度错误处理的基础:
// app/@modal/(.)photos/[id]/loading.tsx
export default function ModalLoading() {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="bg-white rounded-xl p-8 shadow-2xl">
<div className="animate-spin w-8 h-8 border-4 border-blue-500
border-t-transparent rounded-full mx-auto" />
</div>
</div>
)
}
// app/dashboard/@analytics/error.tsx
'use client'
export default function AnalyticsError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-600">分析数据加载失败</p>
<button
onClick={reset}
className="mt-2 text-sm text-red-500 underline"
>
重试
</button>
</div>
)
}
常见陷阱与调试技巧
陷阱一:(.) 层级计算错误。(.) 的层级是相对于 slot 文件夹所在位置计算的,而不是相对于 app 根目录。@modal/(.)photos 中的 (.) 指向与 @modal 同级的 photos 文件夹,也就是 app/photos。
陷阱二:忘记 default.tsx。如果一个 slot 没有 default.tsx,当该 slot 没有匹配路由时,整个页面会变成 404。始终为每个 slot 提供 default.tsx。
陷阱三:在模态框中修改数据后不刷新底层列表。用 Server Actions 提交数据后,调用 router.refresh() 可以让 Next.js 重新获取当前页面的服务端数据而不触发全页重新加载:
'use client'
import { useRouter } from 'next/navigation'
export function DeletePhotoButton({ photoId }: { photoId: string }) {
const router = useRouter()
async function handleDelete() {
await fetch(`/api/photos/${photoId}`, { method: 'DELETE' })
router.back() // 关闭模态框
router.refresh() // 刷新底层列表数据
}
return <button onClick={handleDelete}>删除</button>
}
Level 4 · 边界与陷阱(所有人)
陷阱1:(.) 的层级是相对于 slot 文件夹所在位置计算的,而不是相对于 app 根目录——@modal/(.)photos 中的 (.) 指向与 @modal 同级的 photos 文件夹。
陷阱2:忘记 default.tsx 会导致整个页面 404——始终为每个 slot 提供 default.tsx。
陷阱3:在模态框中修改数据后不刷新底层列表——需要在 Server Action 后调用 router.back() + router.refresh()。
小结
拦截路由与平行路由是 App Router 中最具表现力的路由特性,它们将原本需要大量客户端状态管理的 UI 模式(模态框、仪表盘分区、条件内容)提升到了路由层面。核心设计哲学是:URL 是真实来源,UI 状态由路由结构决定,而不是由 React 状态决定。正确使用这两个特性需要仔细理解软导航与硬导航的区别,以及 default.tsx 在 slot 无匹配时的兜底作用。