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 应用差异最大的核心特性之一。

相关推荐
leafyyuki2 小时前
从零到一落地「智能助手」:一次基于 OpenSpec 的流式对话前端实践
前端·vue.js·人工智能
踩着两条虫2 小时前
VTJ:架构设计模式
前端·架构·ai编程
孙凯亮2 小时前
Three.js VR 模拟器(Immersive Web Emulator)踩坑全记录:从报错到可用,避坑指南一次性奉上
前端·three.js
CDN3602 小时前
2026年Web性能优化实测:360CDN如何通过“时效性”与“地域性”双杀提升排名?
前端·性能优化
Dxy12393102162 小时前
Python使用XPath定位元素:and和or组合条件
前端·javascript·python
李剑一2 小时前
可以说99%的前端都没咋用过!JS逗号操作符,面试常考但业务少用?一篇吃透不踩坑
前端
百结2142 小时前
HAProxy 搭建 Web 集群
前端·web
GISer_Jing2 小时前
Todos
前端·人工智能·学习