第 15 章

拦截路由与平行路由:高级 UI 模式

第15章:拦截路由与平行路由:高级 UI 模式

点击图片弹出模态框,URL 变了但背景列表还在;刷新页面则全屏展示图片。这种“同 URL 双视图”模式完全在路由层面实现,无需客户端状态。

本章核心问题:拦截路由的 (.) 语法如何工作?平行路由的 @slot 如何实现独立加载?软导航与硬导航的行为有何区别?

读完本章你将理解


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.tsxerror.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.tsxerror.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 无匹配时的兜底作用。

本章评分
4.8  / 5  (17 评分)

💬 留言讨论