简介:这份源码面向具备微信小程序开发基础的开发者,提供一套可直接运行的购物车功能实现,适用于电商平台或任何需要在线购物模块的小程序项目。包内共24个文件,以JavaScript逻辑脚本、WXSS样式表、WXML结构模板和JSON配置文件为主,另含少量图片素材与说明文档,压缩包约1.03MB,整体结构清晰、便于按模块查阅。功能覆盖商品添加、列表管理、数量调整、单个删除与总价自动计算,并支持将购物车数据持久化到本地存储,用户再次访问时可恢复原有状态;界面设计直观,交互路径简洁。源码分层合理,组件与页面职责分明,方便在此基础上扩展优惠券、规格选择或结算流程等定制需求。目前已有211人学习下载,适合希望快速搭建购物车原型或参考小程序数据绑定与本地缓存写法的开发者研读。
1. 从一份购物车源码说起:为什么它值得你花时间拆一遍
购物车是电商小程序里最不起眼、却最容易翻车的模块。你可能觉得加减数量、勾选结算能有多难,但真到多规格 SKU、库存边界、优惠券叠加、全选联动、本地持久化这些场景凑到一起,很多人的代码就开始出玄学 bug——数量改完总价不刷新、勾选状态和结算栏对不上、退出重进购物车清空。这份微信小程序购物车功能源码,就是把这些坑一次性摊开给你看的完整实现。它用原生 JavaScript 写逻辑层,不依赖第三方 UI 库,适合两类人:一是刚接触小程序、想找一个能跑通的完整购物车案例照着改的新手;二是接过电商项目、被购物车状态同步折磨过、想看看别人怎么组织数据结构的熟手。源码覆盖商品列表渲染、单选全选、数量增减、价格实时计算、本地缓存持久化这几块核心链路,拿到手就能跑,改改就能用。
2. 数据结构与状态设计:购物车不乱的前提
2.1 为什么购物车的数据结构决定了后面好不好改
很多人写购物车,第一反应是拿渲染用的数组直接改。商品列表长什么样,购物车就存什么样,勾选状态、数量、价格全塞在一个对象里。这种写法在只有三五个商品时没问题,一旦要加优惠券、要区分「选中的商品」和「全部商品」、要做本地持久化,就会发现自己到处在遍历同一个数组,改一个字段要同步三四个地方。
这份源码的做法是把「商品数据」和「购物车状态」分开。商品本身的信息(id、名称、单价、规格、库存)来自后端或静态数据,购物车只存三样东西:商品 id、购买数量、是否勾选。渲染的时候用 id 去商品表里查详情,拼出展示用的完整对象。这样做的直接好处是:改数量只动购物车状态,商品信息永远是最新的;做持久化时只需要存购物车状态这一小块,不用把整个商品列表塞进缓存。
常见做法是用一个对象做映射,key 是商品 id,value 是{ count, checked }。这样查找和更新都是 O(1),不用每次遍历数组找下标。下面是我一般会用的结构:
// 购物车状态:以商品 id 为 key 的映射表 // 好处:查找/更新 O(1),持久化时直接序列化这个对象 const cartState = { 'sku_1001': { count: 2, checked: true }, 'sku_1002': { count: 1, checked: false }, 'sku_1003': { count: 5, checked: true } }; // 商品基础信息表(实际项目里来自接口) const goodsMap = { 'sku_1001': { name: '纯棉短袖', price: 79, stock: 10, spec: '白色/M' }, 'sku_1002': { name: '休闲长裤', price: 159, stock: 3, spec: '黑色/L' }, 'sku_1003': { name: '运动袜三双装', price: 29, stock: 99, spec: '均码' } };逻辑说明:cartState只负责「用户选了什么、选了几件」,goodsMap负责「这个东西是什么、多少钱、还剩多少」。两者通过 id 关联。参数上,count是正整数,checked是布尔值,不要用 0/1 代替,后面做全选判断时布尔值更直观。
2.2 渲染列表怎么拼、怎么算价
有了上面两个结构,渲染列表就是一次映射:遍历cartState的 key,去goodsMap取详情,合并成一个展示对象。价格计算分两层:单行小计是price * count,底部合计只累加checked === true的行。这里有个细节,金额计算不要用浮点数直接乘加,0.1 + 0.2 的问题在小程序里一样存在。常见做法是把价格转成「分」做整数运算,最后再除 100 显示。
// 把元转成分,避免浮点误差 function toFen(yuan) { return Math.round(yuan * 100); } // 计算底部合计(只算勾选的) function calcTotal(cartState, goodsMap) { let totalFen = 0; let checkedCount = 0; Object.keys(cartState).forEach(id => { const item = cartState[id]; if (!item.checked) return; const goods = goodsMap[id]; totalFen += toFen(goods.price) * item.count; checkedCount += item.count; }); return { total: (totalFen / 100).toFixed(2), // 显示用,保留两位 count: checkedCount }; }逻辑说明:toFen用Math.round而不是直接乘,是因为79.9 * 100在某些环境下会得到7989.999...。calcTotal只累加勾选项,checkedCount统计的是商品件数而不是行数,结算栏显示「共 X 件」时用得上。参数上,total返回字符串是为了直接绑定到视图,避免0显示成空。
2.3 本地持久化:什么时候存、存什么
购物车不持久化,用户切个页面回来就清空,体验直接崩。小程序的wx.setStorageSync是同步接口,适合在状态变更后立即写入。但不要每次改数量都写一次,频繁写缓存有性能开销。我一般会在「数量变更」「勾选变更」「删除商品」这三个动作后统一调一次保存,用一个saveCart函数收口。
const CART_KEY = 'cart_state_v1'; // 保存购物车状态到本地 function saveCart(cartState) { try { wx.setStorageSync(CART_KEY, cartState); } catch (e) { console.error('购物车保存失败', e); } } // 读取本地购物车,做一次数据校验 function loadCart() { try { const raw = wx.getStorageSync(CART_KEY); if (!raw || typeof raw !== 'object') return {}; // 过滤掉 count 非法或结构不对的脏数据 const clean = {}; Object.keys(raw).forEach(id => { const item = raw[id]; if (item && Number.isInteger(item.count) && item.count > 0) { clean[id] = { count: item.count, checked: !!item.checked }; } }); return clean; } catch (e) { return {}; } }逻辑说明:CART_KEY带版本号v1,是为了以后数据结构升级时能区分旧数据,不至于读到老格式直接崩。loadCart里做了一次清洗,因为缓存可能被手动改过、或者旧版本写入了不兼容的结构,直接信任缓存是很多线上问题的源头。参数上,count必须是正整数,checked强制转布尔,防止undefined混进来。
3. 交互逻辑落地:单选、全选、数量增减怎么写才不打架
3.1 单选与全选的联动关系
全选和单选是购物车里最容易写出 bug 的一对。常见翻车场景是:手动把每个商品都勾上,全选框没亮;或者点了全选,再取消一个,全选框还亮着。根因是把「全选」当成一个独立状态存了,而不是从所有单选中推导出来。
正确做法是:全选状态永远由cartState里所有checked推导,不单独存。全选框的checked属性等于「所有商品都勾选」,点击全选时遍历所有商品统一赋值。
// 判断是否全选:所有商品都 checked 才为 true function isAllChecked(cartState) { const ids = Object.keys(cartState); if (ids.length === 0) return false; return ids.every(id => cartState[id].checked); } // 点击全选:根据当前全选状态取反,统一赋值 function toggleAll(cartState, nextChecked) { Object.keys(cartState).forEach(id => { cartState[id].checked = nextChecked; }); return cartState; } // 点击单个商品 function toggleItem(cartState, id) { if (cartState[id]) { cartState[id].checked = !cartState[id].checked; } return cartState; }逻辑说明:isAllChecked用every而不是维护一个计数器,避免计数器和实际状态不同步。toggleAll接收目标状态而不是自己取反,这样在视图层拿到点击后的值直接传进来,逻辑更可控。注意空购物车时isAllChecked返回false,否则空列表会显示全选高亮,很怪。
3.2 数量增减的边界处理
数量加减看着简单,坑全在边界上。库存只有 3 件,用户点到 4 怎么办;数量减到 0 是删除还是禁用;快速连点会不会超出库存。这份源码的处理是:减到 1 时禁用减号,加到库存上限时禁用加号,删除走单独的删除按钮。这样比「减到 0 自动删除」更符合用户预期,也避免误删。
// 增加数量,受库存限制 function increaseCount(cartState, goodsMap, id) { const item = cartState[id]; const goods = goodsMap[id]; if (!item || !goods) return cartState; if (item.count >= goods.stock) { wx.showToast({ title: '库存不足', icon: 'none' }); return cartState; } item.count += 1; return cartState; } // 减少数量,最低为 1 function decreaseCount(cartState, id) { const item = cartState[id]; if (!item) return cartState; if (item.count <= 1) return cartState; // 视图层同时禁用按钮 item.count -= 1; return cartState; }逻辑说明:increaseCount里先判断库存再自增,wx.showToast给用户反馈,不然点了没反应会以为卡了。decreaseCount在数量为 1 时直接返回,配合视图层的disabled属性,双重保险。参数上,stock来自商品表,实际项目里这个值应该在加入购物车时校验一次、结算前再校验一次,因为库存会变。
3.3 视图层与逻辑层的数据同步
小程序是setData驱动视图,很多人在这里踩坑:改了cartState但没调setData,界面不动;或者每次改一个字段就setData整个大对象,性能差。我的习惯是维护一个viewList数组专门给视图用,任何状态变更后重新计算一次viewList和合计,一次性setData。
// 重新计算视图数据,统一 setData function refreshView(page, cartState, goodsMap) { const list = Object.keys(cartState).map(id => { const item = cartState[id]; const goods = goodsMap[id]; return { id, name: goods.name, spec: goods.spec, price: goods.price, count: item.count, checked: item.checked, stock: goods.stock, subtotal: (toFen(goods.price) * item.count / 100).toFixed(2) }; }); const totalInfo = calcTotal(cartState, goodsMap); page.setData({ list, total: totalInfo.total, totalCount: totalInfo.count, allChecked: isAllChecked(cartState) }); saveCart(cartState); }逻辑说明:refreshView是唯一调setData的出口,所有交互函数改完cartState后都调它。这样视图和状态永远一致,不会出现「改了数据忘了刷新」。subtotal在视图层算好,模板里直接绑定,减少 WXML 里的运算。saveCart放在这里统一持久化,避免每个交互函数都写一遍。
4. 避坑与排查:购物车最容易翻车的五个地方
4.1 现象:退出重进购物车清空,缓存像没写进去
原因通常有两个:一是setStorageSync写在了异步回调里,页面已经销毁才执行;二是存的时候用了JSON.stringify,读的时候忘了JSON.parse,拿到的是一串字符串,typeof判断直接把它过滤掉了。还有一种情况是 key 带了用户 id,换账号后读不到。
解决:统一用saveCart/loadCart收口读写,读的时候先判断类型,字符串就尝试解析。key 里如果要带用户维度,登录后再初始化购物车,别在onLoad里就急着读。
4.2 现象:全选框状态和实际勾选对不上
原因就是前面说的,把全选当独立状态存了,或者用了一个allChecked变量但忘了在单选变更时更新它。还有一种隐蔽情况:every遍历时数组里有undefined元素,导致判断结果异常。
解决:全选状态永远从cartState推导,不存。每次refreshView重新算一次allChecked。遍历前确保cartState的 value 都是合法对象。
4.3 现象:总价算出来差几分钱
原因:浮点数直接乘加。19.9 * 3可能得到59.699999999999996,toFixed(2)之后变成59.70,但中间累加时误差会放大。
解决:所有金额先转成分做整数运算,最后再除 100。toFen用Math.round兜底。显示层统一toFixed(2),但计算层绝不用浮点。
4.4 现象:快速连点加号,数量超出库存
原因:setData是异步的,视图更新有延迟,用户连点时读到的count还是旧值,判断库存时用的是旧数据。
解决:逻辑层用同步的cartState做判断,不要依赖this.data里的值。increaseCount直接读传入的cartState,它是同步更新的。另外可以在按钮上加节流,但根本解法是判断依据用同步状态。
4.5 现象:删除商品后列表没更新,或者删错了行
原因:用数组下标做删除依据,删除后下标偏移,导致删错。或者删除后没重新refreshView。
解决:删除一律用商品 id,不用下标。删除后立即调refreshView。如果用了wx:key,确保 key 是唯一的 id 而不是 index,否则列表复用会出渲染错乱。
5. 进阶技巧:把购物车源码改成能扛真实项目的版本
源码能跑通只是起点,真实项目里还有几件事要做。第一件是「结算前二次校验」:用户点结算时,拿购物车里的 id 和数量去后端查一次实时库存和价格,因为从加入购物车到结算可能隔了很久,价格和库存都可能变。这一步不做,上线后会有用户下单成功但发不了货的情况。
第二件是「优惠券与购物车的关系」。优惠券通常作用于「选中的商品」,所以计算顺序是:先算出勾选商品的原价合计,再根据优惠券规则算折扣,最后得出应付。不要把优惠券逻辑塞进calcTotal,单独写一个applyCoupon(totalFen, coupon)函数,保持职责单一。
第三件是「性能」。购物车商品多的时候,每次setData整个列表会卡。常见优化是只setData变化的字段,用this.setData({ 'list[2].count': 3 })这种路径写法。但路径写法容易写错,我一般会在商品数量超过 50 条时才做这个优化,否则统一refreshView更好维护。
验证方法上,我习惯用一组边界数据跑一遍:库存为 1 的商品、价格为 0.01 的商品、数量加到上限、全部取消勾选、空购物车点全选。这几个场景过了,基本逻辑就稳了。下面这张表是我常用的自测清单:
| 测试场景 | 预期结果 | 重点检查 |
|---|---|---|
| 库存 1 的商品点加号 | 提示库存不足,数量不变 | 库存判断是否用同步状态 |
| 全部取消勾选 | 合计 0.00,全选框不亮 | 全选推导逻辑 |
| 空购物车点全选 | 无变化,不报错 | 空数组边界 |
| 价格为 0.01 的商品买 3 件 | 合计 0.03 | 浮点转整数 |
| 删除中间一行 | 其余行状态不变 | 是否用 id 删除 |
| 退出重进 | 数量和勾选保持 | 缓存读写收口 |
最后说个我自己的习惯:每次改完购物车逻辑,我都会把cartState打印出来,手动模拟一遍「加购 → 勾选 → 改数量 → 删除 → 重进」的完整链路,确认每一步的cartState和界面一致。这个笨办法帮我挡掉过好几次状态不同步的问题。从那以后我每次接购物车相关的需求,都强制走一遍这个链路再提测。希望帮到你。
本文还有配套的精品资源,点击获取