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 实战里也有服务端视角的讨论,感兴趣可以对照着读。下一节往链路深处走:下单时的库存扣减,怎么在并发面前既不超卖也不误伤。

延伸阅读