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 保留已经确认的筛选/翻页历史,输入中的每个字符先留在本地。
// 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
// 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. 完整商品列表页
<!-- 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 数组加入下面路由,并创建对应页面:
// client/src/router/index.ts:routes 数组新增项
{ path: '/products/:id', name: 'product-detail', component: () => import('@/views/ProductDetailView.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 提交
git add .
git commit -m "L23: 商品列表 + 分页 + 防抖搜索 + URL 同步"🔗 → 下一节
L24 将实现购物车功能——从商品列表点击"加入购物车",在 Pinia Store 中管理购物车状态,实现数量调整和全选结算。