Skip to content

Lesson 22:商品展示 — 分类搜索、分页与 SEO 优化 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:90~150 分钟(首次学习)
  • 先修要求:完成 L17–21,已有使用 Prisma 的商品列表与详情页
  • 学习产出:实现 URL 筛选与分页,为商品详情添加元数据和安全的 JSON-LD
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:实现商品的分类展示、关键词搜索、服务端分页,并通过 Metadata API 和 JSON-LD 结构化数据补充搜索引擎可读取的商品信息。

📦 本节产出:用户可以按分类筛选、搜索关键词、翻页浏览的商品展示页面,并带有动态 meta 标签和结构化数据。

一、在 URL 中保存筛选条件 ​

筛选条件如果只存在 useState 中,刷新页面就会丢失。需要分享、刷新和前进后退恢复的条件,可以保存在 URL 中;CSR 项目同样能这样做。 本课把筛选条件反映在 URL 里 (/products?category=book&q=react&page=2):

优势:

  • 用户可以分享搜索结果链接
  • 浏览器后退/前进正常工作
  • 可为有价值的分类页设计索引策略;搜索结果和大量筛选组合不应无节制地被索引

二、实战:分类 + 搜索 + 分页 ​

2.1 商品列表页 ​

tsx
// src/app/products/page.tsx
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import SearchBar from './SearchBar'
import Pagination from './Pagination'
import type { Prisma } from '@prisma/client'

const PAGE_SIZE = 6

export default async function ProductsPage({
  searchParams
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const params = await searchParams
  const single = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
  const rawCategory = single(params.category)
  const category = ['book', 'electronics', 'clothing'].includes(rawCategory ?? '') ? rawCategory : undefined
  const query = (single(params.q) ?? '').trim().slice(0, 100)
  const rawPage = single(params.page) ?? '1'
  const candidate = /^\d+$/.test(rawPage) ? Number(rawPage) : 1
  const requestedPage = Number.isSafeInteger(candidate) && candidate > 0 ? candidate : 1

  const where: Prisma.ProductWhereInput = {
    ...(category && { category }),
    ...(query && {
      OR: [{ name: { contains: query } }, { description: { contains: query } }],
    }),
  }

  // 同一事务中计数、限制页码并查询,避免负数、NaN、超大 skip。
  const { products, total, totalPages, page } = await prisma.$transaction(async tx => {
    const total = await tx.product.count({ where })
    const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
    const page = Math.min(requestedPage, totalPages)
    const products = await tx.product.findMany({
      where, skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE,
      orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
    })
    return { products, total, totalPages, page }
  })

  return (
    <div className="max-w-7xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">
        全部商品
        <span className="text-lg font-normal text-gray-400 ml-2">({total} 件)</span>
      </h1>

      <SearchBar key={JSON.stringify([query, category])} defaultQuery={query} defaultCategory={category} />

      {products.length === 0 ? (
        <div className="text-center py-20 text-gray-400">
          <p className="text-5xl mb-4">🔍</p>
          <p>没有找到匹配的商品</p>
        </div>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
          {products.map(product => (
            <Link key={product.id} href={`/products/${product.id}`}
              className="group bg-white rounded-2xl border hover:shadow-lg transition-shadow overflow-hidden"
            >
              <div className="h-48 bg-gray-100 flex items-center justify-center text-5xl">📦</div>
              <div className="p-5">
                <span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
                  {product.category}
                </span>
                <h2 className="font-semibold text-lg mt-2 group-hover:text-indigo-600 transition-colors">
                  {product.name}
                </h2>
                <p className="mt-3 text-2xl font-bold text-indigo-600">¥{(product.price / 100).toFixed(2)}</p>
              </div>
            </Link>
          ))}
        </div>
      )}

      {totalPages > 1 && (
        <Pagination
          currentPage={page}
          totalPages={totalPages}
          category={category}
          query={query}
        />
      )}
    </div>
  )
}

searchParams 在 Next.js 15 中要等待 Promise,并可能包含同名参数数组。上面统一取第一个值,限制搜索长度,并让超出范围的页码显示最后一页。createdAt 相同时再按 id 排序,避免同值排序造成翻页不稳定。contains 的大小写行为依赖数据库;SQLite 和 PostgreSQL 的规则不同,切库后需重新验证。

2.2 搜索栏组件 ​

tsx
// src/app/products/SearchBar.tsx
'use client'

import { useRouter } from 'next/navigation'
import { useState } from 'react'

const CATEGORIES = [
  { value: '', label: '全部' },
  { value: 'book', label: '📚 图书' },
  { value: 'electronics', label: '💻 电子' },
  { value: 'clothing', label: '👕 服饰' },
]

export default function SearchBar({ defaultQuery, defaultCategory }: {
  defaultQuery?: string; defaultCategory?: string
}) {
  const router = useRouter()
  const [query, setQuery] = useState(defaultQuery || '')

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    const params = new URLSearchParams()
    if (query.trim()) params.set('q', query.trim())
    if (defaultCategory) params.set('category', defaultCategory)
    router.push(`/products?${params.toString()}`)
  }

  const handleCategoryClick = (cat: string) => {
    const params = new URLSearchParams()
    if (cat) params.set('category', cat)
    if (query.trim()) params.set('q', query.trim())
    router.push(`/products?${params.toString()}`)
  }

  return (
    <div>
      <form onSubmit={handleSearch} className="flex gap-2 mb-4">
        <input value={query} onChange={e => setQuery(e.target.value)}
          aria-label="搜索商品" maxLength={100} placeholder="搜索商品名称或描述..."
          className="flex-1 border rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-indigo-500 outline-none" />
        <button type="submit" className="bg-indigo-600 text-white px-6 rounded-xl hover:bg-indigo-700">
          搜索
        </button>
      </form>
      <div className="flex gap-2 flex-wrap">
        {CATEGORIES.map(cat => (
          <button key={cat.value} onClick={() => handleCategoryClick(cat.value)}
            aria-pressed={(defaultCategory || '') === cat.value}
            className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
              (defaultCategory || '') === cat.value
                ? 'bg-indigo-600 text-white'
                : 'bg-white border text-gray-600 hover:bg-gray-100'
            }`}>
            {cat.label}
          </button>
        ))}
      </div>
    </div>
  )
}

列表给 SearchBar 的 key 由 URL 筛选条件组成。通过浏览器后退、前进或链接改变条件时,输入框会按新 URL 重建,避免 useState 只读取首次 props 后一直保留旧文本。

2.3 分页组件 ​

tsx
// src/app/products/Pagination.tsx
import Link from 'next/link'

interface PaginationProps {
  currentPage: number
  totalPages: number
  category?: string
  query?: string
}

export default function Pagination({ currentPage, totalPages, category, query }: PaginationProps) {
  const buildUrl = (page: number) => {
    const params = new URLSearchParams()
    params.set('page', String(page))
    if (category) params.set('category', category)
    if (query) params.set('q', query)
    return `/products?${params.toString()}`
  }

  // 生成页码数组(最多显示 5 个)
  const getPageNumbers = () => {
    const pages: number[] = []
    const start = Math.max(1, currentPage - 2)
    const end = Math.min(totalPages, currentPage + 2)
    for (let i = start; i <= end; i++) pages.push(i)
    return pages
  }

  return (
    <nav aria-label="商品分页" className="flex items-center justify-center gap-1 mt-12">
      {/* 首页 */}
      {currentPage > 2 && (
        <Link href={buildUrl(1)}
          className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
          首页
        </Link>
      )}

      {/* 上一页 */}
      {currentPage > 1 && (
        <Link href={buildUrl(currentPage - 1)}
          className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
          ← 上一页
        </Link>
      )}

      {/* 页码 */}
      {getPageNumbers().map(p => (
        <Link key={p} href={buildUrl(p)} aria-current={p === currentPage ? 'page' : undefined}
          className={`w-10 h-10 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
            p === currentPage
              ? 'bg-indigo-600 text-white'
              : 'text-gray-500 hover:bg-gray-100'
          }`}>
          {p}
        </Link>
      ))}

      {/* 下一页 */}
      {currentPage < totalPages && (
        <Link href={buildUrl(currentPage + 1)}
          className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
          下一页 →
        </Link>
      )}

      {/* 末页 */}
      {currentPage < totalPages - 1 && (
        <Link href={buildUrl(totalPages)}
          className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
          末页
        </Link>
      )}
    </nav>
  )
}

TIP

next/link 预取: 生产环境中,进入视口的链接可触发预取。默认行为会根据静态/动态路由与 loading 边界决定预取范围,不保证点击后即时加载;prefetch={true} 会请求更完整的预取,可能增加服务器查询。这里保留默认值,见 Next.js 15 Link 文档。


三、SEO 与 Metadata API ​

先在 .env.local 设置 APP_URL=http://localhost:3000,部署时替换为真实 HTTPS 站点地址。L17 根布局的 metadata 中添加 metadataBase: new URL(process.env.APP_URL ?? 'http://localhost:3000'),为相对 OG 图片 URL 提供站点基址。

3.1 静态 Metadata ​

tsx
// 合并到 src/app/products/page.tsx,保留前面的列表组件。
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: '全部商品 — ShopNext',
  description: '浏览 ShopNext 的全部商品,包含图书、电子产品、服饰等分类。',
}

对关键词搜索页还可按业务需要返回 robots: { index: false, follow: true };若改为 generateMetadata,同一文件不要再同时导出静态 metadata。

3.2 动态 Metadata(商品详情页) ​

tsx
// 合并到 src/app/products/[id]/page.tsx,保留已有页面组件。
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'

export async function generateMetadata({
  params
}: {
  params: Promise<{ id: string }>
}): Promise<Metadata> {
  const { id } = await params
  const product = await prisma.product.findUnique({ where: { id } })

  if (!product) return { title: '商品不存在' }

  return {
    title: `${product.name} — ShopNext`,
    description: product.description || `购买 ${product.name},价格 ¥${(product.price / 100).toFixed(2)}`,
    openGraph: {
      title: product.name,
      description: product.description || undefined,
      images: product.image ? [product.image] : [],
    },
  }
}

3.3 JSON-LD 结构化数据 ​

JSON-LD 能描述商品价格和库存等信息,让页面有机会符合富媒体搜索结果的条件,但不保证展示。字段必须与页面实际可见内容一致,不能编造评分。下面替换详情组件的实现;保留 3.2 的 generateMetadata 和它的导入,将这里新增的导入合并到文件顶部:

tsx
// src/app/products/[id]/page.tsx:新增导入;prisma 导入复用 3.2 的同一条。
import { notFound } from 'next/navigation'
import AddToCartButton from './AddToCartButton'
import FavoriteButton from './FavoriteButton'

export const dynamic = 'force-dynamic'

export default async function ProductDetail({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await prisma.product.findUnique({ where: { id } })
  if (!product) notFound()

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description ?? undefined,
    ...(product.image ? { image: new URL(product.image, process.env.APP_URL ?? 'http://localhost:3000').href } : {}),
    offers: {
      '@type': 'Offer',
      price: (product.price / 100).toFixed(2),
      priceCurrency: 'CNY',
      availability: product.stock > 0
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
  }

  return (
    <>
      {/* 可以放在页面 body 中。转义 <,避免商品文本中的 </script> 打断标签。 */}
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
      <div className="max-w-4xl mx-auto px-4 py-12">
        <p>{product.category}</p>
        <h1 className="text-3xl font-bold mt-2">{product.name}</h1>
        <p className="mt-4">{product.description}</p>
        <p className="text-2xl mt-4">¥{(product.price / 100).toFixed(2)}</p>
        <p>库存:{product.stock}</p>
        <div className="mt-6 flex gap-3">
          <AddToCartButton productId={product.id} name={product.name} price={product.price} />
          <FavoriteButton productId={product.id} />
        </div>
      </div>
    </>
  )
}

JSON 字符串不是 HTML 转义器,直接插入未经处理的 JSON.stringify 结果会带来脚本注入风险。转义方式见 Next.js JSON-LD 指南;商品富结果条件见 Google Product 文档。


四、🧠 深度专题:SEO 核心要素 ​


五、练习 ​

  1. 为搜索栏添加 useDebounce(Lesson 12 学过的),实现实时搜索。
  2. 仅当已有真实评分数据,并在页面上显示评分与评价数量时,再为 JSON-LD 添加 aggregateRating;本教程尚无评价模型,不要填模拟好评冒充真实评分。
  3. 使用 Google Search Console 的 Rich Results Test 验证你的 JSON-LD 是否正确。

📌 本节小结 ​

你做了什么你学到了什么
实现了服务端分类、搜索和分页URL searchParams 驱动的查询
构建了完整的分页组件首页/末页/页码的通用分页逻辑
配置了静态和动态 MetadataNext.js Metadata API + Open Graph
添加了 JSON-LD 结构化数据Google Rich Snippets
—next/link prefetch 预取机制

项目驱动 · 边写边学 · React 19