Skip to content

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>
  )
}
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 表现。

本站内容仅供学习和研究使用。