前言
刚接触 Next.js 的 App Router 时,你可能会被 平行路由 、拦截路由 这些新概念弄得有些迷惑。别担心,这恰恰是 Next.js 强大之处的体现。本文会分析这些高级路由功能的使用场景,用它来构建复杂且交互体验友好的应用。
1.动态路由(Dynamic Routes)
在实际开发中,我们经常需要处理不确定的 URL,例如文章详情页(/blog/a-good-post
)、商品页(/products/123
)等。动态路由正是为了解决这类需求而设计的。
1.1[folderName]
:基础动态路由
这是最常见的动态路由形式。通过将文件夹命名为 [paramName]
的形式,你可以捕获 URL 中的一个片段,并将其作为参数在组件中访问。
示例:
假设我们有一个博客应用,需要根据文章的 slug
显示内容。我们可以在 app/blog
目录下创建一个名为 [slug]
的文件夹,并在其中添加 page.js
:
javascript
// app/blog/[slug]/page.js
export default function Page({ params }) {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold">文章详情</h1>
<p>当前文章的 Slug: <span className="font-mono text-blue-600">{params.slug}</span></p>
</div>
);
}
工作原理:
当你访问 /blog/hello-world
时,params
对象将是 { slug: 'hello-world' }
。Next.js 会自动解析URL中的动态部分,并将其作为参数传递给组件,使得你可以根据这些参数从数据库或其他数据源获取相应内容。
1.2. [...folderName]
:捕获所有路由
捕获所有路由 允许你在一个 动态路由 中捕获多个URL路径段。它的主要作用是处理那些 层级不固定、深度不确定 的URL路径。当你在文件夹名称中使用 [...folderName]
语法时,Next.js会将该位置之后的所有路径段都捕获到一个数组中。
应用场景: 假设正在开发一个文档系统,需要支持任意深度的文档目录结构。比如:
/docs/intro
(一级目录)/docs/guide/installation
(二级目录)/docs/api/auth/login/oauth
(多级目录)
可以创建一个 app/docs/[...slug]/page.js
文件来处理所有这些路径:
示例:
app/shop/[...slug]/page.js
将匹配 /shop/clothes
、/shop/clothes/tops
,甚至 /shop/clothes/tops/t-shirts
等所有以 /shop/
开头的多层级路径。
javascript
// app/shop/[...slug]/page.js
export default function Page({ params }) {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold">商品分类</h1>
<p>捕获到的路径片段: <span className="font-mono text-blue-600">{JSON.stringify(params.slug)}</span></p>
</div>
);
}
工作原理:
访问 /shop/electronics/laptops
时,params
的值为 { slug: ['electronics', 'laptops'] }
。slug
会是一个数组,包含所有被捕获的路径片段。这在构建文件系统不固定的多层级分类或面包屑导航时非常有用。
1.3. [[...folderName]]
:可选捕获所有路由
在 [...folderName]
的基础上,如果再用一层方括号包裹,即 [[...folderName]]
,则表示这些路径片段是可选的。这意味着即使没有提供任何路径片段,该路由也能被匹配。
示例:
app/shop/[[...slug]]/page.js
将匹配 /shop
(没有路径片段)、/shop/clothes
、/shop/clothes/tops
等。
javascript
// app/shop/[[...slug]]/page.js
export default function Page({ params }) {
const pathSegments = params.slug ? params.slug.join('/') : '无';
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold">可选商品分类</h1>
<p>捕获到的可选路径片段: <span className="font-mono text-blue-600">{pathSegments}</span></p>
</div>
);
}
工作原理:
访问 /shop
时,params
的值为 {}
。访问 /shop/a/b
时,params
的值为 { slug: ['a', 'b'] }
。这种模式非常适合创建具有可选子路径的根页面,例如一个商品列表页,既可以显示所有商品,也可以根据分类显示商品。
2. 路由组(Route Groups)
在Next.js的App Router中,文件夹名称通常会直接映射到URL路径。然而,路由组允许你将文件和路由逻辑地组织在一起,而不会影响最终的URL路径结构。这通过将文件夹名称用括号 ()
包裹来实现,例如 (dashboard)
。
主要用途:
- 逻辑分组代码: 将相关的路由和文件组织在一起,提高项目可维护性,例如按功能模块(
marketing
、shop
)或团队划分。 - 创建多个布局: 在同一层级中定义不同的布局,甚至可以创建多个根布局,以适应不同页面或用户群体的UI需求。
示例:
2.1. 按逻辑分组而不影响URL
假设你的应用有营销页面和商店页面,它们在URL上是平级的,但你希望在文件结构上区分它们:
bash
app/
├── (marketing)/
│ └── about/page.js // URL: /about
└── (shop)/
└── products/page.js // URL: /products
尽管 about/page.js
和 products/page.js
位于不同的路由组文件夹中,但它们的URL路径不受影响,依然是 /about
和 /products
。
2.2. 创建不同布局
路由组允许你在同一层级为不同的路由应用不同的布局。例如,一个应用可能同时有公共区域和用户后台区域,它们需要不同的布局:
scss
app/
├── layout.js // 全局根布局
├── (public)/
│ ├── layout.js // 公共区域布局
│ └── page.js // URL: /
└── (dashboard)/
├── layout.js // 后台管理布局
└── settings/page.js // URL: /settings
在这个例子中,/
路径下的页面会使用 (public)/layout.js
和 app/layout.js
的组合布局,而 /settings
路径下的页面则会使用 (dashboard)/layout.js
和 app/layout.js
的组合布局。
2.3. 创建多个根布局
如果你需要完全独立的根布局(例如,一个面向公众的网站和一个独立的管理后台),你可以删除顶层的 app/layout.js
,并在每个路由组中创建自己的根布局:
scss
app/
├── (marketing)/
│ ├── layout.js // 包含 <html> 和 <body> 标签的根布局
│ └── page.js // URL: /
└── (admin)/
├── layout.js // 包含 <html> 和 <body> 标签的根布局
└── dashboard/page.js // URL: /dashboard
注意事项:
- 路由组的命名仅用于组织,不影响URL路径。
- 避免不同路由组解析到相同的URL路径,这会导致冲突。
- 如果创建多个根布局,
app/page.js
必须定义在其中一个路由组中,否则/
路径将无法访问。 - 跨根布局导航会导致页面完全重新加载(full page load)。
3. 平行路由(Parallel Routes)
平行路由让你在一个布局里并行渲染多个 槽位 ,相当于在一个页面里开了多个"小窗口",每个窗口可以独立 加载(loading.js) 、报错(error.js) 和 导航(嵌套路由) 显示不同的内容。与 Vue 的"具名插槽"概念相近。
3.1. 用途:条件渲染与独立区域
想象一个后台管理界面,你可能需要同时展示产品信息和数据分析图表,或者根据用户权限显示不同的内容:
java
app/
├── @product/ // 平行路由插槽
│ └── page.js
├── @analytics/ // 平行路由插槽
│ └── page.js
└── layout.js // 共享布局
在 app/layout.js
中,你可以像处理 props 一样接收这些平行路由的内容:
javascript
// app/layout.js
export default function Layout({ children, product, analytics }) {
return (
<div className="flex">
<aside className="w-1/4 p-4 bg-gray-100">侧边栏</aside>
<main className="w-3/4 p-4">
{children} {/* 默认插槽,对应 app/page.js */}
<div className="grid grid-cols-2 gap-4 mt-4">
{product} {/* @product 插槽内容 */}
{analytics} {/* @analytics 插槽内容 */}
</div>
</main>
</div>
);
}
工作原理:
@
开头的文件夹被视为平行路由插槽,它们的内容会作为props传递给父级 layout.js
。children
prop 是一个隐式的插槽,对应于常规的 page.js
文件。你可以根据需要,在布局中并行渲染这些插槽,或者根据条件(如用户登录状态)选择性地渲染。
3.2. 优势
- 代码管理: 将复杂的UI拆分为独立的插槽,每个插槽可以由不同的团队或开发者负责,提高协作效率。
- 独立加载与错误处理: 每个平行路由都可以拥有自己的
loading.js
和error.js
文件。这意味着某个区域的数据加载缓慢或发生错误时,不会影响页面其他部分的渲染和交互,显著提升用户体验。 - 独立导航与状态: 平行路由可以拥有自己的子路由和状态管理,就像一个独立的小应用。例如,
@analytics
插槽下可以有/page-views
和/visitors
等子页面,它们只影响analytics
插槽的内容,而不会导致整个页面刷新。
3.3. default.js
的作用
当用户"硬导航"(直接输入 URL 或刷新)到某个地址,而该地址没有为所有插槽提供匹配页面时,Next.js 会尝试渲染这些插槽各自的 default.js
。若不存在对应 default.js
,会回退到 404。
示例:
假设 app/@analytics
下有 page.js
与 visitors/page.js
。用户从 /
软导航到 /visitors
时,@analytics
会渲染 visitors/page.js
,其他插槽保持在 /
的状态。若直接访问 /visitors
并刷新,因为只匹配到 @analytics
的 visitors/page.js
,而 @team
与默认 children
没有匹配,这时如果存在 app/@team/default.js
与 app/default.js
,它们会被渲染;否则显示 404。
通过 default.js
,可以为未匹配的插槽提供默认 UI,避免 404,体验更稳。
4. 拦截路由(Intercepting Routes)
拦截路由让你在 当前上下文 里加载另一个路由的内容(常见做法是以 弹窗或侧边栏 呈现),避免整页跳转。
4.1. 效果与应用场景
最经典的例子是图片画廊应用。当你浏览图片列表时,点击一张图片,它会以弹框的形式在当前页面上方显示图片详情,而不是跳转到一个全新的页面。此时,URL可能会更新为图片详情的URL,但用户仍然感觉停留在图片列表页。如果用户直接通过该URL访问,则会显示完整的图片详情页。
核心思想: 保持上下文,实现丝滑体验,同时 URL 可分享。
4.2. 实现方式
拦截路由通过特殊的文件夹命名约定来实现,这些约定指示Next.js如何匹配和拦截路由层级:
(.)folderName
:匹配同级路由(..)folderName
:匹配上一级(..)(..)folderName
:匹配上上一级(...)folderName
:匹配根目录
注意: 这里的层级是基于URL路径的层级,而不是文件系统中的物理文件夹层级。路由组(如 (marketing)
)和平行路由(如 @modal
)不会影响URL层级计算。
示例:
假设你的主页是 /feed
,你希望点击图片时,在 /feed
页面上以模态框形式显示 /photo/123
的内容。
bash
app/
├── feed/
│ └── page.js
├── @modal/
│ └── (..)photo/
│ └── [id]/page.js // 拦截 /photo/[id] 路由
└── photo/
└── [id]/page.js // 原始 /photo/[id] 路由
当你在 /feed
页面点击一个链接导航到 /photo/123
时,Next.js 会检查是否存在拦截路由。由于 app/@modal/(..)photo/[id]/page.js
匹配了上一层级的 /photo/[id]
,它将被激活,并在 @modal
插槽中渲染其内容(通常是一个模态框)。
4.3. 示例代码
为了实现一个图片画廊的拦截路由效果,我们通常会结合平行路由来使用。@modal
平行路由插槽用于承载模态框内容,而拦截路由则定义了何时以及如何将内容渲染到这个插槽中。
文件结构示例:
java
app/
├── layout.js
├── page.js // 图片列表主页
├── @modal/
│ ├── default.js // 模态框的默认内容(通常为null)
│ └── (..)photo/
│ └── [id]/page.js // 拦截路由,用于模态框显示图片详情
└── photo/
└── [id]/page.js // 独立的图片详情页
app/layout.js
:
javascript
// app/layout.js
import './globals.css';
export default function RootLayout({ children, modal }) {
return (
<html lang="zh-CN">
<body>
{children} {/* 主页面内容 */}
{modal} {/* 模态框插槽 */}
</body>
</html>
);
}
app/page.js
(图片列表):
javascript
// app/page.js
import Link from 'next/link';
const photos = [
{ id: '1', src: 'https://via.placeholder.com/150/FF0000/FFFFFF?text=Photo1' },
{ id: '2', src: 'https://via.placeholder.com/150/00FF00/FFFFFF?text=Photo2' },
{ id: '3', src: 'https://via.placeholder.com/150/0000FF/FFFFFF?text=Photo3' },
];
export default function HomePage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">我的图片画廊</h1>
<div className="grid grid-cols-3 gap-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img src={photo.src} alt={`Photo ${photo.id}`} className="w-full h-auto rounded shadow-md" />
</Link>
))}
</div>
</div>
);
}
app/photo/[id]/page.js
(独立图片详情页):
javascript
// app/photo/[id]/page.js
const photos = [
{ id: '1', src: 'https://via.placeholder.com/300/FF0000/FFFFFF?text=Photo1-Detail' },
{ id: '2', src: 'https://via.placeholder.com/300/00FF00/FFFFFF?text=Photo2-Detail' },
{ id: '3', src: 'https://via.placeholder.com/300/0000FF/FFFFFF?text=Photo3-Detail' },
];
export default function PhotoDetailPage({ params }) {
const photo = photos.find(p => p.id === params.id);
if (!photo) return <div className="text-center text-red-500">图片未找到</div>;
return (
<div className="container mx-auto p-8 text-center">
<h1 className="text-4xl font-bold mb-4">图片详情</h1>
<img src={photo.src} alt={`Photo ${photo.id}`} className="mx-auto rounded shadow-lg" />
<p className="mt-4 text-lg">这是图片 {params.id} 的详细内容。</p>
</div>
);
}
app/@modal/(.. )photo/[id]/page.js
(拦截模态框):
javascript
// app/@modal/(..)photo/[id]/page.js
'use client';
import { useRouter } from 'next/navigation';
const photos = [
{ id: '1', src: 'https://via.placeholder.com/200/FF0000/FFFFFF?text=Photo1-Modal' },
{ id: '2', src: 'https://via.placeholder.com/200/00FF00/FFFFFF?text=Photo2-Modal' },
{ id: '3', src: 'https://via.placeholder.com/200/0000FF/FFFFFF?text=Photo3-Modal' },
];
export default function PhotoModalPage({ params }) {
const router = useRouter();
const photo = photos.find(p => p.id === params.id);
if (!photo) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-white p-4 rounded-lg shadow-xl relative">
<button
onClick={() => router.back()}
className="absolute top-2 right-2 text-gray-600 hover:text-gray-900 text-2xl font-bold"
>
×
</button>
<h2 className="text-xl font-bold mb-2">图片预览</h2>
<img src={photo.src} alt={`Photo ${photo.id}`} className="max-w-xs max-h-96 rounded" />
<p className="mt-2 text-sm text-gray-600">点击外部关闭</p>
</div>
</div>
);
}
app/@modal/default.js
:
javascript
// app/@modal/default.js
export default function Default() {
return null; // 默认不渲染任何内容
}
效果演示:
- 从主页点击图片: 当你在
/
页面点击一个图片链接(例如/photo/1
),URL会变为/photo/1
,但页面不会完全跳转,而是会在当前页面上方弹出一个模态框显示图片详情。这是因为拦截路由app/@modal/(..)photo/[id]/page.js
被激活。 - 直接访问图片URL: 如果你直接在浏览器中输入
/photo/1
并回车,或者刷新该页面,你将看到一个独立的图片详情页,而不是模态框。这是因为此时没有"拦截"的上下文,Next.js会渲染原始的app/photo/[id]/page.js
。
拦截路由提供了一种优雅的方式来处理这种"上下文内"的内容展示需求,极大地提升了用户体验和应用的灵活性。
小结
本篇文章详细介绍了Next.js 15中App Router的四大核心路由概念:动态路由、路由组、平行路由和拦截路由。它们各自解决了不同的开发痛点,共同构成了Next.js强大而灵活的路由系统。
- 动态路由:处理不确定路径的页面,如文章详情、商品ID等。
- 路由组:用于逻辑组织文件,不影响URL,并能实现多布局和多根布局。
- 平行路由:在同一布局中并行或条件渲染多个独立区域,提升UI复杂度和用户体验。
- 拦截路由:在当前路由上下文中以模态框等形式展示其他路由内容,同时保持URL可分享性。