电商购物车模块设计指南:状态管理、数据联动与全流程实现解析

📅 发布时间:2026/10/21 8:55:30
电商购物车模块设计指南:状态管理、数据联动与全流程实现解析
小兔鲜电商项目五购物车模块这么写才顺手附完整实现思路前几篇我们陆续把登录、首页、商品列表和详情页都聊完了这次轮到电商项目里最烧脑也最考验细节的模块之一——购物车。为什么说它烧脑因为这个页面看着简单就是一个商品列表加几个按钮但真正开发过的人都有体会涉及的数据联动太多了。商品数量要变、勾选状态要变、选中项要同步计算总价、角标要跟着加购动作实时跳动还要考虑未登录和已登录两套完全不同的数据流。这些需求叠在一起稍有疏忽就会出现“明明点击了加购但角标没变”“切换账号购物车数据串了”之类的诡异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.2JS 里得到的是 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 从服务端拉最新列表而不是在本地自作主张地拼接因为服务端可能对价格做了活动调整以本地旧数据为准会展示错误价格。调试时我的习惯是先用浏览器把未登录状态下的购物车流程完整走一遍确认本地增删改查正常且刷新后数据保持然后把用户登录态切上验证合并逻辑和服务端列表拉取最后再测试登录态下的增删改确认每个操作都有对应的接口调用。三条链路独立验证完毕再测试来回切登录和退出登录时数据是否错乱这一整套走完购物车模块基本就比较稳了。购物车这个模块写完之后我最大的感受是它的复杂度不在代码量而在各种状态的交织。数据响应式、本地持久化、服务端同步、业务规则校验全部集中在一个看似简单的列表页面里。把每一层想清楚再动手远比写完再修要轻松得多。希望这篇拆解能帮你少走几步弯路如果你在实现过程中遇到别的问题欢迎在评论区继续交流。