拦截路由
拦截路由(Intercepting Routes)允许你在当前布局中加载另一个路由的内容,而不需要用户切换到不同的上下文。这种路由模式在需要保持用户当前浏览上下文同时展示详细内容的场景中非常有用。
核心概念
什么是拦截路由?
拦截路由是一种特殊的路由机制,它可以在软导航(客户端导航)时"拦截"目标路由,在当前布局中以特定方式(如模态框)展示内容,而在硬导航(页面刷新或直接访问)时渲染正常的页面。
用一个具体的例子来理解:在社交媒体上浏览图片动态时,点击一张图片:
- 软导航:图片在模态框中打开,背景仍然是动态列表
- 硬导航:直接访问该图片的 URL,显示完整的图片详情页面
这两种体验对应同一个 URL,但呈现方式不同。这就是拦截路由的价值。
使用场景
拦截路由特别适合以下场景:
- 模态框:在列表页打开详情模态框,同时支持 URL 分享
- 侧边栏:在不离开当前页面的情况下打开侧边详情面板
- 登录框:从任何页面打开登录模态框,同时有独立的登录页面
- 购物车:在浏览商品时打开购物车侧边栏
命名约定
路径约定
拦截路由使用 (..) 约定来定义,类似于文件系统中的相对路径 ../,但作用于路由段:
| 约定 | 含义 | 示例 |
|---|---|---|
(.) | 匹配同级路由段 | (.)photo 拦截同级的 photo |
(..) | 匹配上一级路由段 | (..)photo 拦截上一级的 photo |
(..)(..) | 匹配上两级路由段 | (..)(..)photo 拦截上两级的 photo |
(...) | 从根目录匹配 | (...)photo 从 app 目录匹配 |
重要说明
这个约定基于路由段,而不是文件系统路径。特别要注意:
- 插槽文件夹(如
@modal)不被视为路由段 - 路由组文件夹(如
(marketing))也不被视为路由段
目录结构示例
假设我们想在图库页面拦截照片路由:
app/
├── layout.tsx
├── page.tsx → 首页
├── feed/
│ └── page.tsx → /feed(图库页面)
├── photo/
│ └── [id]/
│ └── page.tsx → /photo/:id(照片详情页)
└── @modal/
├── default.tsx
└── (.)photo/
└── [id]/
└── page.tsx → 拦截 /photo/:id
在这个结构中:
feed/page.tsx是图库页面photo/[id]/page.tsx是完整的照片详情页@modal/(.)photo/[id]/page.tsx是拦截版本,用于模态框
由于 @modal 是插槽而不是路由段,从 @modal 到 photo 只需要跨一个路由段级别,所以使用 (.) 而不是 (..)。
实现模态框
基本结构
让我们完整实现一个图库模态框示例:
app/
├── layout.tsx
├── @modal/
│ ├── default.tsx
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
└── feed/
└── page.tsx
根布局
布局需要接收模态框插槽并渲染:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
{children}
{modal}
</body>
</html>
);
}
模态框默认状态
当模态框未激活时,渲染空内容:
// app/@modal/default.tsx
export default function Default() {
return null;
}
模态框组件
模态框的拦截版本:
// app/@modal/(.)photo/[id]/page.tsx
import { getPhoto } from "@/lib/photos";
import Modal from "@/components/modal";
export default async function InterceptedPhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img
src={photo.url}
alt={photo.title}
className="w-full max-w-4xl"
/>
<h2 className="text-xl font-bold mt-4">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</Modal>
);
}
模态框 UI 组件
模态框组件本身是一个客户端组件,处理关闭逻辑:
// components/modal.tsx
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
export default function Modal({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
// 关闭模态框
const handleClose = () => {
router.back();
};
// 点击背景关闭
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
handleClose();
}
};
// ESC 键关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto">
<button
onClick={handleClose}
className="float-right text-gray-500 hover:text-gray-700"
>
✕
</button>
{children}
</div>
</div>
);
}
完整的照片页面
当用户直接访问 URL 或刷新页面时,渲染完整的页面:
// app/photo/[id]/page.tsx
import { getPhoto } from "@/lib/photos";
import Link from "next/link";
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow p-4">
<Link href="/feed" className="text-blue-500 hover:underline">
← 返回图库
</Link>
</nav>
<main className="container mx-auto p-8">
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<img
src={photo.url}
alt={photo.title}
className="w-full h-auto"
/>
<div className="p-6">
<h1 className="text-3xl font-bold">{photo.title}</h1>
<p className="text-gray-600 mt-4">{photo.description}</p>
<div className="mt-6 flex gap-4">
<button className="px-4 py-2 bg-blue-500 text-white rounded">
下载
</button>
<button className="px-4 py-2 bg-gray-200 rounded">
分享
</button>
</div>
</div>
</div>
</main>
</div>
);
}
图库列表页面
图库页面包含可点击的照片列表:
// app/feed/page.tsx
import { getPhotos } from "@/lib/photos";
import Link from "next/link";
import Image from "next/image";
export default async function FeedPage() {
const photos = await getPhotos();
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">图片动态</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{photos.map((photo) => (
<Link
key={photo.id}
href={`/photo/${photo.id}`}
className="group relative overflow-hidden rounded-lg shadow-md hover:shadow-xl transition-shadow"
>
<div className="aspect-square relative">
<Image
src={photo.thumbnailUrl}
alt={photo.title}
fill
className="object-cover group-hover:scale-105 transition-transform"
/>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<h2 className="text-white font-semibold">{photo.title}</h2>
</div>
</Link>
))}
</div>
</div>
);
}
行为详解
软导航行为
当用户通过 <Link> 或客户端导航访问被拦截的路由时:
- URL 更新为目标路由(如
/photo/123) - 拦截路由渲染(模态框版本)
- 背景页面保持不变(用户可以继续浏览其他照片)
- 用户可以通过返回按钮或
router.back()关闭模态框
硬导航行为
当用户刷新页面或直接访问 URL 时:
- 渲染完整的照片页面(
photo/[id]/page.tsx) - 不显示模态框
- 提供完整的页面体验
URL 共享
由于软导航和硬导航使用相同的 URL:
- 用户可以将 URL 分享给他人
- 接收者看到完整的页面体验
- 这解决了模态框通常无法分享的问题
高级用法
登录模态框
从导航栏打开登录模态框,同时支持独立的登录页面:
app/
├── layout.tsx
├── @auth/
│ ├── default.tsx
│ └── (.)login/
│ └── page.tsx
├── login/
│ └── page.tsx
└── page.tsx
// app/layout.tsx
import Link from "next/link";
export default function RootLayout({
children,
auth,
}: {
children: React.ReactNode;
auth: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<nav className="p-4 bg-white shadow">
<Link href="/" className="font-bold">首页</Link>
<Link href="/login" className="float-right">登录</Link>
</nav>
{children}
{auth}
</body>
</html>
);
}
// app/@auth/(.)login/page.tsx
import Modal from "@/components/modal";
import LoginForm from "@/components/login-form";
export default function LoginModal() {
return (
<Modal>
<h2 className="text-2xl font-bold mb-4">登录</h2>
<LoginForm />
</Modal>
);
}
购物车侧边栏
在不离开商品页面的情况下打开购物车:
app/
├── layout.tsx
├── @cart/
│ ├── default.tsx
│ └── (.)cart/
│ └── page.tsx
├── cart/
│ └── page.tsx
└── products/
└── [id]/
└── page.tsx
// app/@cart/(.)cart/page.tsx
import { getCartItems } from "@/lib/cart";
export default async function CartSidebar() {
const items = await getCartItems();
return (
<aside className="fixed right-0 top-0 h-full w-80 bg-white shadow-xl z-50 p-4">
<h2 className="text-xl font-bold mb-4">购物车</h2>
{items.length === 0 ? (
<p className="text-gray-500">购物车为空</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id} className="flex justify-between">
<span>{item.name}</span>
<span>¥{item.price}</span>
</li>
))}
</ul>
)}
</aside>
);
}
关闭模态框的正确方式
关闭模态框有两种方式:
方式一:使用 router.back()
"use client";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
return (
<div className="fixed inset-0 bg-black/50">
<button onClick={() => router.back()}>关闭</button>
{children}
</div>
);
}
方式二:使用 Link 组件
需要为返回的目标路由创建一个返回 null 的页面:
// app/@modal/page.tsx
export default function ModalPage() {
return null;
}
import Link from "next/link";
export default function Modal({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 bg-black/50">
<Link href="/">关闭</Link>
{children}
</div>
);
}
处理任意页面的模态框关闭
如果用户可能从任何页面打开模态框,需要使用捕获所有路由:
// app/@modal/[...catchAll]/page.tsx
export default function CatchAllModal() {
return null;
}
这确保从任何页面导航时,模态框都能正确关闭。
小结
本章我们学习了:
- 拦截路由的核心概念:软导航拦截、硬导航正常渲染
(..)命名约定的路径级别规则- 与并行路由配合实现模态框体验
- URL 共享的实现和意义
- 各种实战场景:图库、登录、购物车
- 正确关闭模态框的方式
练习
- 实现一个图库应用,点击图片在模态框中显示详情
- 创建一个可以从任何页面打开的登录模态框
- 实现一个侧边栏购物车,支持直接访问完整购物车页面
- 添加键盘支持(ESC 关闭模态框)和点击背景关闭功能