05. 路由系统基础
Next.js 的路由系统基于文件系统,但支持动态路由、查询参数、路由组等高级功能。本章将深入学习这些特性。
1. 动态路由
动态路由允许你根据 URL 参数动态生成页面。
基础语法
使用方括号 [] 创建动态路由段:
| 文件路径 | URL 示例 | 参数 |
|---|---|---|
app/blog/[id]/page.tsx | /blog/123 | id = "123" |
app/user/[userId]/page.tsx | /user/456 | userId = "456" |
app/shop/[...slug]/page.tsx | /shop/a/b/c | slug = ["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/installation2. 查询参数(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. 总结
本章我们学习了:
- ✅ 动态路由的使用(
[id]、[...slug]、[[...slug]]) - ✅ 查询参数的读取和使用
- ✅ 客户端导航(
useRouter、useSearchParams) - ✅ 路由组的概念和应用
- ✅ 构建了完整的商品列表和详情页
关键要点:
- 📁 动态路由使用方括号
[] - 🔍 查询参数通过
searchParams访问 - 🧭 客户端导航使用
useRouterHook - 📦 路由组用于组织代码,不影响 URL
下一步:在下一章,我们将深入学习组件开发,包括 Server Component 和 Client Component 的区别。