05. 路由系统基础

Next.js 的路由系统基于文件系统,但支持动态路由、查询参数、路由组等高级功能。本章将深入学习这些特性。


1. 动态路由

动态路由允许你根据 URL 参数动态生成页面。

基础语法

使用方括号 [] 创建动态路由段:

文件路径URL 示例参数
app/blog/[id]/page.tsx/blog/123id = "123"
app/user/[userId]/page.tsx/user/456userId = "456"
app/shop/[...slug]/page.tsx/shop/a/b/cslug = ["a", "b", "c"]

实战案例:博客详情页

步骤 1:创建动态路由文件

// src/app/blog/[id]/page.tsx
interface PageProps {
  params: {
    id: string
  }
}
 
// 模拟数据(实际应该从数据库获取)
const posts: Record<string, { title: string; content: string; date: string }> = {
  '1': {
    title: 'Next.js 入门指南',
    content: '这是一篇关于 Next.js 入门的文章...',
    date: '2024-01-15',
  },
  '2': {
    title: 'TypeScript 最佳实践',
    content: '这是一篇关于 TypeScript 的文章...',
    date: '2024-01-10',
  },
}
 
export default function BlogPost({ params }: PageProps) {
  const post = posts[params.id]
 
  if (!post) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <h1 className="text-4xl font-bold mb-4">文章未找到</h1>
          <p className="text-gray-600">抱歉,您访问的文章不存在。</p>
        </div>
      </div>
    )
  }
 
  return (
    <article className="max-w-4xl mx-auto px-4 py-20">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <p className="text-gray-500 mb-8">{post.date}</p>
      <div className="prose max-w-none">
        <p className="text-lg leading-relaxed">{post.content}</p>
      </div>
    </article>
  )
}

步骤 2:从列表页链接到详情页

// src/app/blog/page.tsx
import Link from 'next/link'
 
const posts = [
  { id: '1', title: 'Next.js 入门指南', excerpt: '...' },
  { id: '2', title: 'TypeScript 最佳实践', excerpt: '...' },
]
 
export default function Blog() {
  return (
    <div>
      {posts.map((post) => (
        <Link key={post.id} href={`/blog/${post.id}`}>
          <h2>{post.title}</h2>
        </Link>
      ))}
    </div>
  )
}

多段动态路由

// src/app/shop/[...slug]/page.tsx
interface PageProps {
  params: {
    slug: string[] // 数组类型
  }
}
 
export default function ShopPage({ params }: PageProps) {
  // 访问 /shop/electronics/phones/iphone
  // params.slug = ['electronics', 'phones', 'iphone']
  
  return (
    <div>
      <h1>商品分类:{params.slug.join(' / ')}</h1>
    </div>
  )
}

可选动态路由

使用双括号 [[...slug]] 创建可选动态路由:

// src/app/docs/[[...slug]]/page.tsx
// 可以匹配:
// /docs
// /docs/getting-started
// /docs/getting-started/installation

2. 查询参数(Search Params)

查询参数通过 URL 的 ? 后面的部分传递,如 /search?q=nextjs&page=1

读取查询参数

// src/app/search/page.tsx
interface PageProps {
  searchParams: {
    q?: string
    page?: string
  }
}
 
export default function SearchPage({ searchParams }: PageProps) {
  const query = searchParams.q || ''
  const page = parseInt(searchParams.page || '1')
 
  return (
    <div className="max-w-4xl mx-auto px-4 py-20">
      <h1 className="text-3xl font-bold mb-8">搜索结果</h1>
      <p>搜索关键词:{query}</p>
      <p>当前页码:{page}</p>
    </div>
  )
}

实战案例:带筛选的博客列表

// src/app/blog/page.tsx
interface PageProps {
  searchParams: {
    category?: string
    tag?: string
  }
}
 
const allPosts = [
  { id: '1', title: 'Next.js 入门', category: '前端', tags: ['Next.js', 'React'] },
  { id: '2', title: 'TypeScript 实践', category: '前端', tags: ['TypeScript'] },
  { id: '3', title: 'Node.js 后端', category: '后端', tags: ['Node.js'] },
]
 
export default function BlogPage({ searchParams }: PageProps) {
  // 筛选逻辑
  let filteredPosts = allPosts
 
  if (searchParams.category) {
    filteredPosts = filteredPosts.filter(
      (post) => post.category === searchParams.category
    )
  }
 
  if (searchParams.tag) {
    filteredPosts = filteredPosts.filter((post) =>
      post.tags.includes(searchParams.tag!)
    )
  }
 
  return (
    <div className="max-w-6xl mx-auto px-4 py-20">
      <h1 className="text-4xl font-bold mb-8">博客</h1>
 
      {/* 筛选器 */}
      <div className="mb-8 flex gap-4">
        <a
          href="/blog"
          className={`px-4 py-2 rounded ${
            !searchParams.category
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          全部
        </a>
        <a
          href="/blog?category=前端"
          className={`px-4 py-2 rounded ${
            searchParams.category === '前端'
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          前端
        </a>
        <a
          href="/blog?category=后端"
          className={`px-4 py-2 rounded ${
            searchParams.category === '后端'
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          后端
        </a>
      </div>
 
      {/* 文章列表 */}
      <div className="space-y-6">
        {filteredPosts.map((post) => (
          <article key={post.id} className="bg-white p-6 rounded-lg shadow">
            <h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
            <div className="flex gap-2">
              {post.tags.map((tag) => (
                <a
                  key={tag}
                  href={`/blog?tag=${tag}`}
                  className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded"
                >
                  {tag}
                </a>
              ))}
            </div>
          </article>
        ))}
      </div>
    </div>
  )
}

3. 客户端导航与状态管理

使用 useRouter Hook

在客户端组件中使用 useRouter 进行编程式导航:

// src/app/components/Navigation.tsx
'use client'
 
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
 
export default function SearchBox() {
  const router = useRouter()
  const searchParams = useSearchParams()
  const [query, setQuery] = useState(searchParams.get('q') || '')
 
  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    router.push(`/search?q=${encodeURIComponent(query)}`)
  }
 
  return (
    <form onSubmit={handleSearch} className="flex gap-2">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        className="px-4 py-2 border rounded"
      />
      <button
        type="submit"
        className="bg-blue-600 text-white px-6 py-2 rounded"
      >
        搜索
      </button>
    </form>
  )
}

更新查询参数(不刷新页面)

'use client'
 
import { useRouter, useSearchParams } from 'next/navigation'
 
export default function FilterButton({ category }: { category: string }) {
  const router = useRouter()
  const searchParams = useSearchParams()
 
  const handleClick = () => {
    const params = new URLSearchParams(searchParams.toString())
    params.set('category', category)
    router.push(`/blog?${params.toString()}`)
  }
 
  return (
    <button onClick={handleClick} className="px-4 py-2 bg-blue-600 text-white rounded">
      {category}
    </button>
  )
}

4. 路由组(Route Groups)

路由组使用括号 () 组织路由,但不会影响 URL 路径。

使用场景

场景 1:组织不同布局的页面

app/
├── (marketing)/
│   ├── layout.tsx      # 营销页面布局(有导航栏)
│   ├── about/
│   │   └── page.tsx    # /about
│   └── contact/
│       └── page.tsx    # /contact
├── (dashboard)/
│   ├── layout.tsx      # 后台布局(有侧边栏)
│   ├── dashboard/
│   │   └── page.tsx    # /dashboard
│   └── settings/
│       └── page.tsx    # /settings
└── layout.tsx          # 根布局

场景 2:多语言路由

app/
├── (en)/
│   ├── about/
│   │   └── page.tsx    # /en/about
│   └── contact/
│       └── page.tsx    # /en/contact
└── (zh)/
    ├── about/
    │   └── page.tsx    # /zh/about
    └── contact/
        └── page.tsx    # /zh/contact

实战案例:营销页面和后台页面分离

// src/app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="marketing-layout">
      <nav className="bg-white shadow">
        {/* 营销页面导航栏 */}
        <div className="max-w-6xl mx-auto px-4 py-4">
          <Link href="/">首页</Link>
          <Link href="/about">关于</Link>
          <Link href="/contact">联系</Link>
        </div>
      </nav>
      {children}
    </div>
  )
}
 
// src/app/(dashboard)/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <aside className="w-64 bg-gray-800 text-white p-4">
        {/* 后台侧边栏 */}
        <nav>
          <Link href="/dashboard">仪表盘</Link>
          <Link href="/settings">设置</Link>
        </nav>
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  )
}

5. 路由拦截(Intercepting Routes)

路由拦截允许你在不改变 URL 的情况下显示不同的 UI(如弹窗)。

语法

使用 (.) 表示同级拦截,(..) 表示上一级拦截:

app/
├── @modal/
│   └── (.)photo/
│       └── [id]/
│           └── page.tsx    # 拦截 /photo/[id],显示为弹窗
└── photo/
    └── [id]/
        └── page.tsx         # 正常页面

💡 注意:路由拦截是高级功能,在基础阶段了解即可,详细内容在后续章节讲解。


6. 实战练习:构建商品详情页

需求

创建一个电商网站的商品列表和详情页:

  • /products - 商品列表(支持分类筛选)
  • /products/[id] - 商品详情页

实现

// src/app/products/page.tsx
interface PageProps {
  searchParams: {
    category?: string
  }
}
 
const products = [
  { id: '1', name: 'iPhone 15', price: 5999, category: '手机' },
  { id: '2', name: 'MacBook Pro', price: 12999, category: '电脑' },
  { id: '3', name: 'AirPods Pro', price: 1899, category: '配件' },
]
 
export default function ProductsPage({ searchParams }: PageProps) {
  const filteredProducts = searchParams.category
    ? products.filter((p) => p.category === searchParams.category)
    : products
 
  return (
    <div className="max-w-6xl mx-auto px-4 py-20">
      <h1 className="text-4xl font-bold mb-8">商品列表</h1>
 
      <div className="mb-6 flex gap-4">
        <a
          href="/products"
          className={`px-4 py-2 rounded ${
            !searchParams.category ? 'bg-blue-600 text-white' : 'bg-gray-200'
          }`}
        >
          全部
        </a>
        <a
          href="/products?category=手机"
          className={`px-4 py-2 rounded ${
            searchParams.category === '手机' ? 'bg-blue-600 text-white' : 'bg-gray-200'
          }`}
        >
          手机
        </a>
        <a
          href="/products?category=电脑"
          className={`px-4 py-2 rounded ${
            searchParams.category === '电脑' ? 'bg-blue-600 text-white' : 'bg-gray-200'
          }`}
        >
          电脑
        </a>
      </div>
 
      <div className="grid md:grid-cols-3 gap-6">
        {filteredProducts.map((product) => (
          <a
            key={product.id}
            href={`/products/${product.id}`}
            className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition"
          >
            <h3 className="text-xl font-semibold mb-2">{product.name}</h3>
            <p className="text-2xl font-bold text-blue-600">¥{product.price}</p>
          </a>
        ))}
      </div>
    </div>
  )
}
// src/app/products/[id]/page.tsx
interface PageProps {
  params: {
    id: string
  }
}
 
const products: Record<string, { name: string; price: number; description: string }> = {
  '1': {
    name: 'iPhone 15',
    price: 5999,
    description: '最新的 iPhone,拥有强大的 A17 芯片...',
  },
  '2': {
    name: 'MacBook Pro',
    price: 12999,
    description: '专业级笔记本电脑,适合开发者和设计师...',
  },
  '3': {
    name: 'AirPods Pro',
    price: 1899,
    description: '主动降噪无线耳机...',
  },
}
 
export default function ProductDetailPage({ params }: PageProps) {
  const product = products[params.id]
 
  if (!product) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <h1 className="text-4xl font-bold">商品未找到</h1>
      </div>
    )
  }
 
  return (
    <div className="max-w-4xl mx-auto px-4 py-20">
      <a href="/products" className="text-blue-600 hover:underline mb-8 inline-block">
        ← 返回商品列表
      </a>
      <div className="bg-white p-8 rounded-lg shadow">
        <h1 className="text-4xl font-bold mb-4">{product.name}</h1>
        <p className="text-3xl font-bold text-blue-600 mb-6">¥{product.price}</p>
        <p className="text-lg text-gray-700">{product.description}</p>
        <button className="mt-8 bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700">
          加入购物车
        </button>
      </div>
    </div>
  )
}

7. 总结

本章我们学习了:

  1. ✅ 动态路由的使用([id][...slug][[...slug]]
  2. ✅ 查询参数的读取和使用
  3. ✅ 客户端导航(useRouteruseSearchParams
  4. ✅ 路由组的概念和应用
  5. ✅ 构建了完整的商品列表和详情页

关键要点

  • 📁 动态路由使用方括号 []
  • 🔍 查询参数通过 searchParams 访问
  • 🧭 客户端导航使用 useRouter Hook
  • 📦 路由组用于组织代码,不影响 URL

下一步:在下一章,我们将深入学习组件开发,包括 Server Component 和 Client Component 的区别。