尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UniApp购物车实现指南:数据模型、Vuex状态管理与跨端同步方案

UniApp购物车实现指南:数据模型、Vuex状态管理与跨端同步方案 做电商类的 UniApp 项目购物车模块几乎是绕不开的一道坎。它表面上就是个列表加加减减数量、勾一勾商品、底部算个总价可真到自己动手实现的时候才会发现难的不是列表和样式而是状态一致性、跨页面同步和各种边界情况。我最近刚好在一个跨端项目里完整重写了一遍购物车从数据模型到全局状态管理、再到结算跳转该踩的坑基本都踩了一遍。这篇就把整个思路掰开揉碎讲清楚代码尽量给全想自己实现购物车功能的朋友可以直接参考。需要提前说明的是不同商城项目的购物车规则差别很大——有的支持游客加购、有的必须登录才能加购有的还有满减、运费、优惠券这些叠加逻辑。我这里讲的是通用核心框架你拿到自己的项目里把字段和接口换掉就能用。1. 先把加购的地基打好购物车数据模型设计很多人写购物车是直接拿商品对象往数组里塞页面上需要什么就取什么这种做法在原型演示时没什么问题一旦进入联调和维护阶段就会非常难受。购物车的数据模型值得单独花时间设计因为它决定了后面所有交互逻辑的复杂度。1.1 购物车列表里每一行到底该存什么字段我先给出一份我实测下来比较好用的结构再逐个字段解释为什么这么设计。// store/cart.js 中的一条购物车数据 const cartItem { id: cart_1700000000000_123, // 购物车行唯一ID goodsId: 123, // 商品ID skuId: sku_456, // 规格ID无规格商品也给一个默认值 title: 商品名称快照, image: https://cdn.example.com/img/xxx.png, priceFen: 12900, // 单价快照单位是分 specText: 白色 / 128G, // 规格文字快照用于列表展示 count: 2, // 数量 stock: 999, // 库存上限 checked: true, // 是否勾选默认加购即勾选 invalid: false, // 是否失效 invalidReason: // 失效原因比如“商品已下架” }最容易被忽略的是id这个字段。初级做法往往用goodsId skuId作为唯一标识但真实业务里同一个商品同一规格可能会因为不同活动入口被加进来两次而且用户在购物车里重新选择规格后skuId是会变的。所以最好给每条记录一个独立自增 ID我习惯用时间戳加随机数拼一个字符串保证本地唯一即可。如果购物车是服务端存储那后端会返回专门的购物车记录 ID前端直接用那个。.为什么价格要单独存一份快照因为用户加入购物车之后商品可能调价。到底按加购时的价格结算还是按下单时的最新价格结算这是产品决策但不管怎么定前端都需要在购物车列表里显示一个价格。你把价格快照存下来既能展示加购时的价格也能在下单前拿最新价做对比出现价格变动时还能给用户弹提示。这个细节很多项目都忽略了等运营调价之后才发现购物车金额对不上。1.2 有效商品和失效商品要分开管理购物车列表不是永远静态的。商品下架、库存清零、规格删除这些都会让购物车里的某一条数据变成无效状态。我见过不少项目直接在接口返回里把失效商品过滤掉这个做法对用户很不友好——用户眼睁睁看着自己加购的东西突然消失连个原因都没有。我的做法是在购物车数据里增加invalid字段每次进入购物车页或者下拉刷新时请求最新商品状态逐条校验并更新这个字段。失效商品在 UI 上置灰显示文案标明失效原因同时单独提供一个清空失效商品的按钮用户想处理随时可以处理。这样做还有一个好处失效商品不参与全选、合计、结算计算。计算合计时用list.filter(item !item.invalid item.checked)过滤一遍勾选逻辑和金额逻辑都不会被污染。1.3 勾选状态、编辑模式属于业务状态吗这里要区分两类状态一类是购物车数据本身的业务状态比如数量、价格、失效标记另一类是页面交互状态比如当前是正常模式还是编辑模式、当前全选按钮是否处于半选状态。编辑模式这种状态如果只放在页面组件的data里问题不大但一旦购物车内容被封装成组件、或者页面被嵌到 TabBar 之后状态管理就会变得别扭。我的建议是编辑模式也放进全局 store通过commit(cart/setEditing, true)来切换。这样购物车组件内部需要根据编辑状态切换按钮文案、隐藏金额栏时直接从 store 读取逻辑干净也不容易出现响应式丢失的问题。2. 购物车为什么必须交给全局状态管理Vuex 落地细节这个话题我多说几句因为我见过有人用页面间传参的方式硬扛购物车数据同步结果改到最后自己都看不下去了。2.1 购物车是典型的跨页面共享状态一个完整的购物车流程涉及至少三个页面商品详情页执行加购、TabBar 里的购物车页负责展示和勾选、结算页读取勾选结果生成订单。如果商品详情页加了购物车之后购物车页需要刷新才能看到变化这个交互在移动端是非常糟糕的。更重要的是小程序里的 TabBar 页面之间不能通过 URL 参数自由传参页面跳转本身也有限制。你不可能每次加购都把整个购物车数组塞给购物车页。所以唯一合理的方案就是把购物车数据放到全局 store任何页面通过 action 统一修改所有页面通过计算属性实时读取。项目如果是用 HBuilderX 创建的基础模板默认就是 Vuex。如果创建项目时选了 TypeScript 模板Vue 2 版本建议用 vuex-module-decorators 这类装饰器写法Vue 3 Vite 的 uniapp 项目直接用 Pinia 会更舒服语法更简洁TS 类型推导也好。核心思想是一样的购物车必须是一个全局单例数据源。2.2 Vuex 购物车模块的完整实现下面这个模块是我常用的基础版本核心功能包括加购合并、勾选切换、数量修改、删除选中和本地缓存可以直接复制到你的项目里改一改用。// store/cart.js import Vue from vue const CART_KEY UNI_CART_CACHE function loadCache() { try { const data uni.getStorageSync(CART_KEY) return data ? JSON.parse(data) : { list: [] } } catch (e) { return { list: [] } } } export default { namespaced: true, state: { list: loadCache().list, editing: false }, getters: { validList(state) { return state.list.filter(item !item.invalid) }, invalidList(state) { return state.list.filter(item item.invalid) }, checkedList(state) { return state.list.filter(item !item.invalid item.checked) }, allChecked(state, getters) { const list getters.validList return list.length 0 list.every(item item.checked) }, checkedCount(state, getters) { return getters.checkedList.reduce((sum, item) sum item.count, 0) }, checkedAmount(state, getters) { return getters.checkedList.reduce((sum, item) sum item.priceFen * item.count, 0) } }, mutations: { setList(state, list) { state.list list uni.setStorageSync(CART_KEY, JSON.stringify({ list: state.list })) }, setEditing(state, editing) { state.editing editing }, toggleAll(state, checked) { state.list.forEach(item { if (!item.invalid) item.checked checked }) uni.setStorageSync(CART_KEY, JSON.stringify({ list: state.list })) }, toggleItem(state, id) { const item state.list.find(i i.id id) if (item) item.checked !item.checked uni.setStorageSync(CART_KEY, JSON.stringify({ list: state.list })) } }, actions: { addItem({ state, commit }, payload) { const { goodsId, skuId, title, image, priceFen, specText, stock 999 } payload // 同一商品同规格直接合并数量 const existing state.list.find( item item.goodsId goodsId item.skuId skuId !item.invalid ) if (existing) { existing.count Math.min(existing.count payload.count, Math.min(existing.stock, 99)) } else { state.list.unshift({ id: cart_${Date.now()}_${Math.floor(Math.random() * 1000)}, goodsId, skuId, title, image, priceFen, specText, count: payload.count, stock: Math.min(stock, 99), checked: true, invalid: false, invalidReason: }) } commit(setList, state.list) }, changeCount({ commit, state }, { id, count }) { const item state.list.find(i i.id id) if (!item) return if (count 1) return item.count Math.min(count, Math.min(item.stock, 99)) commit(setList, state.list) }, removeChecked({ commit, state }) { const list state.list.filter(item !item.checked || item.invalid) commit(setList, list) }, removeInvalid({ commit, state }) { const list state.list.filter(item !item.invalid) commit(setList, list) } } }有几个细节多说一句。加购合并时我只合并!item.invalid的记录这是因为失效商品不应该被新加购的数量激活正确行为是新增一条有效记录而数量上限我统一做了Math.min(stock, 99)防止用户手动输入 9999 这种离谱数字这个限制同时也减轻了后续库存校验的压力。2.3 本地缓存的读写策略购物车数据从用户角度看应该是持久化的——用户加了几件商品退出 App 再进来购物车还得在。所以每次setList我都同步写入了uni.setStorageSync读取时在 state 初始化阶段直接从缓存恢复。这里有个注意点缓存的 key 最好固定成一个常量并且不要和登录用户的数据混在一起。游客购物车和登录用户购物车是两个完全不同的数据源具体怎么衔接我放在后面单独讲。另外H5 端uni.setStorageSync底层是 localStorage有效期是永久的但 Safari 的隐私模式下写入会抛异常所以代码里读缓存时我包了一层 try/catch这点在真机上踩过坑线上反馈购物车一打开就白屏往往就是这里崩了。3. 购物车页面的硬骨头勾选联动、数量修改与金额计算页面看起来东西不多一个列表加一个底部结算栏但交互细节非常多。这节把三个最容易出 bug 的点单独拿出来讲。3.1 全选和部分选中的联动逻辑购物车通常有一个全选按钮下面每行一个复选框。全选按钮的状态不能简单地用true或false表示它有三种视觉状态全选、全不选、部分选中。但业务上只需要两个操作全部选中和全部取消。我的做法是用一个计算属性allChecked判断当前是否全部选中点击全选按钮时反向切换computed: { ...mapGetters(cart, [validList, allChecked]), ...mapState(cart, [editing]) }, methods: { handleToggleAll() { this.$store.commit(cart/toggleAll, !this.allChecked) } }toggleAll的 mutation 里我做了一件事遍历时跳过invalid的条目。失效商品不参与全选否则用户点全选时失效商品也被选上后面结算金额就乱套了。页面模板部分建议用 uniapp 自带的 checkbox 组件而不是原生 input跨端表现更一致view v-foritem in validList :keyitem.id classcart-item checkbox :checkeditem.checked color#FA2C19 clicktoggleItem(item) / image :srcitem.image modeaspectFill / view classinfo text classtitle{{ item.title }}/text text classspec{{ item.specText }}/text view classprice-row text classprice¥{{ (item.priceFen / 100).toFixed(2) }}/text uni-number-box :valueitem.count :min1 :maxMath.min(item.stock, 99) changehandleCountChange(item, $event) / /view /view /view表格线注意click里不要直接改 item 的 checked 属性虽然响应式会生效但你没走 mutation缓存没有同步。统一走toggleItemaction 或 commit保证每次变更都落缓存。3.2 数量步进器的手动输入校验uni-number-box 这个组件本身支持 min/max但用户手动输入大数字后它的change事件有时不会帮你做max钳制。我实测发现用户输入 1000 后 blur组件可能会直接触发一个 1000 的 change 事件而不是把值钳到 max。所以我在业务侧再做了一层保险handleCountChange(item, value) { const count Number(value) if (isNaN(count) || count 1) { this.$store.dispatch(cart/changeCount, { id: item.id, count: 1 }) return } const max Math.min(item.stock, 99) this.$store.dispatch(cart/changeCount, { id: item.id, count: Math.min(count, max) }) }另一个小细节是数量修改之后要不要立即同步服务端如果购物车数据是服务端存储的每次changeCount都应该调用后端接口更新数量但不要用户每点一次就发一个请求。我的做法是changeCount只更新本地组件里用一个 300ms 的防抖函数用户停止操作后再统一同步一次。这样既保证 UI 跟手又不会给后端造成压力。3.3 金额计算浮点精度是我唯一想拍桌子的地方前端算钱一定会有精度问题这不是 uniapp 特有的而是 JavaScript 浮点数的通病。最典型的就是0.1 0.2 0.30000000000000004如果你让用户加购了几个商品再算总价显示出来的可能是19.999999999999996这种见鬼的数值。我统一的方案是接口返回的价格字段如果是元那么在前端进入购物车数据之前先转成分如果是分直接存整数。所有金额相关的运算都基于整数分来完成。// 价格从“元”转“分”的正确姿势 function fenFromYuan(price) { return Math.round(parseFloat(price) * 100) } // 从“分”转“元”用于展示 function yuanFromFen(fen) { return (fen / 100).toFixed(2) }Math.round很重要。0.29 * 100在 JS 里算出来是28.999999999999996直接转整数会变成 28 分少 1 分钱。用Math.round先四舍五入再转整数就安全了。合计金额的计算在 getter 里完成展示时统一除以 100不要再做任何乘法除法这样基本可以保证金额完全正确。4. 两个容易翻车的深水区游客车合并与规格再编辑如果上一节的几个坑属于认真就能避开的级别这一节的问题是产品经理不一定提、但真实业务百分之百会遇到的高级场景。4.1 登录和退出时购物车数据到底怎么合并很多商城允许游客加购登录后要把游客本地购物车合并到账号购物车。这个流程设计不好轻则丢失数据重则重复加购。我的推荐流程是这样的游客状态下所有购物车数据仅存本地缓存。登录成功后先读取本地缓存的购物车列表。如果本地列表为空直接拉取服务端购物车列表覆盖本地。如果本地列表不为空把本地列表批量提交给服务端的购物车合并接口提交成功后清空本地缓存再拉取最新购物车数据覆盖 store。合并规则上同商品同规格的我建议数量做相加但要做一个上限钳制不能超过库存不同商品不同规格的直接新增。极端情况下用户可能同时在两台设备操作服务端一般会做去重前端不用过度设计按批量提交 重新拉取的幂等思路即可。async function mergeLocalCartToServer() { const localList uni.getStorageSync(UNI_CART_CACHE) || [] if (!localList.length) return const payload localList.list.map(item ({ goodsId: item.goodsId, skuId: item.skuId, count: item.count })) await request({ url: /cart/merge, method: POST, data: { items: payload } }) uni.removeStorageSync(UNI_CART_CACHE) // 重新拉取服务端购物车走一次 setList const remote await request({ url: /cart/list }) store.commit(cart/setList, remote.list) }退出登录时正好反过来把当前账号购物车同步到服务端后本地 store 清空但缓存不要直接删——因为下一个游客可能又往里面加东西。我的建议是退出时只是重置当前 store 中的 list不清缓存文件等下一次游客加购时再从缓存恢复。这样游客的购物车体验是连续的账号数据也不会串。4.2 用户在购物车里重新选择规格替换还是新增这个需求很多产品都会提购物车列表里点击商品规格文字弹出 SKU 选择器用户换一个规格。这时候有两种处理方式方案 A删除旧行、新增新行。逻辑最简单但有一个致命问题——原本这行商品的勾选状态、参与活动的标记、加购时间排序都会丢失。如果服务端购物车记录里还绑定了优惠信息删旧增新可能会导致重新计算价格用户会觉得我明明没动数量金额怎么变了。方案 B保留当前行的 ID更新这行的skuId、specText、priceFen、image等字段并打一个specChanged标记在结算时提交新规格。推荐用方案 B它保留了购物车记录的身份勾选状态也不会闪变。changeSpec(item, newSku) { this.$store.commit(cart/updateItem, { id: item.id, patch: { skuId: newSku.skuId, specText: newSku.specText, priceFen: newSku.priceFen, image: newSku.image, specChanged: true } }) }sku 选择器弹出层和商品详情页用的是同一套组件唯一区别是购物车场景下不需要再处理加购数量确认后直接替换当前行。这个逻辑听起来简单但很多项目会写成重新加购一条 删掉旧行就会造成勾选丢失和金额跳动个人不建议那么做。4.3 跨端差异小程序、App、H5 的行为要分开看购物车页面在三个端上表现会有差异主要集中在这几个地方小程序端的setData性能压力比 H5 大购物车列表超过 50 行后频繁修改数量会导致页面明显卡顿。如果商品数量真的很大可以用局部更新的思路不要每次setList传整个大数组而是单独提交changeCount只改某一条。App 端使用uni.setStorageSync是安全的但如果混用了异步存储接口uni.setStorage在页面快速进出的场景下可能出现缓存读取到旧值的情况。购物车这种高频读写场景我习惯全部用同步接口牺牲一点点性能换一致性。H5 端还需要注意浏览器刷新问题。store 里的数据是内存态的按 F5 就没了。所以每次变更都必须同步写 storage进入页面时再恢复。这一步在开发阶段容易被忽略等到真机测试刷新白屏才发现。5. 编辑模式与结算跳转最后一段路的细节购物车页还有一个常见的功能切换——编辑和完成。进入编辑模式后底部结算栏变成删除按钮用户可以批量删除商品。5.1 编辑/完成模式切换与批量删除编辑模式是一个布尔状态放 store 里切换入口通常在页面顶部右侧// 页面方法 toggleEditMode() { this.$store.commit(cart/setEditing, !this.editing) }, handleDelete() { this.$store.dispatch(cart/removeChecked) // 如果全部删完了自动退出编辑模式 if (!this.validList.length) { this.$store.commit(cart/setEditing, false) } }模板里正常模式和编辑模式展示的内容不一样。正常模式底部显示合计 结算按钮编辑模式底部显示删除选中。合计金额在编辑模式下可以隐藏也可以继续显示看产品需求。但无论哪种模式勾选状态的更新逻辑都走同一条链路这样用户在编辑模式下勾选商品后删除按钮上可以显示删除2体验更明确。5.2 失效商品的清空入口失效商品单独分区展示底部或者分区头部放一个清空失效商品按钮点击触发removeInvalidaction。注意这里要区分用户可能只勾选了一部分失效商品但清空失效的语义是全部删除所以不要复用删除勾选的逻辑直接过滤invalid字段。5.3 跳转结算页别用长 URL 传整个数组这是我在小程序端踩过的一个具体的坑。早期实现跳转结算页时我图省事把勾选商品数组直接塞进 URLuni.navigateTo({ url: /pages/checkout/index?items${encodeURIComponent(JSON.stringify(checkedList))} })小屏机型上如果勾选了 20 件商品URL 长度很容易超出限制结果就是页面跳过去了但参数被截断结算页拿到半个 JSON解析直接报错。现在我的标准做法是跳转前把结算数据写入 storage结算页读取后自己消费跳转 URL 只保留一个来源标记。// 购物车页跳结算 beforeCheckout() { if (!this.checkedList.length) { uni.showToast({ title: 请先选择商品, icon: none }) return } uni.setStorageSync(CHECKOUT_DATA, { items: this.checkedList.map(item ({ cartId: item.id, goodsId: item.goodsId, skuId: item.skuId, count: item.count, priceFen: item.priceFen })), from: cart }) uni.navigateTo({ url: /pages/checkout/index }) }这样做的好处是数据量大也不怕结算页刷新后还能从 storage 恢复待确认订单不会因为页面重建而丢数据。如果服务端拖着没给结算接口你这个临时方案至少能让前端联调跑通。6. 上线前后绕不开的周边问题体积超限、manifest 配置与分享入口购物车功能本身做完之后整个项目发布前还有几个高频问题会冒出来这里集中讲一下。6.1 购物车页导致的小程序主包 2MB 超限uniapp 打包微信小程序很容易撞见类似的报错source size 2612kb exceed max limit 2mb。购物车页本身不一定很大但商品图片如果被本地打包、或者引入了一个体积较大的组件库主包很容易超。解决思路优先级先做分包优化。把购物车相关的低频页面商品详情、结算、订单列表放到subpackages分包里主包只保留 TabBar 页面和公共组件。然后检查图片资源购物车列表的商品图必须用 CDN 外链不能放在本地的static目录下图标类的资源尽量用 iconfont 字体而不是图片。最后如果用了比较重的第三方组件库按需引入别整个库 import。还有一个容易被忽略的开发阶段写的大量console.log也会占用体积。发布前可以这样处理// main.js 中根据编译条件关闭日志 // #ifndef H5 const originalLog console.log console.log function() {} // #endif这个写法在微信小程序端可以明显减少编译后的 console 代码也顺便解决了上线后用户日志刷屏的问题。6.2 manifest 配置购物车结算往下走之前先检查这几项购物车后面通常是结算和支付所以 manifest.json 的配置直接影响流程是否能跑通。微信小程序端要检查mp-weixin节点下的appid是否和实际使用的一致支付需要在微信商户平台绑定 AppIDApp 端如果走plus.payment调起微信支付或支付宝需要在 manifest 里配置对应的 SDK 参数和包名签名。还有一个容易现场翻车的点H5 端访问购物车页时uni.request的接口域名必须是 HTTPS而且要在开发后台配置合法域名否则上线后请求直接 fail。购物车页所有数据都依赖接口这块没配好页面就是个空壳。6.3 如果购物车页要做分享入口别让落地页裸奔有些产品希望用户把购物车分享给好友常见场景是帮我看看买什么一起凑单。uniapp 里分享用小程序的button open-typeshare或uni.share但分享出去的页面必须处理落地参数。购物车页的onShareAppMessage里分享路径建议写成onShareAppMessage() { return { title: 帮我看看这几个商品值不值得买, path: /pages/cart/index?sourceshare } }好友点开分享卡片后购物车页的onLoad会收到options.source share这时候可以弹一个引导层让用户知道这是好友分享进来的而不是自己之前遗留的购物车。如果不做这个区分用户点进来第一眼看到的是别人的购物车内容会立刻退出分享转化率直接归零。这个细节虽然不在购物车核心流程里但在做社交裂变的项目里非常重要顺便提一下。到这里一套可用的 Uniapp 购物车核心实现就完整落地了。从我实际重构这套模块的经验来说最值得花时间的不是 UI 样式而是数据结构的稳定性和变更逻辑的一致性。你先把字段定义清楚、把全局 store 和缓存读写捋顺后面不管是加优惠券、加预售、加改价都是在稳定的地基上继续盖楼。如果按这个思路做购物车这个模块应该不会再让你加班到后半夜了。
返回列表