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
typescript
// client/src/composables/useRouteQuery.ts
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useRouteQuery(key: string, defaultValue: string = '') {
const route = useRoute()
const router = useRouter()
return computed({
get() {
return (route.query[key] as string) || defaultValue
},
set(value: string) {
router.replace({
query: {
...route.query,
[key]: value || undefined, // 空值时从 URL 移除
},
})
},
})
}
// 数字版本
export function useRouteQueryNumber(key: string, defaultValue: number = 1) {
const route = useRoute()
const router = useRouter()
return computed({
get() {
const val = route.query[key]
return val ? parseInt(val as string) : defaultValue
},
set(value: number) {
router.replace({
query: {
...route.query,
[key]: value !== defaultValue ? String(value) : undefined,
},
})
},
})
}3. 防抖搜索
3.1 useDebouncedRef
typescript
// client/src/composables/useDebouncedRef.ts
import { ref, watch, type Ref } from 'vue'
export function useDebouncedRef<T>(
source: Ref<T>,
delay: number = 300
): Ref<T> {
const debounced = ref(source.value) as Ref<T>
let timer: ReturnType<typeof setTimeout>
watch(source, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = newVal
}, delay)
})
return debounced
}4. 完整商品列表页
vue
<!-- client/src/views/ProductListView.vue -->
<script setup lang="ts">
import { watch, computed } from 'vue'
import { productApi, type ProductListParams } from '@/api/products'
import { useRequest } from '@/composables/useRequest'
import { useRouteQuery, useRouteQueryNumber } from '@/composables/useRouteQuery'
import { useDebouncedRef } from '@/composables/useDebouncedRef'
// ─── URL 同步状态 ───
const page = useRouteQueryNumber('page', 1)
const searchQuery = useRouteQuery('search', '')
const sortBy = useRouteQuery('sort', '-createdAt')
const category = useRouteQuery('category', '')
// ─── 防抖搜索 ───
const debouncedSearch = useDebouncedRef(searchQuery, 300)
// ─── 请求参数 ───
const params = computed<ProductListParams>(() => ({
page: page.value,
limit: 12,
search: debouncedSearch.value || undefined,
sort: sortBy.value,
category: category.value || undefined,
}))
// ─── 数据请求 ───
const { data, loading, error, execute: fetchProducts } = useRequest(
() => productApi.getList(params.value),
{ immediate: true }
)
// 参数变化时重新请求
watch(params, () => {
fetchProducts()
})
// 搜索变化时重置页码
watch(debouncedSearch, () => {
page.value = 1
})
// ─── 排序选项 ───
const sortOptions = [
{ value: '-createdAt', label: '最新上架' },
{ value: 'price', label: '价格从低到高' },
{ value: '-price', label: '价格从高到低' },
{ value: '-rating', label: '评分最高' },
]
// ─── 分页 ───
function goToPage(p: number) {
page.value = p
window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
<template>
<div class="product-page">
<!-- 搜索和筛选栏 -->
<div class="filter-bar">
<div class="search-box">
<input
v-model="searchQuery"
placeholder="搜索商品..."
class="search-input"
/>
<span v-if="loading" class="search-spinner">⏳</span>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<!-- 加载状态 -->
<div v-if="loading && !data" class="skeleton-grid">
<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>
</div>
<!-- 商品列表 -->
<div v-else-if="data" class="product-grid">
<div
v-for="product in data.data"
:key="product._id"
class="product-card"
@click="$router.push(`/products/${product._id}`)"
>
<div class="card-image">
<img :src="product.images[0]" :alt="product.name" loading="lazy" />
<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>
</div>
</div>
<!-- 分页器 -->
<div v-if="data && data.pagination.totalPages > 1" class="pagination">
<button
:disabled="page <= 1"
@click="goToPage(page - 1)"
class="page-btn"
>
← 上一页
</button>
<div class="page-numbers">
<button
v-for="p in data.pagination.totalPages"
: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>
</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 {
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; } }
/* 状态 */
.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),旧请求可能比新请求晚返回,导致显示了错误的页面数据。
解决方案:请求 ID 校验
typescript
// 在 useRequest 中添加竞态处理
let requestId = 0
async function execute() {
const currentId = ++requestId // 每次请求递增
loading.value = true
try {
const result = await requestFn()
// 只有最新的请求才更新数据
if (currentId === requestId) {
data.value = result
}
} catch (err) {
if (currentId === requestId) {
error.value = err.message
}
} finally {
if (currentId === requestId) {
loading.value = false
}
}
}6. 本节总结
检查清单
- [ ] 能用
useRouteQuery将筛选状态同步到 URL - [ ] 能实现防抖搜索(
useDebouncedRef),避免每键发请求 - [ ] 能实现商品列表的分页加载
- [ ] 能处理加载、错误、空结果三种状态
- [ ] 能实现骨架屏加载效果
- [ ] 能理解并解决请求竞态问题
- [ ] 理解搜索变化时需要重置页码
Git 提交
bash
git add .
git commit -m "L23: 商品列表 + 分页 + 防抖搜索 + URL 同步"🔗 → 下一节
L24 将实现购物车功能——从商品列表点击"加入购物车",在 Pinia Store 中管理购物车状态,实现数量调整和全选结算。