3.1 购物车:本地缓存与云端同步
场景
购物车是小程序里最容易做粗的一个模块。第一版我们做得很“标准”:加点按钮、列表页、全选反选、合计金额,半天写完,觉得没啥难度。上线第二天朋友就来问:顾客在店里挑好的东西,回家打开怎么没了?我理直气壮:购物车存在本地缓存里啊,换个手机当然没有。她说:那你觉得顾客会怎么想?顾客只会觉得你的车是漏的。
这还没完。本地缓存方案的天花板比我想象的低:顾客换设备、清缓存、卸载重装,车全空。更隐蔽的问题是多端不一致——顾客上午在手机上加了一只杯子,下午用 iPad 打开看一看(对,就是那种能扫码的轻量场景),车里趴着两套数据,谁也不知道该信哪边。
还有个真实的糟心案例:有顾客反馈车里的价格和详情页不一样。一查,商品调价了,但本地缓存的购物车里存的还是旧价格快照,合计金额自然对不上。这种数据对不齐的小问题最伤信任——顾客不懂快照,他只知道你算错了钱。那天朋友把聊天记录转发给我看,顾客说“你们这价格一天三个样”,我看了直冒冷汗。购物车看起来简单,本质是一个分布式数据同步问题,只是藏在“加购”这个朴素的动作背后。这节讲我们怎么重构购物车:本地缓存保体验,云端同步保数据,快照与实时价格分开管。
方案
重构后的购物车架构是“本地为显示、云端为准”的双写模型:
| 设计点 | 目的 | 做法 |
|---|---|---|
| 本地缓存 | 打开即有、离线可看 | cart 结构存在 storage |
| 云端同步 | 换设备不丢 | 关键操作立即同步 |
| 登录合并 | 游客与登录身份衔接 | 登录时本地车并入云端车 |
| 价格不落地 | 防止价格快照过期 | 车里只存 skuId 与数量 |
| 下拉刷新校准 | 定期对账 | 拉取云端车覆盖本地 |
最关键的一条是“价格不落地”:购物车数据结构里只有 skuId 和 qty(数量),金额一律实时查。这样调价、下架、售罄的状态变化,都能在下一次渲染时自然反映,不存在快照对不齐的问题。代价是每次打开购物车都要查一次商品接口——这价格我们不打算省,因为对齐比快重要。查的时候用 where 加 in 批量拉取,20 条上限内一次搞定。
登录合并是个容易丢分的地方:顾客以游客身份加了三件货,这时才登录,如果直接用云端车覆盖本地,三件货凭空消失;反之亦然。我们的做法是登录成功后做一次合并:以 skuId 为键,数量取两者较大值(按业务定的宽松策略),写入云端再回本地。合并逻辑放在云函数里做,保证原子性。
同步时机我们也排过一张表,什么动作触发什么级别的同步,心里有数才不会乱:
| 动作 | 同步策略 | 理由 |
|---|---|---|
| 加车/减车 | 本地立即、云端两秒内聚合 | 高频,合并请求 |
| 勾选/全选 | 本地立即、云端随下次请求带上 | 低频但重要 |
| 删除商品 | 本地立即、云端立即 | 误删要尽快同步 |
| 打开购物车页 | 拉云端全量对账 | 兜底,修正一切漂移 |
| 离线期间操作 | 入待同步队列,联网后批量重放 | 无网络时也能加车 |
最后两行是这个设计的保险丝:打开页面对账兜底,离线队列保证弱网可用。有顾客反馈“地铁里加的货回到家还在”,就是这套机制在起作用——本地先写是对体验的尊重,云端最终一致是对数据的尊重。
配置/代码
本地车的数据结构尽量精简:
// 本地购物车结构(storage 里的 cart)
{
version: 2, // 结构版本,方便以后升级
items: [
{ skuId: 'sku_2024001', qty: 2, addAt: 1730000000000 }
]
}
加入购物的动作做成本地先写、云端异步跟:
async addToCart(skuId, qty) {
// 1. 本地先更新,界面立刻有反应
this._localUpsert(skuId, qty);
// 2. 云端同步,失败重试一次后入列等待
try {
await wx.cloud.callFunction({
name: 'cartSync', data: { skuId, qty, op: 'add' }
});
} catch (e) {
this._enqueuePending(skuId, qty); // 待同步队列
}
}
云函数 cartSync 里对数量做校验:单 sku 上限 20 件(精品店的定位决定的),整车上限 30 种 sku,超出直接返回错误码。这些边界值别写死在云函数里,配置化放集合,以后改不用重新部署。
购物车页面的渲染是另一个重点。前面说过价格不落地,那么渲染时就要做一次“补水”:用车里的 skuId 列表批量查 skus 集合,拿回实时价格、库存和上下架状态,再和本地车合并渲染。失效商品归到列表末尾、灰显并带“已失效”标签,合计只算勾选且有效的商品。这套渲染流程一开始写成了两层循环逐个查,20 件商品要查 20 次数据库,卡顿明显;改成 where({_id: db.command.in(list)}) 批量查之后,渲染时间从一秒多降到两百毫秒内。
合并与对账逻辑是登录后触发的:
// 云函数片段:合并本地车到云端(游客车并入会员车)
const mergeCart = async (openid, localItems) => {
const cloud = await db.collection('carts')
.where({ _openid: openid }).get();
const map = new Map();
(cloud.data[0]?.items || []).forEach(i => map.set(i.skuId, i.qty));
localItems.forEach(i =>
map.set(i.skuId, Math.max(map.get(i.skuId) || 0, i.qty)));
// 回写云端车,同时返回合并结果给前端
};
购物车渲染的补水逻辑也贴一段核心的:
// 前端:用 skuId 批量查实时信息,再与本地车合并渲染
const { result } = await wx.cloud.callFunction({
name: 'cartFetch',
data: { skuIds: items.map(i => i.skuId) }
});
// 云函数返回 {valid: [...], invalid: [...]}
this.setData({
validList: this._mergeQty(result.valid, items),
invalidList: result.invalid // 失效商品单列,不进合计
});
cartFetch 里还顺带返回每个 sku 的限购数量,前端用它拦截超量加购。这样校验逻辑只有一份,在云函数里维护,不会前端一套后台一套。
踩坑
第一个坑是清缓存导致“丢车”。纯本地方案的下场前面说过了,双写之后这类反馈降到零。数据归属要在产品设计之初就想清楚:凡是顾客自己产生的数据,就该往云端存。
第二个坑是同步频率过高。最初每次加车都立即调一次云函数,高峰期并发量惊人,云函数调用量费用也上去了。后来改成“本地立即写、云端聚合同步”:操作后两秒内的多次改动合并成一次同步请求,调用量降了一半多,体验上完全感觉不到差别。
第三个坑是购物车里商品下架没提示。服务器查询时如果下架商品被过滤掉而前端没处理,顾客看着车里的数量合计对不上,一笔糊涂账。正确做法:查询时下架或售罄的商品也返回,标记成“失效”展示在列表底部,让顾客能删不能买,清清楚楚。
第四个坑是勾选状态存本地。顾客关掉小程序再打开,勾选全丢了,合计归零,感觉像被清空了车。勾选状态也走云端同步,别省这点同步量。
第五个坑是库存校验只在下单时做。加车时没校验,顾客选了 5 件到下单才被告知只有 2 件,怒删购物车。加车时顺带回库存提示(“仅剩 2 件”),把拦截前置,体验上温柔得多。
第六个坑是购物车角标数量口径。首页和底栏 tab 上的红色角标,一开始按购物车“件数”算,批量同款加购时角标蹦到 99+,顾客以为出了 bug。后来改成按“种类数”显示,顺手解决了这个问题。角标虽小,口径要想清楚。
第七个坑是合并策略本身的争议。取较大值这个策略上线不久,有运营同学反馈:顾客在两个设备上都加了同一款的不同数量,合并后数量被放大,顾客反而来问“我没买这么多”。我们坚持了一段时间观察数据,发现这类反馈占比极低,而合并丢失的投诉会高得多,所以保留了宽松策略。合并策略没有标准答案,得看自己业务的数据反馈来定。
小结
购物车重构上线后,朋友最直观的感受是“客人回家还在车里加东西”——后台显示同一账号的跨时段加车行为占比明显提高,说明数据同步确实留住了回家的顾客。这个模块的教训很集中:凡是用“简单”两个字估过工时的模块,最后都要补课。双写模型本身不难,难的是把每一个边界(合并策略、失效商品、勾选状态)都想到位。数据层面的同步一致性,在云开发 Python 实战里也有服务端视角的讨论,感兴趣可以对照着读。下一节往链路深处走:下单时的库存扣减,怎么在并发面前既不超卖也不误伤。
延伸阅读
- 库存扣减:原子操作防超卖:下一节,并发扣减的实战
- 订单状态机:待付款到已完成:购物车之后的订单旅程
- 回到本书目录:全书五篇结构一览
