一、前言:列表页是本地生活类小程序的核心
如果说首页是"门面",那么列表页就是转化的关键入口 。
无论是美食、酒店还是团购服务,用户最终都要通过列表页找到心仪商品。
一个专业的列表页应具备:
- ✅ 分类筛选(如"按销量"、"距离最近")
- ✅ 下拉刷新 & 上拉加载更多
- ✅ 空状态处理
- ✅ 高性能滚动(避免卡顿)
本文将带你从零实现一个高仿美团/大众点评的商品列表页,涵盖数据请求、分页加载、交互优化等核心技能!
💡 本案例承接《微信小程序案例 - 本地生活(首页)》,建议先阅读首页开发。
二、最终效果预览
功能模块:
- 顶部筛选栏(综合、销量、价格)
- 商品卡片列表(带图片、标题、评分、价格)
- 下拉刷新最新数据
- 上拉自动加载下一页
- 无数据时显示空状态
三、项目结构准备
确保已有以下目录结构:
local-life/
├── pages/
│ ├── index/ # 首页(已实现)
│ └── list/ # 新增列表页
│ ├── list.js
│ ├── list.json
│ ├── list.wxml
│ └── list.wxss
├── utils/
│ └── request.js # 封装的网络请求工具
└── app.json # 需注册新页面
在 app.json 中注册页面
javascript
{
"pages": [
"pages/index/index",
"pages/list/list" // ← 新增
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#fff",
"navigationBarTitleText": "本地生活",
"navigationBarTextStyle": "black"
}
}
四、页面配置(list.json)
javascript
{
"navigationBarTitleText": "美食推荐",
"enablePullDownRefresh": true,
"onReachBottomDistance": 50
}
✅
enablePullDownRefresh:开启下拉刷新✅
onReachBottomDistance:距底部 50px 触发上拉加载
五、核心逻辑:list.js(数据 + 分页)
javascript
// pages/list/list.js
import { get } from '../../utils/request';
Page({
data: {
list: [], // 商品列表
page: 1, // 当前页码
pageSize: 10, // 每页数量
hasMore: true, // 是否还有更多数据
loading: false, // 上拉加载中
sortType: 'default', // 排序类型:default | sales | price
categoryId: '', // 分类ID(可扩展)
isEmpty: false // 是否空列表
},
// 页面加载(可接收分类参数)
onLoad(options) {
if (options.categoryId) {
this.setData({ categoryId: options.categoryId });
}
this.loadData(true); // 首次加载
},
// 下拉刷新
onPullDownRefresh() {
this.setData({ page: 1, list: [] });
this.loadData(true);
},
// 上拉加载更多
onReachBottom() {
if (!this.data.loading && this.data.hasMore) {
this.setData({ loading: true });
this.loadData(false);
}
},
// 加载数据(refresh: 是否刷新)
async loadData(refresh) {
const { page, pageSize, sortType, categoryId } = this.data;
try {
// 模拟 API 请求(实际替换为你的接口)
const res = await get('/api/products', {
page,
pageSize,
sort: sortType,
category: categoryId
});
const newList = refresh ? res.data : [...this.data.list, ...res.data];
const hasMore = res.data.length === pageSize;
this.setData({
list: newList,
page: refresh ? 1 : page + 1,
hasMore,
loading: false,
isEmpty: newList.length === 0
});
} catch (err) {
wx.showToast({ title: '加载失败', icon: 'none' });
this.setData({ loading: false });
} finally {
// 停止下拉刷新动画
if (refresh) wx.stopPullDownRefresh();
}
},
// 切换排序
handleSortChange(e) {
const type = e.currentTarget.dataset.type;
if (type === this.data.sortType) return; // 避免重复点击
this.setData({ sortType: type, page: 1, list: [] });
this.loadData(true);
},
// 跳转详情页
goToDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/detail/detail?id=${id}` });
}
});
💡 提示:
get方法来自之前封装的request.js(支持 token、错误处理等)。
六、页面结构:list.wxml
javascript
<!-- pages/list/list.wxml -->
<view class="container">
<!-- 筛选栏 -->
<view class="filter-bar">
<view
class="filter-item {{sortType === 'default' ? 'active' : ''}}"
data-type="default"
bindtap="handleSortChange">
综合
</view>
<view
class="filter-item {{sortType === 'sales' ? 'active' : ''}}"
data-type="sales"
bindtap="handleSortChange">
销量
</view>
<view
class="filter-item {{sortType === 'price' ? 'active' : ''}}"
data-type="price"
bindtap="handleSortChange">
价格
</view>
</view>
<!-- 列表内容 -->
<block wx:if="{{!isEmpty}}">
<view
class="product-item"
wx:for="{{list}}"
wx:key="id"
bindtap="goToDetail"
data-id="{{item.id}}">
<image src="{{item.image}}" class="product-img" mode="aspectFill" />
<view class="product-info">
<view class="title">{{item.title}}</view>
<view class="meta">
<text class="rating">⭐ {{item.rating}}</text>
<text class="sales">{{item.sales}}人购买</text>
</view>
<view class="price-row">
<text class="price">¥{{item.price}}</text>
<text wx:if="{{item.originalPrice}}" class="origin-price">¥{{item.originalPrice}}</text>
</view>
</view>
</view>
<!-- 上拉加载提示 -->
<view wx:if="{{loading}}" class="load-more">正在加载...</view>
<view wx:elif="{{!hasMore}}" class="load-more">没有更多了</view>
</block>
<!-- 空状态 -->
<view wx:else class="empty-state">
<image src="/images/empty.png" class="empty-img" />
<text class="empty-text">暂无相关商品</text>
</view>
</view>
七、样式美化:list.wxss
javascript
/* pages/list/list.wxss */
.container {
padding: 0 20rpx;
background-color: #f8f8f8;
}
/* 筛选栏 */
.filter-bar {
display: flex;
background: white;
padding: 20rpx 0;
border-radius: 16rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.filter-item {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #666;
}
.filter-item.active {
color: #e74c3c;
font-weight: bold;
}
/* 商品项 */
.product-item {
display: flex;
background: white;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.08);
}
.product-img {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin-right: 20rpx;
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.title {
font-size: 28rpx;
font-weight: bold;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
justify-content: space-between;
color: #999;
font-size: 24rpx;
margin: 10rpx 0;
}
.rating {
color: #ff9900;
}
.sales {
color: #666;
}
.price-row {
display: flex;
align-items: center;
}
.price {
font-size: 32rpx;
color: #e74c3c;
font-weight: bold;
}
.origin-price {
font-size: 24rpx;
color: #999;
text-decoration: line-through;
margin-left: 10rpx;
}
/* 加载提示 */
.load-more {
text-align: center;
padding: 30rpx 0;
color: #999;
font-size: 26rpx;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 200rpx 0;
}
.empty-img {
width: 200rpx;
height: 200rpx;
opacity: 0.6;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
八、关键知识点总结
| 功能 | 实现方式 |
|---|---|
| 下拉刷新 | onPullDownRefresh + wx.stopPullDownRefresh() |
| 上拉加载 | onReachBottom + 分页参数控制 |
| 排序切换 | data-type 传参 + 重置分页 |
| 空状态 | wx:if 判断列表长度 |
| 性能优化 | 使用 wx:key、避免深层嵌套 |
九、扩展建议(进阶方向)
- 添加搜索框:支持关键词过滤
- 地图模式切换:列表 ↔ 地图双视图
- 骨架屏加载:提升等待体验
- 缓存分页数据:避免重复请求
- 接入真实后端:替换 mock 数据
十、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!