Next.js 动态路由
动态路由是 Next.js 的核心功能之一,允许你创建基于参数的页面。本章将详细介绍如何使用动态路由构建灵活的应用。
动态路由基础
App Router 动态路由
在 App Router 中,使用方括号 [] 创建动态路由:
app/
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/[slug]
└── users/
└── [id]/
└── page.tsx # /users/[id]Pages Router 动态路由
在 Pages Router 中:
pages/
├── blog/
│ ├── index.tsx # /blog
│ └── [slug].tsx # /blog/[slug]
└── users/
└── [id].tsx # /users/[id]单个动态路由
App Router 实现
typescript
// app/blog/[slug]/page.tsx
interface PageProps {
params: {
slug: string
}
}
export default function BlogPost({ params }: PageProps) {
return (
<div>
<h1>博客文章</h1>
<p>文章 slug: {params.slug}</p>
</div>
)
}
// 生成静态参数 (可选)
export async function generateStaticParams() {
const posts = await fetchPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}Pages Router 实现
typescript
// pages/blog/[slug].tsx
import { useRouter } from 'next/router'
import { GetStaticProps, GetStaticPaths } from 'next'
interface BlogPostProps {
post: {
title: string
content: string
slug: string
}
}
export default function BlogPost({ post }: BlogPostProps) {
const router = useRouter()
// 如果页面还没有生成,显示加载状态
if (router.isFallback) {
return <div>加载中...</div>
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetchPosts()
const paths = posts.map((post) => ({
params: { slug: post.slug }
}))
return {
paths,
fallback: false // 或 true 或 'blocking'
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetchPost(params?.slug as string)
if (!post) {
return {
notFound: true,
}
}
return {
props: { post },
revalidate: 3600, // ISR - 1小时重新生成
}
}多层动态路由
嵌套动态路由
typescript
// app/blog/[category]/[slug]/page.tsx
interface PageProps {
params: {
category: string
slug: string
}
}
export default function CategoryPost({ params }: PageProps) {
return (
<div>
<h1>分类文章</h1>
<p>分类: {params.category}</p>
<p>文章: {params.slug}</p>
</div>
)
}
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map((post) => ({
category: post.category,
slug: post.slug,
}))
}用户资料页面示例
typescript
// app/users/[id]/profile/page.tsx
interface PageProps {
params: {
id: string
}
}
export default async function UserProfile({ params }: PageProps) {
const user = await fetchUser(params.id)
if (!user) {
return <div>用户未找到</div>
}
return (
<div>
<h1>{user.name} 的资料</h1>
<p>邮箱: {user.email}</p>
<p>注册时间: {user.createdAt}</p>
</div>
)
}捕获所有路由
使用 [...slug] 语法
typescript
// app/docs/[...slug]/page.tsx
interface PageProps {
params: {
slug: string[]
}
}
export default function DocsPage({ params }: PageProps) {
const path = params.slug.join('/')
return (
<div>
<h1>文档页面</h1>
<p>路径: /{path}</p>
<p>段落: {JSON.stringify(params.slug)}</p>
</div>
)
}
// 匹配:
// /docs/getting-started -> slug: ['getting-started']
// /docs/api/users -> slug: ['api', 'users']
// /docs/guide/advanced/hooks -> slug: ['guide', 'advanced', 'hooks']可选捕获所有路由
typescript
// app/shop/[[...slug]]/page.tsx
interface PageProps {
params: {
slug?: string[]
}
}
export default function ShopPage({ params }: PageProps) {
if (!params.slug) {
// 匹配 /shop
return <div>商店首页</div>
}
const [category, subcategory, product] = params.slug
if (product) {
// 匹配 /shop/electronics/phones/iphone
return <div>产品页面: {product}</div>
}
if (subcategory) {
// 匹配 /shop/electronics/phones
return <div>子分类: {subcategory}</div>
}
// 匹配 /shop/electronics
return <div>分类: {category}</div>
}查询参数和搜索参数
App Router 处理搜索参数
typescript
// app/search/page.tsx
interface PageProps {
searchParams: {
q?: string
page?: string
category?: string
}
}
export default function SearchPage({ searchParams }: PageProps) {
const query = searchParams.q || ''
const page = parseInt(searchParams.page || '1')
const category = searchParams.category
return (
<div>
<h1>搜索结果</h1>
<p>查询: {query}</p>
<p>页码: {page}</p>
{category && <p>分类: {category}</p>}
</div>
)
}
// URL: /search?q=nextjs&page=2&category=tutorial客户端获取搜索参数
typescript
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchComponent() {
const searchParams = useSearchParams()
const query = searchParams.get('q')
const page = searchParams.get('page')
return (
<div>
<p>查询: {query}</p>
<p>页码: {page}</p>
</div>
)
}路由参数验证
参数类型验证
typescript
// app/users/[id]/page.tsx
import { notFound } from 'next/navigation'
interface PageProps {
params: {
id: string
}
}
export default async function UserPage({ params }: PageProps) {
// 验证 ID 格式
const userId = parseInt(params.id)
if (isNaN(userId) || userId <= 0) {
notFound()
}
const user = await fetchUser(userId)
if (!user) {
notFound()
}
return (
<div>
<h1>{user.name}</h1>
<p>用户 ID: {userId}</p>
</div>
)
}使用 Zod 验证
typescript
// lib/validations.ts
import { z } from 'zod'
export const userParamsSchema = z.object({
id: z.string().regex(/^\d+$/, '用户 ID 必须是数字')
})
export const postParamsSchema = z.object({
slug: z.string().min(1, 'slug 不能为空').regex(/^[a-z0-9-]+$/, 'slug 格式无效')
})typescript
// app/users/[id]/page.tsx
import { userParamsSchema } from '@/lib/validations'
import { notFound } from 'next/navigation'
export default async function UserPage({ params }: { params: { id: string } }) {
try {
const { id } = userParamsSchema.parse(params)
const user = await fetchUser(parseInt(id))
if (!user) {
notFound()
}
return <div>{user.name}</div>
} catch (error) {
notFound()
}
}动态导航
编程式导航
typescript
'use client'
import { useRouter } from 'next/navigation'
export default function NavigationExample() {
const router = useRouter()
const goToUser = (userId: number) => {
router.push(`/users/${userId}`)
}
const goToPost = (slug: string) => {
router.push(`/blog/${slug}`)
}
const goWithQuery = () => {
router.push('/search?q=nextjs&page=1')
}
return (
<div>
<button onClick={() => goToUser(123)}>
查看用户 123
</button>
<button onClick={() => goToPost('my-first-post')}>
查看文章
</button>
<button onClick={goWithQuery}>
搜索 Next.js
</button>
</div>
)
}Link 组件导航
typescript
import Link from 'next/link'
interface Post {
id: number
slug: string
title: string
}
interface PostListProps {
posts: Post[]
}
export default function PostList({ posts }: PostListProps) {
return (
<div>
{posts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="block p-4 border rounded hover:bg-gray-50"
>
<h3>{post.title}</h3>
</Link>
))}
</div>
)
}动态路由的 SEO 优化
动态元数据
typescript
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
interface PageProps {
params: { slug: string }
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const post = await fetchPost(params.slug)
if (!post) {
return {
title: '文章未找到',
}
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
export default async function BlogPost({ params }: PageProps) {
const post = await fetchPost(params.slug)
if (!post) {
return <div>文章未找到</div>
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}结构化数据
typescript
// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetchProduct(params.id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'CNY',
availability: 'https://schema.org/InStock',
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>价格: ¥{product.price}</p>
</div>
</>
)
}性能优化
预加载和预取
typescript
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export default function PostCard({ post }: { post: Post }) {
const router = useRouter()
return (
<div
onMouseEnter={() => {
// 鼠标悬停时预取页面
router.prefetch(`/blog/${post.slug}`)
}}
>
<Link href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</Link>
</div>
)
}增量静态再生 (ISR)
typescript
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // 1小时重新验证
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}错误处理
自定义 404 页面
typescript
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold mb-4">文章未找到</h1>
<p className="text-gray-600 mb-8">
抱歉,您访问的文章不存在或已被删除。
</p>
<Link
href="/blog"
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
>
返回博客首页
</Link>
</div>
)
}错误边界
typescript
// app/blog/[slug]/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold mb-4">出错了</h1>
<p className="text-gray-600 mb-8">
加载文章时发生错误: {error.message}
</p>
<button
onClick={reset}
className="bg-red-500 text-white px-6 py-2 rounded hover:bg-red-600"
>
重试
</button>
</div>
)
}实际应用示例
电商产品页面
typescript
// app/products/[category]/[id]/page.tsx
interface PageProps {
params: {
category: string
id: string
}
}
export async function generateStaticParams() {
const products = await fetchAllProducts()
return products.map((product) => ({
category: product.category,
id: product.id.toString(),
}))
}
export async function generateMetadata({ params }: PageProps) {
const product = await fetchProduct(params.id)
return {
title: `${product.name} - ${product.category}`,
description: product.description,
}
}
export default async function ProductPage({ params }: PageProps) {
const product = await fetchProduct(params.id)
return (
<div className="max-w-4xl mx-auto p-6">
<nav className="mb-6">
<Link href="/products">产品</Link>
{' > '}
<Link href={`/products/${params.category}`}>
{params.category}
</Link>
{' > '}
<span>{product.name}</span>
</nav>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<img
src={product.image}
alt={product.name}
className="w-full rounded-lg"
/>
</div>
<div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-2xl text-green-600 mb-4">¥{product.price}</p>
<p className="text-gray-600 mb-6">{product.description}</p>
<button className="bg-blue-500 text-white px-8 py-3 rounded-lg hover:bg-blue-600">
加入购物车
</button>
</div>
</div>
</div>
)
}总结
Next.js 动态路由提供了强大的功能:
- 灵活的路由结构 - 支持单个和多个动态段
- 捕获所有路由 - 处理任意深度的路径
- 类型安全 - TypeScript 支持参数类型
- SEO 友好 - 动态元数据和结构化数据
- 性能优化 - 预取、ISR 等优化策略
通过合理使用动态路由,你可以构建灵活、可扩展的应用程序,同时保持良好的用户体验和 SEO 表现。