Next.js 核心路由解析:动态路由、路由组、平行路由和拦截路由(四)

前言

刚接触 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)

主要用途:

  1. 逻辑分组代码: 将相关的路由和文件组织在一起,提高项目可维护性,例如按功能模块(marketingshop)或团队划分。
  2. 创建多个布局: 在同一层级中定义不同的布局,甚至可以创建多个根布局,以适应不同页面或用户群体的UI需求。

示例:

2.1. 按逻辑分组而不影响URL

假设你的应用有营销页面和商店页面,它们在URL上是平级的,但你希望在文件结构上区分它们:

bash 复制代码
app/
├── (marketing)/
│   └── about/page.js  // URL: /about
└── (shop)/
    └── products/page.js // URL: /products

尽管 about/page.jsproducts/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.jsapp/layout.js 的组合布局,而 /settings 路径下的页面则会使用 (dashboard)/layout.jsapp/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.jschildren prop 是一个隐式的插槽,对应于常规的 page.js 文件。你可以根据需要,在布局中并行渲染这些插槽,或者根据条件(如用户登录状态)选择性地渲染。

3.2. 优势

  1. 代码管理: 将复杂的UI拆分为独立的插槽,每个插槽可以由不同的团队或开发者负责,提高协作效率。
  2. 独立加载与错误处理: 每个平行路由都可以拥有自己的 loading.jserror.js 文件。这意味着某个区域的数据加载缓慢或发生错误时,不会影响页面其他部分的渲染和交互,显著提升用户体验。
  3. 独立导航与状态: 平行路由可以拥有自己的子路由和状态管理,就像一个独立的小应用。例如,@analytics 插槽下可以有 /page-views/visitors 等子页面,它们只影响 analytics 插槽的内容,而不会导致整个页面刷新。

3.3. default.js 的作用

当用户"硬导航"(直接输入 URL 或刷新)到某个地址,而该地址没有为所有插槽提供匹配页面时,Next.js 会尝试渲染这些插槽各自的 default.js。若不存在对应 default.js,会回退到 404。

示例:

假设 app/@analytics 下有 page.jsvisitors/page.js。用户从 / 软导航到 /visitors 时,@analytics 会渲染 visitors/page.js,其他插槽保持在 / 的状态。若直接访问 /visitors 并刷新,因为只匹配到 @analyticsvisitors/page.js,而 @team 与默认 children 没有匹配,这时如果存在 app/@team/default.jsapp/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"
        >
          &times;
        </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; // 默认不渲染任何内容
}

效果演示:

  1. 从主页点击图片: 当你在 / 页面点击一个图片链接(例如 /photo/1),URL会变为 /photo/1,但页面不会完全跳转,而是会在当前页面上方弹出一个模态框显示图片详情。这是因为拦截路由 app/@modal/(..)photo/[id]/page.js 被激活。
  2. 直接访问图片URL: 如果你直接在浏览器中输入 /photo/1 并回车,或者刷新该页面,你将看到一个独立的图片详情页,而不是模态框。这是因为此时没有"拦截"的上下文,Next.js会渲染原始的 app/photo/[id]/page.js

拦截路由提供了一种优雅的方式来处理这种"上下文内"的内容展示需求,极大地提升了用户体验和应用的灵活性。

小结

本篇文章详细介绍了Next.js 15中App Router的四大核心路由概念:动态路由、路由组、平行路由和拦截路由。它们各自解决了不同的开发痛点,共同构成了Next.js强大而灵活的路由系统。

  • 动态路由:处理不确定路径的页面,如文章详情、商品ID等。
  • 路由组:用于逻辑组织文件,不影响URL,并能实现多布局和多根布局。
  • 平行路由:在同一布局中并行或条件渲染多个独立区域,提升UI复杂度和用户体验。
  • 拦截路由:在当前路由上下文中以模态框等形式展示其他路由内容,同时保持URL可分享性。

参考链接

相关推荐
JefferyXZF2 分钟前
Next.js 路由处理程序:前端也能轻松玩转后端 API(五)
前端·全栈·next.js
阳先森8 分钟前
vue 数据更新到视图变化全过程
前端·vue.js
Sobeit9 分钟前
Vue 3.5 响应式设计与实现流程全解析
前端·vue.js
蓝倾97611 分钟前
唯品会以图搜图(拍立淘)API接口调用指南详解
java·大数据·前端·数据库·开放api接口
暖木生晖12 分钟前
流式布局(百分比布局)
前端·移动端
啃火龙果的兔子28 分钟前
快速删除 `node_modules`
前端
yourkin6661 小时前
npm run 常见脚本
前端·npm·node.js
ZzMemory1 小时前
深入理解JS(八):事件循环,单线程的“一心多用”
前端·javascript·面试
FogLetter1 小时前
玩转Canvas:从静态图像到动态动画的奇妙之旅
前端·canvas
llq_3501 小时前
解决 Linux 部署中的文件大小写问题
前端