
1. 项目概述与整体设计思路1.1 功能需求拆解做购物车页面几乎是每个电商小程序的必经之路。别小看这个页面它把一套完整的状态管理逻辑全塞进了一个看似简单的列表中单个商品的选中状态、全选反选联动、数量加减、删除确认、总价总数量实时计算再到收货地址的选择和回显每一环都牵扯着数据流的方向和UI的即时反馈。我这次整理的标题正是这个经典模块的完整实现全选和反选、修改商品数量、删除商品、计算总价格和总数量、收货地址。适合两类人参考一类是刚入门小程序开发、想搞懂列表状态管理的新手另一类是已经在做电商项目、想复用一版稳定购物车逻辑的开发者。这个页面不引入任何第三方UI库全部用原生小程序能力实现跑起来零依赖拿到任何环境里都能直接改改用。这里要特别强调一下整体设计思路。很多人在做购物车时容易把业务逻辑写在Page里一大坨看起来功能是通了但一遇到数量变化后重新计算价格删除后同步更新全选状态这种交叉需求逻辑就会乱成毛线团。所以动手前先把数据模型想清楚再按功能点逐层实现后面才不会有返工的痛苦。1.2 购物车数据模型设计购物车页面的核心不是UI而是数据。我建议把商品列表设计成数组每个元素保存商品的基础信息和一个独立的选中标记cartList: [ { id: 1, name: 无线蓝牙耳机, price: 199.00, count: 1, selected: true, image: /images/headphones.png }, { id: 2, name: 智能手环, price: 249.00, count: 2, selected: false, image: /images/band.png } ]为什么要在每个商品对象里单独维护一个selected字段而不是用一个全局选中数组原因很简单小程序的setData只会触发视图层对应数据的更新如果单独维护一个选中数组那么每一次勾选都要同步修改两个数据源很容易出现状态不同步的问题。把选中态直接挂在商品数据上setData时只需要更新对应索引的商品对象页面渲染天然一致。我实际在项目里用到的完整 data 结构是这样data: { cartList: [], allSelected: false, totalPrice: 0.00, totalCount: 0, address: null }totalPrice用字符串而不是数字原因是后端通常会要求金额按两位小数传递字符串在toFixed(2)后直接存进去不会丢精度也不会有浮点尾数。这个细节等到了计算总价那一节还会再提。2. 全选与反选的实现逻辑2.1 单个选中状态的管理单个商品选中状态是整个页面的最小粒度。我在WXML里通常用checkbox组件或者一个自定义的圆形勾选按钮来承载。用原生checkbox的好处是省事坏处是不同平台的样式有些差异所以我一般更推荐用一个view加上一行条件渲染来实现勾选样式这样在任何设备上长得都一样view classcheckbox bindtaptoggleSelect>toggleSelect(e) { const index e.currentTarget.dataset.index; const key cartList[${index}].selected; this.setData({ [key]: !this.data.cartList[index].selected }); this.calcTotalAndAllSelected(); }这里我用了小程序setData的路径语法直接通过索引字符串更新数组中某个字段的值。这样做比先把整个数组取出来、改完再整包塞回去要高效得多尤其在商品数量多的时候视图层 diff 的成本更小。需要注意dataset.index是从>toggleAllSelect() { const allSelected !this.data.allSelected; const cartList this.data.cartList.map(item { item.selected allSelected; return item; }); this.setData({ cartList, allSelected }); this.calcTotalAndAllSelected(); }关键点在于每次用户手动勾选单个商品之后也必须调用一次calcTotalAndAllSelected()来重新计算allSelected的值。否则会出现一个很典型的 bug所有商品都手动勾选了但全选按钮仍然是灰色的。calcTotalAndAllSelected要做的其实很简单——遍历一遍当前列表统计选中项的数量如果大于 0 且等于列表长度就置为 true否则为 falsecalcTotalAndAllSelected() { const cartList this.data.cartList; const selectCount cartList.filter(item item.selected).length; const allSelected cartList.length 0 selectCount cartList.length; this.setData({ allSelected }); this.calcTotal(); }这里有个边界情况很多人会漏掉当购物车为空时cartList.length是 0如果直接写selectCount cartList.length0 等于 0全选按钮会亮着这就错了。所以我在计算全选状态前先加了cartList.length 0这个前置条件宁可多写一次判断也不要让空购物车出现全选已激活的乌龙。3. 商品数量修改与删除功能的落地3.1 步进器组件与数量边界控制修改商品数量最常见的就是加减按钮。我在页面上放了一组步进器左边减号、中间数字、右边加号。这个组件看起来简单但边界条件处理不好会产生不少体验问题。先看 WXMLview classstepper view classstepper-minus bindtapdecreaseCount>increaseCount(e) { const index parseInt(e.currentTarget.dataset.index); const count this.data.cartList[index].count; this.setData({ [cartList[${index}].count]: count 1 }); this.calcTotal(); }, decreaseCount(e) { const index parseInt(e.currentTarget.dataset.index); const count this.data.cartList[index].count; if (count 1) { wx.showToast({ title: 最少购买1件, icon: none }); return; } this.setData({ [cartList[${index}].count]: count - 1 }); this.calcTotal(); }在实际项目里我会把步进器和业务数据分离成一个自定义组件因为这个小功能在很多页面都会用到。但如果你只是做一个购物车页面直接在页面里写这个逻辑也完全够用。要注意decreaseCount里必须先判断count 1否则会出现数量变成 0 甚至负数的情况。还有一种设计是把减号在 count 为 1 时置灰并不可点击两种方案我更推荐防呆判断因为它能在用户点下去的时候给出即时反馈而不是让用户感觉按钮没反应。数量上限也需要考虑。电商场景中单件商品单次购买数量通常会有一个上限比如 99。如果不做上限用户连续点加号能把数量加到好几百后端接口压力是一回事业务上也容易出问题。所以加一个上限判断和 toast 提示是比较合理的if (count 99) { wx.showToast({ title: 已达单件上限, icon: none }); return; }3.2 删除商品与数据更新删除商品我踩过一个坑第一次做的时候直接在某个删除按钮的回调里用this.data.cartList.splice(index, 1)修改数据源然后setData({ cartList })。看起来没问题但一旦用户在删除前已经做过数量加减操作再删一个中间位置的商品之后的索引全都对不上了——因为 WXML 里按钮的>deleteItem(e) { const index parseInt(e.currentTarget.dataset.index); wx.showModal({ title: 删除商品, content: 确定要把这件商品移出购物车吗, success: (res) { if (res.confirm) { const cartList this.data.cartList; cartList.splice(index, 1); this.setData({ cartList }); this.calcTotalAndAllSelected(); } } }); }删除后别忘了重新计算总价和全选状态。如果删掉的刚好是最后一个选中商品allSelected会自动变成 false总价格也会同步归零这就是为什么我在每次删除后都要调用calcTotalAndAllSelected()而不是只用calcTotal()。4. 总价格与总数量的实时计算4.1 计算函数的设计与调用时机总价格和总数量的计算逻辑不难但调用时机必须覆盖所有可能改变商品数量或选中状态的操作。我的做法是抽一个calcTotal()方法在所有相关操作之后统一调用。calcTotal() { const cartList this.data.cartList; let totalPrice 0; let totalCount 0; cartList.forEach(item { if (item.selected) { totalPrice item.price * item.count; totalCount item.count; } }); this.setData({ totalPrice: totalPrice.toFixed(2), totalCount }); }我把calcTotal()的调用点分布在这几个地方单个商品勾选、取消勾选、全选反选、数量加、数量减、删除商品。虽然看起来调用很频繁但小程序setData这样的细粒度更新并不会造成明显的性能问题。真正需要注意的反而是不要为了减少渲染而把总价计算和总数量计算拆得太碎否则每个功能点都要写两遍逻辑维护成本翻倍。之前我提过totalPrice用字符串存储这个设计在这里就体现出了价值toFixed(2)返回字符串直接赋给totalPrice可以保证展示层永远按两位小数渲染。如果这里用 number 类型JavaScript 的浮点运算很可能会在小数位出现误差比如 0.1 0.2 变成 0.30000000000000004前端展示就会很尴尬。4.2 价格精度与展示格式处理价格计算还有一个细节容易忽略单价乘以数量之后结果可能出现 199.00 * 3 597.0000000000001 这种浮点尾数。虽然toFixed(2)能把它截成 597.00但如果购物车里有几十个商品每个都带一些微小误差累加结果就可能失真。所以更严谨的做法是在单品小计那里就做一次四舍五入然后累加最后再格式化。但是也不能在中间步骤就把值变成两位小数后累加因为多次四舍五入会放大误差。正确姿势是全程按原始浮点数累加最后一次toFixed(2)。上面那版calcTotal就是这么干的totalPrice item.price * item.count累加时保持了精度只有最后setData时格式化这种方案实测下来最稳。展示方面我通常在totalPrice前面加一个¥符号单位文案就放在数字旁边。如果小程序内需要把价格拆成整数部分和小数部分分别展示可以这样处理const priceStr totalPrice.toFixed(2).split(.); const intPart priceStr[0]; const decimalPart priceStr[1];这种拆分在视觉上做大金额样式时会用到比如把整数部分放大、小数部分缩小很多电商首页和购物车结算区都有这种设计。我把这段保留在项目里方便之后扩展样式。5. 收货地址的接入与存储5.1 收货地址的获取方案购物车页面的最底部一般就是结算区结算前必须确认收货地址。微信官方提供了一个现成的接口wx.chooseAddress可以直接拉起微信内置的收货地址选择器用户选择后返回的数据里包括收货人姓名、电话、省份、城市、区县和详细地址。用起来非常方便chooseAddress() { wx.chooseAddress({ success: (res) { const address ${res.provinceName}${res.cityName}${res.countyName} ${res.detailInfo}; const fullAddress ${res.userName} ${res.telNumber} ${address}; this.setData({ address: fullAddress }); wx.setStorageSync(shippingAddress, fullAddress); }, fail: () { wx.showToast({ title: 未获取到地址, icon: none }); } }); }需要注意的是wx.chooseAddress在首次调用时可能会弹出授权对话框。如果用户拒绝了授权这个接口会直接走fail回调后续再次调用也可能无法弹出授权窗口。这种情况下比较好的兜底方案是在页面上提供一个新增收货地址的入口跳转到一个自定义的地址填写页面用户手动输入地址信息。这样即使用户拒绝了系统的微信地址授权依然可以正常完成下单流程。很多商品在小程序里已经接入了用户收货信息但wx.chooseAddress拿到的数据字段会随微信版本有一些微小差异。特别常见的坑是在开发者工具里模拟器中chooseAddress是可以直接返回假数据的但真机上如果用户没有在微信里绑定过任何收货地址接口也会进入fail分支。所以真机调试时一定要先确认微信里有没有可用的地址信息。5.2 地址数据存储与回显地址数据有一个特点它不依赖购物车列表也不需要在每次进入页面时重新选择。用户选了一次地址下次进入页面应该自动显示上一次的地址。所以我会把完整的地址字符串存进本地缓存比如wx.setStorageSync。onLoad() { const address wx.getStorageSync(shippingAddress); if (address) { this.setData({ address }); } }如果项目有用户登录体系更推荐把地址同步到服务端因为本地缓存只存在当前设备上用户换一台手机登录同一个账号地址就丢了。但在没有服务端的情况下本地缓存已经是成本最低且体验最完整的方案。地址展示区域的 WXML 设计也很重要因为它往往承担了点击跳转到选择页的功能view classaddress-section bindtapchooseAddress block wx:if{{address}} text classaddress-text{{address}}/text /block block wx:else text classaddress-placeholder请选择收货地址/text /block /view使用wx:if和wx:else的好处是有地址时展示完整文案没有地址时展示占位提示。这样可以引导用户主动去点击而不是面对一个空白的区域不知所措。另外地址的点击区域在整个视图上建议做得大一点尽量撑满一行按钮的可点击面积大了用户操作起来就不容易误触。6. 常见问题与排查经验6.1 典型问题速查表我把开发这个购物车页面时最容易踩的坑整理成了表格方便快速排查问题现象可能原因解决方式手动勾选所有商品后全选按钮仍然没亮没有在单个选中事件后调用calcTotalAndAllSelected在toggleSelect末尾补上全选状态更新删除一项后再点其他商品按钮没有反应>