2.2 商品列表:分页加载与骨架屏
场景
列表页是顾客进小程序的第一屏,也是第一版最容易做砸的地方。我的第一版图省事,页面打开时一次性把 goods 集合全部两百条拉下来,结果真机上首屏要等四秒多——光是等数据就两秒,渲染又转了两秒。朋友拿着手机在店里转了一圈,说感觉像在等加载转圈,不像在逛商场。
更难看的是第二屏:图片绑定的全是云存储原始域名,没做懒加载,列表一滚几十张图同时请求,安卓中端机直接卡成幻灯片。顾客在店里站着等的那个表情,让我下定决心把列表页当成正经性能项目来做。
我们后来定了条规矩:每次性能改动,都用店里那台骁龙中端机走一遍完整的“从打开到滑到底”流程,并记录每个阶段的时间点,不用高端机自欺欺人。这节讲的就是分页加载与骨架屏这两件事:数据别一次到齐,图别一次全抢。
方案
加载策略我对比过三种:全量拉取(图省事、首屏慢)、分页加载(首屏只取第一页,触底再取下一页)、游标分页(按最后一条的排序值翻页)。两百个 SKU 的规模,分页加载绰绰有余,实现也最简单。朋友听完三种方案的第一反应是“为什么不能一次全给我”,我给她打了个比方:一次两百道菜端上桌,桌子先被压垮;一页一页上,客人永远有热菜吃。
| 策略 | 首屏数据量 | 实现难度 | 适用规模 |
|---|---|---|---|
| 全量拉取 | 200 条 | 低 | 几十条以内 |
| 分页加载 | 20 条 | 中 | 几百到上千条 |
| 游标分页 | 20 条 | 高 | 万级以上深翻 |
骨架屏则替代了原来的菊花转圈:首屏停留瞬间先画出灰色的卡片轮廓(封面块、两行文字块、价格块),数据回来再替换成真实内容。顾客的体感差别很大——转圈是在“等”,骨架屏是在“加载中”,心理等待时间能差一倍。这套体验是跟小程序信息流学的做法,那本书里有更完整的列表体验设计。
每页条数 20 是我试出来的平衡点:一屏大约显示四张半卡片,20 条正好够滑到第五屏再触发加载,用户几乎遇不到“滑到底了还没加载”的空窗;请求量又比一次 50 条小得多。规模再长的列表可以调到 10 到 15 条,首屏更快。
除了分页,列表首屏还多吃两口水:数据返回前先从本地缓存读上一页做兜底(数据回来再静默替换),以及把封面图统一走 CDN 域名比原始域名快一截。缓存兜底的代价是偶尔会闪一下旧数据,我们做了个判断——两秒内数据回来就直接替换,超过则保留并重新拉取,体感上顾客几乎察觉不到。
配置/代码
云函数 getGoods 做分页,前端传 page,每页固定 20 条,按 createdAt 倒序:
const cloud = require("wx-server-sdk");
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
exports.main = async (event) => {
const db = cloud.database();
const { page = 1, size = 20, categoryId } = event;
const where = categoryId ? { categoryId } : {};
const res = await db.collection("goods")
.where(where)
.orderBy("createdAt", "desc")
.skip((page - 1) * size)
.limit(size)
.field({ title: true, cover: true })
.get();
return { list: res.data, page, hasMore: res.data.length === size };
};
注意 hasMore 是用“本页是否满额”判断的,不额外发一次 count 请求——列表尾部的空查询每页省一次数据库调用,一个月下来省的调用费够买几杯咖啡,更重要的是少一次往返。列表价格取 SPU 下最低 SKU 价,这一步聚合放在另一个云函数 getPriceBatch 里批量跑,避免列表接口膨胀。
前端的触底加载和骨架屏核心逻辑很短:
// pages/goods/list/list.js
Page({
data: { list: [], loading: true, skeleton: true, page: 1 },
onLoad() { this.loadPage(); },
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return;
this.setData({ loading: true });
this.loadPage(this.data.page + 1);
},
async loadPage(page = 1) {
const res = await wx.cloud.callFunction({
name: "getGoods", data: { page, size: 20 } });
this.setData({
list: page === 1 ? res.result.list
: this.data.list.concat(res.result.list),
page, loading: false, skeleton: false,
hasMore: res.result.hasMore
});
}
});
骨架屏的 wxml 就是几个灰色块,用 skeleton 控制显隐,没有花活但效果立竿见影:
<!-- 骨架屏卡片,真实数据回来前重复显示四张 -->
<view class="sk-card" wx:if="{{skeleton}}">
<view class="sk-cover"></view>
<view class="sk-line w70"></view>
<view class="sk-line w40"></view>
</view>
.sk-cover { height: 340rpx; background: #eee; border-radius: 8rpx; }
.sk-line { height: 28rpx; margin-top: 16rpx; background: #eee; }
.w70 { width: 70%; } .w40 { width: 40%; }
灰色块故意做得比真实卡片略矮一点,替换时由矮变高,用户会觉得内容“长出来了”而不是“跳了一下”。这套骨架屏后来被我们全项目复用,详情页和订单页都套同一套样式,设计也不用再单独出稿。图片则统一加 lazy-load,只渲染视口内的图,列表滚动时的并发请求数从几十个降到五六个。
踩坑
第一个坑是 skip 深分页越来越慢。skip(400) 之后数据库要先跳过四百条再返回,越翻越慢。两百条的量级无感,但大促后商品涨到近千条时第五页以后明显迟钝,有顾客反馈“越翻越累”。优化办法是 createdAt 上加索引(索引建好,深分页成本主要在跳过),量再大就换游标分页,用上一页最后一条的排序值做游标,把跳过变成定位。
第二个坑是快速触底的请求竞态。用户狂滑到底部,触底事件连发好几次,如果前一次没返回,第二次又发起,回调顺序乱了列表会重复和错位,同一件商品连续出现两次。加了 loading 闸门(上面代码里的 if 判断)才治好,回调里再校验一次返回的 page 与当前是否一致,双保险。
第三个坑是骨架屏闪烁。最初骨架屏和数据是同时出现的,数据回来太快,骨架一闪显得更廉价,还不如不加。给骨架屏加了最小展示时长 300 毫秒,快归快,仪式感不能少,顾客反而觉得 app 质感更好。
第四个坑是分类筛选时列表没重置。切分类忘了把 page 和 list 清空,新一页接在旧列表后面,出现了两个分类混排的奇景,朋友的截图我至今留着。后来把切分类统一走 onLoad 里的同一套重置逻辑,顺便把滚动条也reset 回顶部。
第五个坑是封面图不带尺寸。详情没返回宽高,列表图片高度自适应导致整页跳动,滚着滚着图突然长高,误触率很高。在 goods 里冗余了 coverRatio 字段,渲染时按比例留位,滚动立刻稳了。
小结
列表页的性能配方就三味药:首屏只拉一页、骨架屏兜住等待感、图片懒加载加上比例占位。改完之后真机首屏从四秒二降到八百毫秒左右,列表滚动也再没出现卡顿,朋友来回滑了十几次,说终于像在逛商场了。小团队最怕把性能优化做成玄学,其实抓住首屏、滚动、图片这三件事,大部分体感问题就解决了。分页接口的 hasMore 判定、竞态闸门这两段代码不大,但都是被真实用户滑出来的,值得每行都看懂。下一节进详情页,处理图片预览与富文本。
延伸阅读
- 商品详情:图片预览与富文本:下一节,详情页体验
- 小程序信息流实战:列表体验的完整设计参考
- 回到本书目录:全书五篇结构一览
