小兔鲜电商项目(五):购物车模块这么写才顺手,附完整实现思路
前几篇我们陆续把登录、首页、商品列表和详情页都聊完了,这次轮到电商项目里最烧脑也最考验细节的模块之一——购物车。为什么说它烧脑?因为这个页面看着简单,就是一个商品列表加几个按钮,但真正开发过的人都有体会:涉及的数据联动太多了。商品数量要变、勾选状态要变、选中项要同步计算总价、角标要跟着加购动作实时跳动,还要考虑未登录和已登录两套完全不同的数据流。这些需求叠在一起,稍有疏忽就会出现“明明点击了加购但角标没变”“切换账号购物车数据串了”之类的诡异bug。
这篇就以“小兔鲜”项目的购物车模块为例子,把完整的设计思路和实现细节拆开讲。内容会覆盖状态管理方案选型、商品数据模型设计、加购与数量调整逻辑、结算金额与勾选联动、本地持久化和多端同步这几个核心环节,同时把实践中踩过的坑一并整理出来。无论你是刚开始接触前端项目、正在模仿练手,还是工作中需要独立设计这类业务模块,这篇都能给你一份可以照着敲的参考方案。
1. 购物车模块的整体设计与拆解
购物车看似是一个页面,其实横跨了非常多的功能点。做之前如果没想清楚它的边界,开发过程中很容易越写越乱。我习惯先把它拆成“数据层—业务层—视图层”三层来理解,再分别设计。
1.1 先想清楚购物车要解决的核心需求
购物车本质上是一个“临时暂存决策”的容器。用户在商品详情页产生了购买意向,但未必马上结算,所以需要一个地方把这些商品临时攒起来,攒的过程中还能调整数量、删减条目、查看总价。从这个角度出发,购物车模块的核心需求可以归纳成四个部分:
一是加购,也就是从商品详情页把商品丢进购物车,这里要考虑规格(SKU)是否存在、库存是否充足,以及重复加购时是新增条目还是合并数量。二是展示,购物车列表要展示商品图片、名称、规格、单价,还要支持勾选和反选。三是运算,包括单项小计、选中商品总价、全选状态的计算,以及在批量删除时对选中的条目做过滤。四是同步,购物车数据要在本地保存,账号登录后还要跟服务端数据合并。
这四个需求拆出来之后,后面的技术方案就清晰了。加购和同步涉及跨页面通信,展示和运算涉及组件内状态管理,合并在一起,最适合用全局状态来打理。
1.2 状态管理选型:为什么用 Pinia 而不是手写全局变量
小兔鲜项目是基于 Vue 3 的,所以状态管理框架直接用了 Pinia。有些同学可能会问,购物车数据我用一个模块级的单例对象或者 localStorage 监听也能实现,为什么非要引入 Pinia?
这里我聊聊实际开发的体会。购物车的数据不只是购物车页面自己要用,首页的导航栏角标、商品详情页的加购按钮、结算页的下单入口,全都依赖同一份购物车数据。如果不用全局状态管理,这些页面之间就得靠事件总线传值,或者一遍遍从 localStorage 里读,既容易漏同步,又难以追踪数据的变更来源。Pinia 的好处在于它是单向数据流,组件里只负责调用 action 触发修改,所有数据变更都收敛在 store 里,调试的时候打开 Vue DevTools 就能看清每一步改动。
另外一个原因是响应式。Pinia 的 state 是 reactive 的,购物车数组一变化,所有引用了这个 state 的组件都会自动更新。比如加入商品后导航栏角标数字立刻跳变,这一行代码都不用写。性能上有 diff 机制兜底,不会因为一个数据变化就把整棵组件树全部重新渲染。
1.3 购物车数据模型的字段设计
数据模型是整个购物车的骨架,设计得好不好,直接决定后续业务逻辑写起来顺不顺手。我在一开始就定义了一个 CartItem 的结构,每个购物车条目包含这些字段:
// store/cart.js 中的购物车条目结构 { id: '1430001', // 商品SPU ID,用于定位商品信息 skuId: '143000143981', // 规格ID,区分不同规格的同款商品 name: '秋装新款衬衫', // 商品名称 picture: 'https://...', // 商品图片地址 price: '129.00', // 单价,注意用字符串保留精度 nowPrice: '99.00', // 当前售价,参加活动时可能不同于原价 count: 1, // 购买数量 stock: 98, // 库存,用于校验数量上限 selected: true, // 是否勾选 isEffective: true, // 商品是否有效(下架/失效标记) attrsText: '藏青色 / M', // 规格文本,购物车列表里展示用的 storeId: '1001', // 店铺ID,如果支持多店铺需要预留 }有几个字段需要特别说明。price 和 nowPrice 我建议都用字符串类型来存,因为涉及金额计算的场景,浮点数会出现经典的 0.1 + 0.2 不等于 0.3 的问题。展示和做加法的时候,用整数分做单位运算,最后再转回带两位小数的字符串,这样总额计算不会漂移。
skuId 是购物车里区分条目的关键。同一个商品如果选了不同规格,它们是两个独立的购物车条目,不能合并;选了相同规格再加购,就应该在已有条目上累计数量。所以购物车里条目的唯一标识用的是 skuId 而不是商品 id,这个细节不少初学者会忽略,结果就是同款商品不同颜色在购物车里互相覆盖,用户投诉不断。
2. 加购逻辑与跨页面数据流转
数据模型定好之后,接着看购物车数据是怎么从商品详情页流进来的。这个过程看起来就是点击一个按钮、调一个接口,但背后要处理的边界情况还真不少。
2.1 从商品详情页加入购物车的完整链路
在小兔鲜项目里,商品详情页的“加入购物车”按钮绑定了这样一个动作:先校验是否选中了规格。如果用户没有选择任何规格就直接点加购,我们需要弹出提示,要求先选规格。选中规格之后,调用的核心方法就是 store 里的 addCart。
addCart 的逻辑可以分成三个分支来处理,我在代码里是这么写的:
// 加入购物车 action const addCart = (goods) => { // 判断是否已登录 const token = useUserStore().token if (token) { // 已登录:调接口加入服务端购物车,再把返回结果同步到本地 insertCartAPI(goods).then(() => { // 更新本地角标数量 updateCartCountAction() }) } else { // 未登录:直接在本地购物车数组上操作 const item = cartList.value.find(cart => cart.skuId === goods.skuId) if (item) { item.count += goods.count } else { cartList.value.push({ ...goods, selected: true }) } } }这里最大的分水岭是登录状态。登录用户的购物车数据以服务端为准,本地只是镜像;未登录用户的购物车数据就是纯本地的,存储在 localStorage 里。两条链路的处理方式完全不同,分开写才能避免互相干扰。
2.2 未登录状态下购物车数据的本地持久化
未登录状态下,购物车数据存在内存里有个隐患:用户刷新页面、关闭浏览器再回来,数据就没了。对购物车这种强状态依赖的模块来说,这几乎等于功能不可用。我的做法是把购物车列表同步到 localStorage,每次 addCart、修改数量、删除条目、切换勾选状态后,都会调用一个 persistCart 方法把最新的数组序列化写进本地存储。
// 同步购物车数据到本地存储 const persistCart = () => { localStorage.setItem('cart-list', JSON.stringify(cartList.value)) }读取的时候在 store 初始化时做一次延迟恢复,因为 localStorage 是同步 API,直接读不会有异步时序问题。恢复数据的代码大概这样:
// 初始化时从本地读取 const initCart = () => { const savedCart = localStorage.getItem('cart-list') if (savedCart) { cartList.value = JSON.parse(savedCart) } }这里有一个坑必须提醒:从 localStorage 恢复的数据是纯对象,不再具有响应式引用,但放进 reactive 数组里之后,Vue 会重新代理这些对象的属性访问,所以响应式依然成立。真正容易出问题的点在于存储时机,如果你在 action 修改完数据后忘了调用 persistCart,下一次刷新就会回到旧状态。我个人的习惯是封装一个统一的方法,内部先改 state,再立刻持久化,保证两步不会分离。
2.3 登录状态下的购物车合并策略
用户从“未登录”变成“已登录”,本地购物车里的商品不能直接丢弃,否则用户之前辛辛苦苦加购的商品就消失了。小兔鲜项目中,登录成功后需要触发一个 mergeCart 操作,把本地购物车和服务端购物车做一次合并。
合并的规则是这样:遍历本地购物车列表,对每一项调用服务端的合并接口,如果服务端已有相同 skuId 的条目,则数量相加;如果没有则新增条目录入服务端。合并完成之后,以服务端返回的列表作为当前购物车数据源,然后立刻清空本地购物车。
const mergeCart = async () => { // 登录后合并购物车 const localCart = cartList.value if (localCart.length === 0) return for (const item of localCart) { await mergeCartAPI({ skuId: item.skuId, count: item.count, selected: item.selected }) } // 合并完成后拉取服务端最新购物车列表 const res = await getCartListAPI() cartList.value = res.result // 清空本地购物车 clearLocalCart() }这个方案的缺点是串行请求较多,本地如果攒了几十个商品,合并接口就要调用几十次。在小兔鲜这种偏教学性质的练手项目里完全够用;真要做到生产级优化,应该让后端提供一个批量合并接口,一次请求穿数组过去,效率和稳定性都会好很多。
3. 购物车页面的核心实现与交互联动
购物车页面本身就是一个典型的中等复杂度的列表页,需要实现商品列表渲染、勾选切换、数量增减、删除、结算栏计算等功能。这里挑几个最容易出错的环节详细说。
3.1 购物车列表的渲染与规格展示
购物车列表的每一行都包含一个复选框、商品图、商品名称、规格文本、单价、数量调整器和单项小计。布局上我用的是 el-card 配合 flex 布局,整体结构简洁,这里不逐行写模板代码,重点说数据和视图怎么映射。
复选框的绑定值是 item.selected,修改这个值要经过 action 而不是直接赋值,以便在勾选状态变化后同步触发结算金额重新计算。商品图用的懒加载指令,本身购物车条目就不算多,直接渲染也没有性能压力,但保持统一风格总归是好的。
规格文本就是数据模型里的 attrsText 字段,在加购的时候由商品详情页传入,购物车本身不做解析。这个设计很重要,如果你在购物车页面去解析规格对象再拼字符串,代码就重复了,而且不同来源的规格格式还未必一致。
3.2 数量加减与库存边界控制
购物车里的数量调整器是一个典型的“边界控制”场景。用户点击加号,数量能不能一直加?不行,上限是库存 stock。点击减号,数量最少是 1,不能减到 0 或负数。用户手动输入数量的时候,还要处理清空输入框、输入非法字符、输入超过库存等状况。
我的实现里统一走一个 changeCount action,接收 skuId 和目标数量,内部做边界钳制:
const changeCount = (skuId, count) => { const item = cartList.value.find(cart => cart.skuId === skuId) if (!item) return // 将输入转为整数,非法值取1 const target = parseInt(count, 10) if (isNaN(target) || target < 1) { item.count = 1 } else if (target > item.stock) { item.count = item.stock } else { item.count = target } // 更新服务端或本地存储 persistCart() }这个边界处理看起来简单,但实际开发中踩坑最多的是“输入过程中被强制修正”。用户想输入 10,输了个 1,还没来得及输 0,脚本就把 1 当成最终值同步了,这时候用户体验会非常割裂。所以我额外加了处理:输入框在 change 或者 blur 时才做边界修正,input 过程中只保留数字字符,不做强制大小判断。这样一来用户能顺畅完成输入,失去焦点后再校验合理性。
3.3 全选与单选的状态联动计算
全选和单选联动是购物车页面最典型的“状态互相依赖”场景。单选发生变化,要能推出全选状态;点击全选,要能批量修改所有条目的选中状态。
理论上,全选状态可以通过计算属性直接推出:只有所有条目都选中时,全选才为 true;只要有一个未选中,全选就是 false。反过来,点击全选则是把全选状态赋值给所有条目。这两件事放在一个计算属性里处理,有循环依赖的风险,容易把代码写复杂。我的做法是分开:全选状态用 getter 计算属性读取,setter 里调用 toggleAllSelected action 修改所有条目的 selected 字段。
const isAllSelected = computed({ get: () => cartList.value.every(item => item.selected), set: (value) => { cartList.value.forEach(item => item.selected = value) persistCart() } })这个计算属性同时承担了读和写两种角色。读的时候,不管用户单选怎么切换,全选框状态都会实时随之变化;写的时候,点击全选引用的就是 setter 逻辑。之前见过不少实现把全选状态存成独立变量,每次单选变化还要手动同步全选变量,代码很容易漏同步,用计算属性则天然规避了这类问题。
3.4 结算金额的计算与实时联动
结算金额是购物车页面最核心的“数据出口”,它要显示两项:选中商品的总数量、总金额。这两项都只统计 selected 为 true 的条目,用计算属性实现再合适不过。
const selectedCartList = computed(() => cartList.value.filter(item => item.selected)) const selectedCount = computed(() => { return selectedCartList.value.reduce((sum, item) => sum + item.count, 0) }) const selectedAmount = computed(() => { const total = selectedCartList.value.reduce((sum, item) => { return sum + Math.round(parseFloat(item.nowPrice) * 100) * item.count }, 0) return (total / 100).toFixed(2) })金额计算这里有个细节值得展开说:parseFloat 得到的是浮点数,直接乘数量再做累加,累加多次之后精度会漂移。比如 3 件单价 99.99 的商品,正确结果是 299.97,但浮点运算可能得到 299.96999999999997。我先把单价乘以 100 转成“分”,做整数累加,最后除以 100 再 toFixed(2),整个过程没有任何浮点误差。
这里的 selectedCartList 也算是一个中间计算属性,其他组件如果需要“已选商品列表”,比如去结算页展示商品清单,直接复用这个计算属性即可,不用每个页面重写过滤逻辑。
4. 购物车与账号体系、库存无效商品的处理
购物车模块有一个容易被忽视但实际开发中经常遇到的场景:商品可能存在库存不足、已下架、或者商品信息被商家删除的情况。这些东西在加购时是正常的,到了结算前却可能变成“无效商品”。
4.1 登录后购物车列表的拉取与有效状态标注
用户登录成功之后,购物车列表从服务端拉取,每一件商品都要重新做有效性校验。服务端返回的购物车数据里会带一个有效状态字段,比如 isEffective,表示这个商品当前是否还能正常购买。有效状态下正常展示价格和数量;无效状态则置灰,并且不允许选中,即使它之前是选中状态也要强制取消选中。
我在拿到服务端购物车列表后做了一个后处理:遍历每个条目,如果 isEffective 为 false,就把 selected 置为 false,这样结算金额不可能被无效商品污染。这个处理虽然很小,但不做的话会出现“选中了一个下架商品还计算了总价”的离奇情况,用户一点结算就报错,排查半天才发现根源不在结算逻辑,而在购物车状态没校正。
4.2 库存变化引起的数量自动修正
库存是动态数据。用户往购物车里加了 5 件商品,过两天商家把库存改成 3 件,购物车里还显示 5 件,到结算时才发现数量超了。为了避免这个问题,拉取购物车列表时可以增加一步库存校正:当商品库存小于购物车中的 count 时,自动把 count 改为库存值,并给出提示“部分商品库存不足,已调整购买数量”。
这一步在代码里实现非常简单,就是一次比较和赋值。但它体现的是对业务细节的把控能力,懂得在数据源头把状态修正,而不是等到用户结算时才抛出一个莫名其妙的错误提示。
4.3 未登录状态下无效商品的本地清理
未登录用户的购物车存在本地,商品的失效判断无法依赖服务端实时接口,但可以在每次进入购物车页面前做一次过滤:从本地存储中读取列表,结合商品缓存数据(或者调一个批量查询接口)判断有效性,过滤掉明显无效的条目。如果商品信息完全从本地无法判断,至少要把本地条目里的 isEffective 标记保留,当数量为 0 或字段异常时直接移除。
这个逻辑我放在购物车页面路由进入时的 beforeEach 或者页面初始化函数里,保证用户每次看到购物车都是干净状态。每隔一段时间做一次数据清洗,避免无效条目像垃圾一样一直积压在本地存储里。
5. 购物车导航角标与多页面状态同步
购物车条目数据存在于 store 里,但展示入口不止购物车页面。首页导航栏、商品详情页的加购反馈、甚至用户中心的“我的购物车”入口,都可能展示购物车数量角标。怎么让角标始终准确,是购物车模块的另一个关键设计点。
5.1 角标数量的统计口径设计
角标数量通常指购物车中所有商品条目的数量之和,还是商品种类数?这两种口径差别很大。小兔鲜项目里采用前者,也就是遍历购物车列表,把每个条目的 count 累加起来,显示的是“件数”而不是“种类数”。
const cartTotalCount = computed(() => { return cartList.value.reduce((sum, item) => sum + (item.selected ? item.count : 0), 0) })这里还有一个值得一提的细节:角标统计是否要区分选中和未选中?我的实现里只统计选中商品的数量,因为去结算时带走的是选中的那一部分。如果统计全部数量,用户取消勾选了两件商品,角标数字却不减,看起来就非常奇怪。
5.2 多页面下角标数据的自动更新方案
由于 cartTotalCount 是计算属性,它会在 cartList 变化时自动求值。所以任何页面只要调用了 addCart、changeCount、deleteCart 这些 action,导航栏角标都会自动更新,不需要手动调用任何刷新方法。这正是 Pinia 响应式机制带来的好处,也是我强烈建议把购物车数据放在全局 store 里的原因。
唯一的特例是登录状态切换时。用户登录成功后,购物车数据从“本地版本”切换成“服务端合并后的版本”,cartList 被整个替换,计算属性照常更新。如果服务端购物车是空的,角标会变为 0;如果合并后有 5 件商品,角标就显示 5。整个流程是自洽的,不需要额外的布告或事件通知。
5.3 跨页面加购时的 toast 反馈与跳转引导
在商品详情页点击加购之后,通常需要给用户一个即时反馈,常见做法是弹一个轻提示,提示内容为“添加成功”,然后可以附带一个“去购物车结算”的按钮。这个交互在用户体验上非常重要,因为它完成了“加购—查看—结算”的路径闭环,用户不需要自己摸索去购物车页面的入口。
实现上我用的是对消息提示组件的二次封装,操作成功回调里调用提示方法,并将购物车页面的路由跳转绑定为按钮的点击事件。这里提醒一点:加购提示要在 action 成功返回和本地添加成功后触发,失败时不要给成功提示,避免给用户错误的预期。
6. 购物车模块的常见问题与实战避坑
购物车模块写完之后,联调和上线阶段大概率会遇到一批问题,这里把我在小兔鲜项目里真实遇到过的、以及团队同学常问的典型问题整理成速查表,方便你排查时对照。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 加购后角标数字没有变化 | 购物车 cartList 没有触发响应式更新;addCart 内直接对未代理的数组做 push | 确认 cartList 定义在 Pinia 的 state 中;使用 storeToRefs 解构后保持响应式 |
| 切换账号后购物车数据串了 | 登录时未触发 mergeCart;退出登录时未清空本地缓存 | 登录成功后调用合并接口并重置本地 cartList;退出时执行 clearLocalCart |
| 总价出现 0.30000000000000004 的精度问题 | 直接用浮点数累加金额 | 将金额转为以“分”为单位的整数累加,最后再 toFixed(2) |
| 购物车里数量超过库存还能结算 | changeCount 未做 stock 上限拦截;拉取服务端列表后未校正库存 | 在数量变更 action 内限制 count 最大值;拉取列表后对 count 超过 stock 的条目自动修正 |
| 输入数量时输入框还没输完就跳变了 | input 事件内直接修改 count 并执行计算 | 区分 input 与 change 事件,边界修正放到 blur 或 change 时执行 |
| 商品下架后购物车仍可勾选计算金额 | 未处理 isEffective 字段 | 列表数据加载时如 isEffective 为 false,强制 selected 置为 false |
| 从 localStorage 恢复后购物车页面不响应 | 恢复数据时直接赋值给普通变量,丢失了响应式 | 将恢复的数据直接赋值给 reactive 的 cartList.value,让 Vue 重新代理 |
| 合并购物车后本地数据和服务端重复 | 合并后未清空本地缓存 | mergeCart 完成后 clearLocalCart,以服务端列表为准 |
6.1 关于金额精度问题,我再多说两句
金额精度是购物车模块最容易踩的暗坑之一。我见过不少同学直接用 parseFloat 乘法累加,结果前端展示的总额跟后端算出来的总是差那么几分钱。排查到最后发现根本不是接口问题,而是 JS 浮点表示的固有缺陷。
比如 0.1 加 0.2,JS 里得到的是 0.30000000000000004。如果购物车里有十几件价格带小数的商品,累加出来的误差就会累积放大。我在小兔鲜项目里统一用“先转分、再累加、最后还原”的策略,并且把这个逻辑封装成一个公共函数,购物车和后续的结算页共用同一个计算函数,避免不同页面算法不一致又产生新的对账问题。
6.2 本地存储方案的边界与时机的把握
localStorage 持久化也不是无脑用的。写入频率过高、存储内容过大,都会造成性能问题。购物车条目本身不会特别多,每次操作都持久化压力不大。但如果未来购物车商品条目数量增长到几百上千条,每次整个数组序列化写入 localStorage 就不合适了,建议改成防抖写入或者增量字段更新。
另一个要点是清理时机。退出登录时一定要清空本地购物车缓存,否则下一个用户在这台设备上登录时会看到上一个用户的购物车残留。这一步在测试阶段不容易暴露,但到了真实使用环境就是严重的账号数据串扰事故。我在用户退出登录的 action 里强制调用了 clearLocalCart,并把 localStorage 的删除逻辑和内存数组的清空写在一起,确保不会只清了内存忘了清存储。
6.3 购物车与结算页的数据衔接设计
购物车页面点击“去结算”按钮后,要把选中的商品信息传递给结算页。跨页面的数据传递我倾向于不通过路由参数传对象,因为对象串进 URL 既臃肿又不安全。正确做法是结算页从 store 中读取 selectedCartList 计算属性,或者点击结算时把这组数据存入一个临时状态,再由结算页消费。
小兔鲜项目里我选择了前者:结算页直接读 store 里的 selectedCartList 计算属性。好处是数据始终单一来源,不会出现购物车页面传过去的数据和 store 里的不一致。唯一需要注意的是用户在结算页停留期间千万不能再操作购物车页面,否则数据会动态变化,导致“我在结算页看着商品 A,回到购物车页面商品 B 被勾选了”的错乱。如果需要锁定数据快照,可以在前往结算页前把选中列表复制一份存到 sessionStorage,结算页只依赖快照。
7. 多店铺购物车的前置设计与扩展思考
小兔鲜项目本身是单店铺模式,购物车数据结构里没有明显的店铺维度。但如果你的项目是多店铺电商,购物车列表通常要按店铺分组展示,每组店铺下面有自己的商品条目、店铺选中状态、店铺小计和店铺优惠券入口。
7.1 店铺分组对购物车数据模型的影响
多店铺模式下,原来的扁平购物车数组就不够用了,需要把数据结构改成“店铺—商品”的二级结构:
// 多店铺购物车数据结构 { storeId: '1001', storeName: '某某旗舰店', checked: true, cartItems: [ { /* 商品条目,结构同上 */ }, { /* 商品条目 */ } ] }前端渲染时按店铺分组展示,每组都有一个店铺级别的全选框,控制该店铺下所有商品的选中状态。结算金额的计算也要分两层:先算每家店铺的小计,再汇总成总金额。代码结构上需要把原先单层的 total 计算改成先 map 再 reduce 两级聚合,逻辑量大约翻倍。
7.2 店铺级优惠与满减的接入思路
有了店铺分组之后,后续接优惠券和满减活动就顺理成章了。每家店铺可以有自己的满减规则,结算栏按店铺分别计算优惠金额。购物车页面的“结算金额”展示就成了“商品总金额—店铺优惠合计—最终应付金额”的结构。这些扩展如果在最初设计数据模型时没有预留 storeId 字段,后期就要返工改数据结构,牵一发动全身。所以早期思考多店铺场景是值得的,即使当前用不到,保留一个 storeId 字段的成本也很低。
7.3 购物车模块的性能优化与渲染优化方向
购物车条目数量通常不会超过几十条,Vue 的 diff 算法处理起来毫无压力。但如果未来扩展到上千条,列表渲染的优化就可以提上日程:把每行抽成独立组件,用 defineComponent 的 props 传参代替遍历时直接修改 item;对不需要实时更新的静态文本用 v-memo 标记;超长列表用虚拟滚动。这些优化手段在当前的规模下不是必需,但提前了解对后续项目进阶会有帮助。
8. 购物车模块的核心代码组织与调试心得
最后分享一段我在小兔鲜项目中实际使用的购物车 store 的整体结构,如果你正在自己搭购物车模块,可以参考这个骨架去填充细节。
// store/cart.js import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { useUserStore } from './user' export const useCartStore = defineStore('cart', () => { // 购物车列表 const cartList = ref([]) // 是否登录 const isLogin = computed(() => useUserStore().token) // 已选购物车列表 const selectedCartList = computed(() => cartList.value.filter(item => item.selected)) // 已选商品总件数 const selectedCount = computed(() => selectedCartList.value.reduce((sum, item) => sum + item.count, 0)) // 已选商品总金额(单位:分,展示时转回元) const selectedAmount = computed(() => { const total = selectedCartList.value.reduce((sum, item) => { const priceInCents = Math.round(parseFloat(item.nowPrice) * 100) return sum + priceInCents * item.count }, 0) return (total / 100).toFixed(2) }) // 加入购物车 const addCart = (goods) => { // 已登录走服务端,未登录走本地 if (isLogin.value) { insertCartAPI(goods).then(() => { getCartList() }) } else { const item = cartList.value.find(cart => cart.skuId === goods.skuId) if (item) { item.count += goods.count } else { cartList.value.push({ ...goods, selected: true }) } persistCart() } } // 修改数量 const changeCount = (skuId, count) => { const item = cartList.value.find(cart => cart.skuId === skuId) if (!item) return let target = parseInt(count, 10) if (isNaN(target) || target < 1) target = 1 if (target > item.stock) target = item.stock item.count = target if (isLogin.value) { updateCartItemAPI({ skuId, count: item.count }) } else { persistCart() } } // 切换选中状态 const toggleSelected = (skuId) => { const item = cartList.value.find(cart => cart.skuId === skuId) if (!item) return item.selected = !item.selected if (isLogin.value) { updateCartItemAPI({ skuId, selected: item.selected }) } else { persistCart() } } // 删除单个条目 const deleteCartItem = (skuId) => { if (isLogin.value) { deleteCartItemAPI(skuId).then(() => getCartList()) } else { cartList.value = cartList.value.filter(cart => cart.skuId !== skuId) persistCart() } } // 批量删除选中条目 const deleteSelectedCart = () => { const skuIds = selectedCartList.value.map(item => item.skuId) if (skuIds.length === 0) return if (isLogin.value) { deleteCartBatchAPI(skuIds).then(() => getCartList()) } else { cartList.value = cartList.value.filter(cart => !cart.selected) persistCart() } } // 获取购物车列表(已登录) const getCartList = async () => { const res = await getCartListAPI() cartList.value = res.result.map(item => { if (!item.isEffective) item.selected = false if (item.count > item.stock) item.count = item.stock return item }) } // 合并本地购物车到服务端 const mergeCart = async () => { if (!cartList.value.length) return for (const item of cartList.value) { await mergeCartAPI({ skuId: item.skuId, count: item.count, selected: item.selected }) } await getCartList() clearLocalCart() } // 本地持久化 const persistCart = () => { localStorage.setItem('cart-list', JSON.stringify(cartList.value)) } const clearLocalCart = () => { cartList.value = [] localStorage.removeItem('cart-list') } return { cartList, selectedCartList, selectedCount, selectedAmount, addCart, changeCount, toggleSelected, deleteCartItem, deleteSelectedCart, getCartList, mergeCart, clearLocalCart } })这套代码基本覆盖了购物车模块的绝大多数业务场景,你在自己项目里可以直接拷贝骨架,然后替换成自己的接口名和字段名。有一点需要留意:action 里调 API 之后,数据回填一定要通过 getCartList 从服务端拉最新列表,而不是在本地自作主张地拼接,因为服务端可能对价格做了活动调整,以本地旧数据为准会展示错误价格。
调试时我的习惯是先用浏览器把未登录状态下的购物车流程完整走一遍,确认本地增删改查正常且刷新后数据保持;然后把用户登录态切上,验证合并逻辑和服务端列表拉取;最后再测试登录态下的增删改,确认每个操作都有对应的接口调用。三条链路独立验证完毕,再测试来回切登录和退出登录时数据是否错乱,这一整套走完,购物车模块基本就比较稳了。
购物车这个模块写完之后,我最大的感受是它的复杂度不在代码量,而在各种状态的交织。数据响应式、本地持久化、服务端同步、业务规则校验,全部集中在一个看似简单的列表页面里。把每一层想清楚再动手,远比写完再修要轻松得多。希望这篇拆解能帮你少走几步弯路,如果你在实现过程中遇到别的问题,欢迎在评论区继续交流。