Next.js从入门到实战保姆级教程(第四章):路由系统详解

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

上一章《项目结构与文件系统约定》介绍了文件系统路由的基本概念,但完整的路由系统包含更多维度:页面间的导航机制、URL 参数的读取与处理、访问权限控制等。本章将系统性地讲解这些核心功能。

一、页面导航机制

Next.js 提供了两种导航方式,分别适用于不同的使用场景。

<Link> 组件是 Next.js 中实现页面跳转的首选方案,相较于原生 <a> 标签具有显著优势。

(1)工作原理对比

原生 <a> 标签:触发完整的页面刷新流程

  • 浏览器向服务器重新请求 HTML
  • 重新下载并执行 JavaScript
  • 清空所有应用状态
  • 用户体验存在明显的中断感

<Link> 组件:实现客户端导航(Client-Side Navigation)

  • JavaScript 拦截默认跳转行为
  • 仅获取新页面所需的数据
  • 局部更新 DOM,保留应用状态
  • 导航过程流畅,无白屏闪烁
typescript 复制代码
import Link from 'next/link'

export default function Navigation() {
  return (
    <nav>
      <Link href="/">首页</Link>
      <Link href="/blog">博客</Link>
      <Link href="/about">关于</Link>
    </nav>
  )
}

支持动态路径构建:

typescript 复制代码
{posts.map(post => (
  <Link key={post.id} href={`/blog/${post.slug}`}>
    {post.title}
  </Link>
))}

prefetch(预取)

默认情况下,当链接进入视口时,Next.js 会自动预取目标页面的数据。这种优化使得用户点击后几乎无感知延迟即可看到内容。对于访问频率低或数据量大的页面,可禁用预取以节省资源:

typescript 复制代码
<Link href="/rarely-visited-page" prefetch={false}>
  低频访问页面
</Link>

replace(替换历史记录)

默认导航会在浏览器历史记录中添加新条目。某些场景下需要替换当前记录而非追加,例如登录成功后跳转至首页,防止用户通过后退按钮返回登录页:

typescript 复制代码
<Link href="/" replace>
  登录后返回首页
</Link>

scroll(滚动行为控制)

导航完成后默认滚动至页面顶部。若需保持当前滚动位置(如分页筛选场景),可禁用此行为:

typescript 复制代码
<Link href="/blog?tag=react" scroll={false}>
  筛选 React 标签(保持滚动位置)
</Link>

2. useRouter Hook:编程式导航

<Link> 适用于用户主动点击的场景,而 useRouter Hook 则用于代码逻辑触发的导航,如表单提交成功后的跳转、身份验证失败后的重定向等。

typescript 复制代码
'use client'

import { useRouter } from 'next/navigation'

export default function LoginForm() {
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const result = await login(formData)

    if (result.success) {
      // 使用 replace 避免用户后退时返回登录页
      router.replace('/')
    }
  }

  return <form action={handleSubmit}>...</form>
}

useRouter API 概览

typescript 复制代码
const router = useRouter()

router.push('/dashboard')       // 导航至指定路径,添加历史记录
router.replace('/dashboard')    // 导航至指定路径,替换历史记录
router.back()                   // 后退(等同于浏览器后退按钮)
router.forward()                // 前进
router.refresh()                // 刷新当前路由(重新获取服务端数据)
router.prefetch('/heavy-page')  // 手动预取指定页面

重要提示useRouter 应从 next/navigation 导入,而非 next/router(后者属于已废弃的 Pages Router)。这是初学者常见的错误,TypeScript 可能不会报错,但运行时行为会异常。


二、URL 参数处理

URL 中携带的信息主要分为两类:路径参数 (Dynamic Segments)和查询参数(Search Params)。Next.js 提供了相应的工具来读取和处理这些信息。

1. 路径参数(Dynamic Segments)

路径参数指 URL 路径中的动态部分,如 /blog/my-post 中的 my-post

(1)服务端组件中读取

在服务端组件中,路径参数通过 params prop 传递:

typescript 复制代码
// src/app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  return (
    <article>
      <h1>{post.title}</h1>
      {/* 文章内容 */}
    </article>
  )
}

注意 :在 Next.js 15+ 版本中,params 是一个 Promise,需要使用 await 解包。

(2)客户端组件中读取

在客户端组件中,使用 useParams Hook:

typescript 复制代码
'use client'
import { useParams } from 'next/navigation'

export function PostActions() {
  const params = useParams<{ slug: string }>()
  // params.slug 即为当前 URL 中的路径参数值
  
  return <button>分享文章</button>
}

2. 查询参数(Search Params)

查询参数位于 URL 的 ? 之后,常用于筛选、搜索、分页等场景,如 /blog?page=2&tag=react

(1)服务端组件中读取

typescript 复制代码
// src/app/blog/page.tsx
export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string; tag?: string; q?: string }>
}) {
  const { page = '1', tag, q } = await searchParams

  const posts = await getPosts({
    page: parseInt(page),
    tag,
    query: q,
  })

  return (
    <div>
      {q && <p>搜索结果:"{q}"</p>}
      {/* 文章列表 */}
    </div>
  )
}

(2)客户端组件中读取与更新

在客户端组件中,结合 useSearchParamsusePathnameuseRouter 实现查询参数的读取与更新:

typescript 复制代码
'use client'

import { useSearchParams, useRouter, usePathname } from 'next/navigation'

interface TagFilterProps {
  tags: string[]
}

export function TagFilter({ tags }: TagFilterProps) {
  const searchParams = useSearchParams()
  const pathname = usePathname()
  const router = useRouter()
  const currentTag = searchParams.get('tag')

  const handleTagClick = (tag: string) => {
    const params = new URLSearchParams(searchParams.toString())

    if (tag === currentTag) {
      params.delete('tag')  // 取消选中
    } else {
      params.set('tag', tag)
      params.delete('page')  // 切换标签时重置页码
    }

    router.push(`${pathname}?${params.toString()}`)
  }

  return (
    <div className="flex gap-2 flex-wrap">
      {tags.map(tag => (
        <button
          key={tag}
          onClick={() => handleTagClick(tag)}
          className={`px-3 py-1 rounded-full text-sm ${
            tag === currentTag
              ? 'bg-blue-500 text-white'
              : 'bg-gray-100 text-gray-700'
          }`}
        >
          {tag}
        </button>
      ))}
    </div>
  )
}

使用查询参数的优势

  • URL 状态可分享,用户可将筛选结果发送给他人
  • 页面刷新后状态不丢失
  • 支持浏览器前进/后退操作
  • 有利于 SEO,搜索引擎可索引不同的筛选视图

三、当前导航项高亮

导航栏中高亮显示当前页面是常见需求,可通过 usePathname Hook 实现:

typescript 复制代码
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

const navItems = [
  { href: '/', label: '首页' },
  { href: '/blog', label: '博客' },
  { href: '/about', label: '关于' },
]

export function Navbar() {
  const pathname = usePathname()

  return (
    <nav className="flex gap-6">
      {navItems.map(item => {
        // 首页精确匹配,其他页面前缀匹配
        const isActive = item.href === '/'
          ? pathname === '/'
          : pathname.startsWith(item.href)

        return (
          <Link
            key={item.href}
            href={item.href}
            className={`text-sm font-medium transition-colors ${
              isActive
                ? 'text-blue-600 border-b-2 border-blue-600'
                : 'text-gray-600 hover:text-gray-900'
            }`}
          >
            {item.label}
          </Link>
        )
      })}
    </nav>
  )
}

四、静态参数预生成(generateStaticParams)

对于动态路由,如果已知所有可能的参数值(如博客文章的所有 slug),可使用 generateStaticParams 在构建阶段预生成静态页面(SSG),而非每次请求时实时渲染。

typescript 复制代码
// src/app/blog/[slug]/page.tsx

// 构建阶段执行,返回需要预生成的所有参数组合
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  
  return (
    <article>
      <h1>{post.title}</h1>
      {/* 文章内容 */}
    </article>
  )
}

优势

  • 生成纯静态 HTML 文件,CDN 直接分发
  • 访问速度极快,无需服务器计算
  • 降低服务器负载
  • 适用于博客、文档、产品列表等内容相对稳定的场景

五、并行路由(Parallel Routes)

某些复杂界面需要同时展示多个独立的内容区域,每个区域拥有独立的加载状态和错误处理。例如仪表盘中同时显示用户统计、销售图表和最新订单。Next.js提供了并行路由来处理这类需求。

1. 目录结构

使用 @ 前缀创建命名插槽(Slots):

bash 复制代码
src/app/dashboard/
├── layout.tsx           # 布局组件
├── page.tsx             # 主页面
├── @stats/
│   ├── page.tsx         # 统计数据
│   └── loading.tsx      # 加载状态
├── @chart/
│   ├── page.tsx         # 图表数据
│   └── loading.tsx      # 加载状态
└── @recent/
    ├── page.tsx         # 最新订单
    └── loading.tsx      # 加载状态

2. 布局组件实现

并行路由的插槽会跟children属性一起传递给布局组件:

typescript 复制代码
// src/app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  stats,
  chart,
  recent,
}: {
  children: React.ReactNode
  stats: React.ReactNode
  chart: React.ReactNode
  recent: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-2 gap-6">
      <div className="col-span-2">{stats}</div>
      <div>{chart}</div>
      <div>{recent}</div>
    </div>
  )
}

核心价值 :各插槽独立加载,互不阻塞@stats 数据加载完成即可显示,无需等待 @chart@recent,显著提升用户体验。


六、拦截路由(Intercepting Routes)

在某些场景下,我们希望用户点击一个链接时,不是跳转到一个全新的页面,而是在当前页面的上下文中(例如通过一个模态框)展示目标内容。同时,这个内容又拥有自己独立的 URL,可以被直接访问或分享。Next.js 的拦截路由正是为了解决这种"上下文相关导航"而设计的。

1. 核心概念

拦截路由允许你拦截一个原本要跳转的路由,并在当前布局中渲染一个替代组件,例如:

  • 拦截时 :用户从 /photos 点击一张照片,URL 变为 /photos/123,但内容以模态框形式叠加在 /photos 页面上。
  • 直接访问时 :用户直接在浏览器地址栏输入 /photos/123 或刷新页面,则会完整渲染 /photos/123 的独立页面。

这完美实现了类似 Instagram 或 Dribbble 的图片浏览体验:在信息流中点击是弹窗,直接访问链接是详情页。

2. 目录结构与命名约定

拦截路由通过特殊的文件夹命名来实现,使用括号 () 和点 . 来表示相对路径关系:

  • (.):匹配同一层级的路由。
  • (..):匹配上一层级的路由。
  • (..)(..):匹配上上层级的路由。
  • (...):匹配根目录 app/ 下的路由。

通常,拦截路由会与并行路由@slot)结合使用,将拦截到的内容渲染在模态框插槽中。

3. 实战案例:图片详情模态框

假设我们有一个图片列表页 /photos,点击任意图片应弹出详情模态框,URL 变为 /photos/[id]

文件结构如下:

bash 复制代码
src/app/
├── layout.tsx                     # 根布局
├── photos/
│   ├── page.tsx                   # 图片列表页 (/photos)
│   └── [id]/
│       └── page.tsx               # 图片详情页 (/photos/[id]) - 直接访问时渲染
└── @modal/                        # 并行路由插槽,用于模态框
    └── (..)photos/                # 拦截上一层级的 photos 路由
        └── [id]/
            └── page.tsx           # 拦截后的模态框组件

代码实现:

typescript 复制代码
// src/app/photos/[id]/page.tsx
// 这是 /photos/[id] 的独立页面,直接访问时显示
export default function PhotoPage({ params }: { params: { id: string } }) {
  return (
    <div className="p-8">
      <h1>照片详情 #{params.id}</h1>
      <Photo image-id={params.id}/> <!-- 假如已存在该组件-->
      <p>这是照片的完整详情页面。</p>
    </div>
  )
}

// src/app/@modal/(..)photos/[id]/page.tsx
// 这是拦截路由,从 /photos 跳转时显示
export default function PhotoModal({ params }: { params: { id: string } }) {
  return (
    <dialog open className="fixed inset-0 bg-black/80 flex items-center justify-center">
      <div className="relative">
        <Photo image-id={params.id}/> <!-- 假如已存在该组件-->
        <button className="absolute top-4 right-4 text-white">关闭</button>
      </div>
    </dialog>
  )
}

布局组件配置:

为了让模态框能正确显示,需要在根布局中定义 modal 插槽。

typescript 复制代码
// src/app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

处理未匹配状态:

当用户直接访问 /photos/123 时,@modal 插槽没有匹配到任何内容,Next.js 会尝试渲染 default.tsx。为了避免显示 404,我们创建一个返回 null 的默认文件。

typescript 复制代码
// src/app/@modal/default.tsx
export default function Default() {
  return null
}
graph TD Start((用户操作)) --> RouteCheck{当前路径是?} %% 场景一:在列表页点击 RouteCheck -- "/photos (列表页)" --> ClickAction[点击某张图片] ClickAction --> URLChange[URL 变为 /photos/123] URLChange --> Interceptor{拦截路由匹配} Interceptor -- "命中 @modal/(..)photos/[id]" --> RenderModal[渲染模态框组件] RenderModal --> ShowModal[显示模态框: 图片详情] ShowModal -.-> KeepContext[背景保持: /photos 列表页] %% 场景二:直接访问或刷新 RouteCheck -- "直接输入 /photos/123" --> DirectAccess{是否匹配拦截器?} DirectAccess -- "否 (无 @modal 上下文)" --> Fallback[渲染默认页面] Fallback --> RenderPage["渲染: photos/[id]/page.tsx"] RenderPage --> ShowFullPage[显示全屏详情页] %% 样式调整 style Start fill:#f9f,stroke:#333,stroke-width:2px style RenderModal fill:#bbf,stroke:#333,stroke-width:2px style RenderPage fill:#bfb,stroke:#333,stroke-width:2px style ShowFullPage fill:#dfd,stroke:#333,stroke-width:2px

4. 核心价值

  • 保持上下文:用户在浏览列表时不会丢失当前位置,体验更流畅。
  • 可分享的 URL:模态框中的内容拥有独立的 URL,可以直接复制链接分享给他人。
  • 渐进增强:直接访问链接时,内容依然可以完整展示,保证了功能的健壮性。

七、中间件:路由守卫与权限控制

保护需要身份验证才能访问的页面,最优雅的实现方式是使用中间件(Middleware),在请求到达页面组件之前进行拦截和验证。

1. 中间件实现

在项目根目录(与 src/ 同级)创建 middleware.ts

typescript 复制代码
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'  // 认证工具

export default async function middleware(request: NextRequest) {
  const session = await auth()
  const { pathname } = request.nextUrl

  // 定义需要保护的路径
  const protectedPaths = ['/dashboard', '/profile', '/settings']
  const isProtected = protectedPaths.some(path => pathname.startsWith(path))

  // 未登录用户访问受保护路径,重定向至登录页
  if (isProtected && !session) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // 已登录用户访问登录页,重定向至首页
  if (session && pathname === '/login') {
    return NextResponse.redirect(new URL('/', request.url))
  }

  return NextResponse.next()
}

// 配置中间件匹配规则
// 排除静态资源和 API 路由(它们有独立的权限控制)
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

当然中间件除了做权限认证外,所有需要在用户访问与服务器层之间实现的逻辑,都可以使用中间件来承担。

2. 中间件的优势

  • 在服务器端执行,安全性高
  • 统一处理权限逻辑,避免遗漏
  • 在页面组件渲染前拦截,性能更优
  • 支持复杂的条件判断和重定向策略

八、重定向机制

Next.js 提供了多种重定向方式,适用于不同场景。

1. 组件内重定向

typescript 复制代码
import { redirect, permanentRedirect } from 'next/navigation'

// 临时重定向(HTTP 307)
// 适用场景:登录后跳转、表单提交后跳转
async function ProtectedPage() {
  const user = await getUser()
  if (!user) {
    redirect('/login')
  }
  // ...
}

// 永久重定向(HTTP 308)
// 适用场景:URL 迁移,告知搜索引擎更新索引
function OldBlogPost() {
  permanentRedirect('/blog/new-url-here')
}

2. 配置文件重定向

对于批量 URL 重定向(如域名迁移),可在 next.config.ts 中统一配置:

typescript 复制代码
// next.config.ts
const nextConfig = {
  async redirects() {
    return [
      {
        source: '/old-blog/:slug',
        destination: '/blog/:slug',
        permanent: true,  // true = 308 永久重定向,false = 307 临时重定向
      },
      {
        source: '/team',
        destination: '/about#team',
        permanent: false,
      },
    ]
  },
}

export default nextConfig

配置文件重定向的优势

  • 在请求层面处理,效率更高
  • 对 SEO 更友好
  • 集中管理,便于维护

九、路由系统架构总览

将本章所学内容整合为完整的请求处理流程:

graph TD Request[用户请求 URL] --> Middleware[中间件检查权限] Middleware -->|未授权| Redirect[重定向至登录页] Middleware -->|已授权| Layout[匹配布局层级] Layout --> Loading[显示 loading.tsx] Loading --> Page[执行 page.tsx 获取数据] Page -->|数据错误| Error[显示 error.tsx] Page -->|页面不存在| NotFound[显示 not-found.tsx] Page -->|成功| Render[渲染至浏览器]

Next.js 的路由系统不仅是"URL 到页面的映射",而是一个完整的请求处理流水线,每个环节都提供了扩展点供开发者定制。


十、本章小结

通过本章学习,你应该掌握了:

  • 两种导航方式(Link 组件与 useRouter)的使用场景
  • 路径参数和查询参数的读取与处理方法
  • 静态参数预生成的优化策略
  • 并行路由、拦截路由实现复杂布局的技巧
  • 中间件实现路由守卫的最佳实践
  • 不同重定向方式的适用场景

对客户端而言光有界面还不行,还得有数据。下一章《Next.js数据获取与缓存策略》将深入探讨数据获取机制------这是 Next.js 与传统 React 应用差异最大的核心特性之一。

相关推荐
掘金者阿豪1 小时前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
kyriewen1 小时前
折腾了半年 AI 编程工作流,最后发现效率瓶颈是桌上那块屏幕
前端·javascript·ai编程
蜗牛前端2 小时前
codex 全流程开发上线的高颜值礼簿小程序
前端·微信小程序
大龄秃头程序员2 小时前
我在图文流 App 里落地双层缓存、弱网降级与 OOM 治理
前端
老王以为2 小时前
React Renderer 分离的多平台架构
前端·react native·react.js
hunterandroid2 小时前
Kotlin Coroutines 与 Flow:让异步任务更清晰
前端
Bigger3 小时前
从零搭建 AI 代码审查服务:一份前端也能看懂的 Python 学习笔记
前端·ci/cd·ai编程
lichenyang4533 小时前
JSAPI、NAPI、Biz、Imp:ASCF Demo 如何真正调用系统能力和 C++ 能力
前端
lichenyang4534 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174464 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css