Skip to content

L23 · 商品列表:分页、搜索与 URL 同步 ​

🎯 本节目标:实现带分页、防抖搜索、URL 查询参数同步的商品列表页
📦 本节产出:可分享 URL 的商品列表 + 防抖搜索 + 加载状态 + 请求竞态处理
🔗 前置钩子:L21 的 Axios 封装 + useRequest、L22 的 JWT 认证
🔗 后续钩子:L24 将从商品列表添加到购物车

1. 需求分析 ​


2. URL 查询参数同步 ​

2.1 为什么要同步到 URL ​

用户操作:搜索「手机」→ 排序「价格从低到高」→ 翻到第 3 页
URL 变为:/products?search=手机&sort=price&page=3

好处:
✅ 刷新页面状态不丢失
✅ 复制链接发给别人,恢复相同筛选条件(商品数据可能已经变化)
✅ 浏览器前进/后退按预期工作

2.2 useRouteQuery composable ​

URL query 可能是字符串、null 或数组,不能直接断言为 string。这里把不合法值回退为默认值;单次筛选变化同时清掉 page,避免先按旧页码发一次请求。使用 push 保留已经确认的筛选/翻页历史,输入中的每个字符先留在本地。

typescript
// client/src/composables/useRouteQuery.ts
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteQuery<T extends string = string>(
  key: string,
  defaultValue: T,
  options: { resetPage?: boolean; allowed?: readonly T[] } = {},
) {
  const route = useRoute()
  const router = useRouter()
  return computed<T>({
    get() {
      const value = route.query[key]
      if (typeof value !== 'string' || !value) return defaultValue
      if (options.allowed && !options.allowed.includes(value as T)) return defaultValue
      return value as T
    },
    set(value) {
      const query = { ...route.query, [key]: value === defaultValue ? undefined : value }
      if (options.resetPage) query.page = undefined
      void router.push({ query })
    },
  })
}

export function useRouteQueryNumber(key: string, defaultValue = 1) {
  const route = useRoute()
  const router = useRouter()
  const valid = (value: number) => Number.isSafeInteger(value) && value >= 1 && value <= 1000000
  return computed({
    get() {
      const value = route.query[key]
      if (typeof value !== 'string' || !/^\d+$/.test(value)) return defaultValue
      const number = Number(value)
      return valid(number) ? number : defaultValue
    },
    set(value: number) {
      if (!valid(value)) return
      void router.push({ query: { ...route.query, [key]: value === defaultValue ? undefined : String(value) } })
    },
  })
}

这里的页码上限是示例 UI 的防御性限制,服务端仍独立验证。不合法页码、非单值参数和未支持的排序会回退,不会把 NaN、重复参数数组或负页码发给 API;URL 中过长的搜索词仍由服务端返回验证错误。push 与 replace 的区别是是否新增历史记录。Vue Router 导航方法


3. 防抖搜索 ​

3.1 useDebouncedRef ​

typescript
// client/src/composables/useDebouncedRef.ts
import { shallowRef, watch, type Ref } from 'vue'

export function useDebouncedRef<T>(source: Ref<T>, delay = 300) {
  const debounced = shallowRef<T>(source.value)
  watch(source, (value, _old, onCleanup) => {
    const timer = setTimeout(() => { debounced.value = value }, delay)
    onCleanup(() => clearTimeout(timer))
  })
  return debounced
}

在 setup 中同步创建 watcher;源值再次变化或组件卸载时,cleanup 会清除还没执行的定时器。本例监听字符串替换,不用于监听对象内部的任意变动。Vue watcher 清理


4. 完整商品列表页 ​

vue
<!-- client/src/views/ProductListView.vue -->
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { productApi, type ProductListParams } from '@/api/products'
import { useRequest } from '@/composables/useRequest'
import { useRouteQuery, useRouteQueryNumber } from '@/composables/useRouteQuery'
import { useDebouncedRef } from '@/composables/useDebouncedRef'

const sortOptions = [
  { value: '-createdAt', label: '最新上架' },
  { value: 'price', label: '价格从低到高' },
  { value: '-price', label: '价格从高到低' },
  { value: '-rating', label: '评分最高' },
] as const
type Sort = typeof sortOptions[number]['value']
const route = useRoute()
const page = useRouteQueryNumber('page', 1)
const searchQuery = useRouteQuery<string>('search', '', { resetPage: true })
const sortBy = useRouteQuery<Sort>('sort', '-createdAt', {
  resetPage: true, allowed: sortOptions.map(option => option.value),
})
const category = useRouteQuery<string>('category', '', { resetPage: true })

// 输入草稿不立即改 URL;确认后的 URL 是请求参数的唯一来源。
const searchInput = ref(searchQuery.value)
const debouncedSearch = useDebouncedRef(searchInput, 300)
watch(debouncedSearch, value => {
  const query = value.trim()
  if (query !== searchQuery.value) searchQuery.value = query
})
// 前进/后退或点击其他筛选时,丢弃未提交的输入草稿。
watch(() => route.fullPath, () => { searchInput.value = searchQuery.value })

const params = computed<ProductListParams>(() => ({
  page: page.value, limit: 12,
  search: searchQuery.value || undefined,
  sort: sortBy.value,
  category: category.value || undefined,
}))
const { data, loading, error, execute: fetchProducts } = useRequest(
  signal => productApi.getList(params.value, signal),
)
watch(params, () => { void fetchProducts() }, { immediate: true })

const visiblePages = computed(() => {
  const total = data.value?.pagination.totalPages || 0
  const start = Math.max(1, Math.min(page.value - 2, total - 4))
  return Array.from({ length: Math.min(5, total) }, (_, i) => start + i)
})
function goToPage(value: number) {
  if (value < 1 || value > (data.value?.pagination.totalPages || 1)) return
  page.value = value
  window.scrollTo({ top: 0, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
}
</script>

<template>
  <div class="product-page">
    <!-- 搜索和筛选栏 -->
    <div class="filter-bar">
      <div class="search-box">
        <input
          v-model="searchInput"
          aria-label="搜索商品"
          maxlength="100"
          placeholder="搜索商品..."
          class="search-input"
        />
        <span v-if="loading" class="search-spinner">⏳</span>
      </div>

      <select v-model="sortBy" class="sort-select" aria-label="商品排序">
        <option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      <label>分类 <input v-model.lazy="category" maxlength="80" placeholder="输入分类,离开输入框确认" /></label>
    </div>

    <!-- 加载状态 -->
    <div v-if="loading" class="skeleton-grid" aria-label="正在加载商品" aria-busy="true">
      <div v-for="i in 12" :key="i" class="skeleton-card">
        <div class="skeleton-image pulse"></div>
        <div class="skeleton-text pulse"></div>
        <div class="skeleton-text short pulse"></div>
      </div>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-state">
      <p>{{ error }}</p>
      <button @click="fetchProducts()" class="retry-btn">🔄 重试</button>
    </div>

    <!-- 空状态 -->
    <div v-else-if="data && data.data.length === 0" class="empty-state">
      <p>{{ searchQuery ? `没有找到"${searchQuery}"相关的商品` : '当前页暂无商品' }}</p>
      <button v-if="page > 1" @click="page = 1">回到第一页</button>
    </div>

    <!-- 商品列表 -->
    <div v-else-if="data" class="product-grid">
      <RouterLink
        v-for="product in data.data"
        :key="product._id"
        class="product-card"
        :to="{ name: 'product-detail', params: { id: product._id } }"
      >
        <div class="card-image">
          <img v-if="product.images[0]" :src="product.images[0]" :alt="product.name" loading="lazy" />
          <span v-else>暂无图片</span>
          <span v-if="product.stock === 0" class="sold-out-badge">售罄</span>
        </div>
        <div class="card-body">
          <h3 class="card-title">{{ product.name }}</h3>
          <div class="card-meta">
            <span class="card-price">¥{{ product.price.toLocaleString() }}</span>
            <span class="card-rating">⭐ {{ product.rating.toFixed(1) }}</span>
          </div>
        </div>
      </RouterLink>
    </div>

    <!-- 分页器 -->
    <div v-if="!loading && !error && data && data.pagination.totalPages > 1" class="pagination">
      <button
        :disabled="page <= 1"
        @click="goToPage(page - 1)"
        class="page-btn"
      >
        ← 上一页
      </button>

      <div class="page-numbers">
        <!-- 最多显示相邻 5 页,避免大结果集渲染成千上万个按钮 -->
        <button
          v-for="p in visiblePages"
          :key="p"
          :class="['page-num', { active: p === page }]"
          @click="goToPage(p)"
        >
          {{ p }}
        </button>
      </div>

      <button
        :disabled="page >= data.pagination.totalPages"
        @click="goToPage(page + 1)"
        class="page-btn"
      >
        下一页 →
      </button>
      <span>第 {{ page }} / {{ data.pagination.totalPages }} 页</span>
    </div>
  </div>
</template>

<style scoped>
.product-page { padding: 24px; max-width: 1200px; margin: 0 auto; }

/* 筛选栏 */
.filter-bar {
  display: flex; gap: 12px; margin-bottom: 24px;
  align-items: center; flex-wrap: wrap;
}
.search-box { position: relative; flex: 1; min-width: 200px; }
.search-input {
  width: 100%; padding: 10px 40px 10px 16px;
  border: 1px solid var(--border-color, #ddd); border-radius: 8px;
  font-size: 0.9rem;
}
.search-spinner { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); }
.sort-select {
  padding: 10px 16px; border: 1px solid var(--border-color, #ddd);
  border-radius: 8px; font-size: 0.9rem; background: white;
}

/* 商品网格 */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 20px;
}

.product-card {
  color: inherit; text-decoration: none;
  border: 1px solid var(--border-color, #e0e0e0);
  border-radius: 12px; overflow: hidden;
  cursor: pointer; transition: box-shadow 0.2s, transform 0.2s;
}
.product-card:hover {
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); transform: translateY(-2px);
}

.card-image { position: relative; aspect-ratio: 1; overflow: hidden; background: #f5f5f5; }
.card-image img { width: 100%; height: 100%; object-fit: cover; }
.sold-out-badge {
  position: absolute; top: 8px; right: 8px;
  background: rgba(0,0,0,0.7); color: white;
  padding: 2px 10px; border-radius: 4px; font-size: 0.75rem;
}

.card-body { padding: 12px 14px; }
.card-title { font-size: 0.9rem; margin: 0 0 8px; line-height: 1.4; }
.card-meta { display: flex; justify-content: space-between; align-items: center; }
.card-price { font-size: 1.1rem; font-weight: 700; color: #e74c3c; }
.card-rating { font-size: 0.8rem; color: #999; }

/* 分页 */
.pagination {
  display: flex; justify-content: center; align-items: center; gap: 8px;
  margin-top: 32px; padding-top: 24px; border-top: 1px solid #eee;
}
.page-btn {
  padding: 8px 16px; border: 1px solid #ddd; border-radius: 6px;
  background: white; cursor: pointer; font-size: 0.85rem;
}
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-numbers { display: flex; gap: 4px; }
.page-num {
  width: 36px; height: 36px; border: 1px solid #ddd; border-radius: 6px;
  background: white; cursor: pointer; font-size: 0.85rem;
}
.page-num.active { background: #42b883; color: white; border-color: #42b883; }

/* 骨架屏 */
.skeleton-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px;
}
.skeleton-card { border-radius: 12px; overflow: hidden; border: 1px solid #eee; }
.skeleton-image { aspect-ratio: 1; background: #e0e0e0; }
.skeleton-text { height: 14px; margin: 12px 14px; background: #e0e0e0; border-radius: 4px; }
.skeleton-text.short { width: 60%; }
.pulse { animation: pulse 1.5s infinite ease-in-out; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .pulse { animation: none; } .product-card { transition: none; } }

/* 状态 */
.empty-state, .error-state { text-align: center; padding: 60px 20px; color: #999; }
.retry-btn { padding: 8px 20px; border: none; border-radius: 6px; background: #42b883; color: white; cursor: pointer; }
</style>

5. 竞态处理 ​

快速翻页时(1→2→3→4),旧请求可能比新请求晚返回,导致显示了错误的页面数据。

L21 的 useRequest 已同时实现 AbortSignal 和请求序号校验。这里直接复用,不要用一个省略版 execute 覆盖它。第 4 节将 signal 传入 productApi,并只监听确认后的 params;搜索词与页码在同一次导航中修改,因此不会先用新搜索词请求旧页码。

即使客户端取消晚了一步,或某个请求函数不响应 abort,序号检查仍能挡住迟到数据。请求错误和 loading 的收尾也要检查序号,不能只保护 data。取消请求不负责撤销服务端写操作。

补齐商品详情入口 ​

商品卡片使用真实链接,键盘也能打开。向 L22 的 routes 数组加入下面路由,并创建对应页面:

typescript
// client/src/router/index.ts:routes 数组新增项
{ path: '/products/:id', name: 'product-detail', component: () => import('@/views/ProductDetailView.vue') },
vue
<!-- client/src/views/ProductDetailView.vue -->
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { productApi } from '@/api/products'
import { useRequest } from '@/composables/useRequest'
const route = useRoute()
const { data, loading, error, execute } = useRequest(
  (signal, id: string) => productApi.getById(id, signal),
)
watch(() => route.params.id, id => {
  if (typeof id === 'string') void execute(id)
}, { immediate: true })
</script>
<template>
  <main>
    <RouterLink to="/products">返回商品列表</RouterLink>
    <p v-if="loading">加载中…</p>
    <p v-else-if="error" role="alert">{{ error }}</p>
    <article v-else-if="data">
      <h1>{{ data.data.name }}</h1>
      <img v-if="data.data.images[0]" :src="data.data.images[0]" :alt="data.data.name" width="320" />
      <p>{{ data.data.description }}</p><p>¥{{ data.data.price.toFixed(2) }}</p>
      <p>{{ data.data.stock > 0 ? `库存 ${data.data.stock}` : '售罄' }}</p>
    </article>
  </main>
</template>

详情监听参数,支持同一个组件实例从商品 A 切到 B。未找到、已下架和非法 ID 由服务端返回错误,页面显示相应提示。


6. 本节总结 ​

🔬 深度专题 ​

📖 D13 · 请求竞态处理 — 快速切换页面时如何避免数据错乱?

检查清单 ​

  • [ ] 能用 useRouteQuery 将筛选状态同步到 URL
  • [ ] 能实现防抖搜索(useDebouncedRef),避免每键发请求
  • [ ] 能实现商品列表的分页加载
  • [ ] 能处理加载、错误、空结果三种状态
  • [ ] 能实现骨架屏加载效果
  • [ ] 能理解并解决请求竞态问题
  • [ ] 理解搜索变化时需要重置页码

Git 提交 ​

bash
git add .
git commit -m "L23: 商品列表 + 分页 + 防抖搜索 + URL 同步"

🔗 → 下一节 ​

L24 将实现购物车功能——从商品列表点击"加入购物车",在 Pinia Store 中管理购物车状态,实现数量调整和全选结算。