ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

移动端购物车实战:三层数据结构与价格计算全解析

移动端购物车实战:三层数据结构与价格计算全解析

1. 购物车这个"小功能"到底难在哪

移动端购物车,乍一看就是个列表加勾选框,很多刚入行的朋友会觉得这东西两天就能撸完。但我带过几个类似项目之后发现,真正把它做到"能用、好用、经得起折腾",工作量远超预期。它牵扯到的东西特别杂:本地状态和远端数据的同步、勾选逻辑的联动、价格计算的精度、优惠券和满减的叠加、库存校验、失效商品的兜底、编辑态和结算态的切换,还有各种边界情况。任何一个环节没想清楚,上线之后都会被用户骂。

这篇文章我打算把"模仿移动端淘宝购物车"这个项目从头到尾拆一遍。核心关键词就是购物车、移动端、状态管理、价格计算、勾选联动、本地持久化、结算流程。我会讲清楚整体设计思路、核心模块怎么拆、关键代码怎么写、参数怎么算,以及我在实际做的时候踩过的那些坑。适合谁看?前端有一定基础、想做一个完整电商类交互模块的同学;也适合做后端、想理解前端购物车数据结构的同学。哪怕你只是想搞懂"为什么购物车的价格总是算不对",这篇也能给你答案。

先说清楚一件事:我这里讲的"模仿",指的是交互逻辑和数据结构的复刻,不涉及任何真实平台的私有接口、加密协议或者商业数据。所有实现都基于公开可见的通用交互模式,用自己搭的模拟数据来跑。这一点先摆明,后面就不重复了。

购物车本质上是一个**"临时订单容器"。用户把想买的东西丢进去,改数量、挑规格、勾选一部分,最后结算。它同时承担了三个角色:一是暂存区**,二是决策辅助工具(帮用户对比、凑单),三是价格计算器。这三个角色决定了它的数据结构必须同时满足"易修改""易展示""易计算"三个要求。很多人做不好,就是因为只把它当成一个数组来存,没考虑后面两个角色。

我见过最常见的错误做法,是把购物车数据直接塞进一个扁平数组,每个元素就是商品对象,勾选状态、数量、规格全混在一起。这样写前期很爽,等到要做"按店铺分组""跨店满减""选中商品总价"的时候,代码就开始到处打补丁,最后变成一坨。正确的思路是分层:商品层、店铺层、购物车层,每一层管自己的事。这个分层思想是整篇文章的主线,后面所有细节都围绕它展开。

2. 整体架构与数据结构设计

2.1 为什么一定要做三层结构

移动端购物车和 PC 端最大的区别在于屏幕窄、操作靠手指、信息密度要求高。用户希望一眼看到"哪些店的东西、每样多少钱、一共多少钱"。如果数据结构是扁平的,渲染的时候就得在模板里现算分组,性能差不说,逻辑还容易乱。

三层结构是这样分的:

  • 商品层(Item):单个 SKU 的信息,包括商品 ID、规格、单价、数量、库存、是否选中、是否失效。
  • 店铺层(Shop):一个店铺下的所有商品,加上店铺级的选中状态、店铺优惠信息。
  • 购物车层(Cart):所有店铺的集合,加上全局的选中状态、总价、总数量、优惠汇总。

这么分的好处是,每一层的"全选"逻辑都能独立处理。店铺的全选只影响自己店内的商品,购物车的全选影响所有店铺。计算总价的时候,从商品层往上汇总,逻辑清晰,不会串。

我用一个简化的 TypeScript 接口来描述这个结构,方便你直接抄:

interface CartItem { itemId: string; skuId: string; title: string; spec: string; price: number; // 单价,单位分 quantity: number; stock: number; selected: boolean; invalid: boolean; // 是否失效 invalidReason?: string; } interface CartShop { shopId: string; shopName: string; items: CartItem[]; selected: boolean; // 店铺级选中 promotions: Promotion[]; } interface Cart { shops: CartShop[]; selected: boolean; // 全局选中 totalPrice: number; totalCount: number; }

注意价格我用的是分做单位,不是元。这是血泪教训。浮点数在 JavaScript 里做加减会出精度问题,0.1 + 0.2 !== 0.3这个经典问题在购物车里会直接导致用户看到"合计 99.99999999 元"。所有价格在存储和计算时都用整数分,只在展示的时候除以 100 并格式化。这个原则后面还会反复提到。

2.2 状态管理方案怎么选

移动端购物车的状态管理,常见的有三种方案:组件内部 state、全局状态库(如 Redux、Pinia、Zustand)、以及本地持久化加内存缓存。

我的建议是:购物车数据必须走全局状态,并且做本地持久化。原因很简单,购物车数据会被多个页面用到——商品详情页的"加入购物车"、购物车页本身、结算页、甚至首页的购物车角标。如果只放在购物车页的组件里,其他页面拿不到,就得靠事件总线或者层层传参,维护成本极高。

具体选哪个库,看你的技术栈。Vue 用 Pinia,React 用 Zustand 或 Redux Toolkit 都行。核心是把这个 store 设计好,暴露几个关键 action:

  • addItem(item):加入商品,已存在则数量累加。
  • removeItem(itemId):删除商品。
  • updateQuantity(itemId, quantity):改数量,带库存和上下限校验。
  • toggleItem(itemId):切换单个商品选中。
  • toggleShop(shopId):切换店铺全选。
  • toggleAll():切换全局全选。
  • clearInvalid():一键清理失效商品。

本地持久化用localStorage或移动端的 storage API,把整个 cart 序列化存进去。但要注意,持久化的只是"用户意图",不是"最终数据"。比如价格、库存这些会变的信息,本地存的只是快照,进入购物车页时必须重新拉一次最新数据做校验。这个点很多人忽略,导致用户看到的价格和结算时不一致,投诉就来了。

2.3 数据同步的时机与策略

购物车的数据同步是个容易被低估的难点。用户在多个设备上操作、或者本地改了数量但没提交,这些情况都要处理。

我的策略是**"本地优先,异步同步,冲突以服务端为准"**。具体来说:

  1. 用户任何操作先改本地状态,界面立即响应,保证流畅。
  2. 同时发起一个异步请求把变更同步到服务端。
  3. 进入购物车页时,先展示本地缓存,同时拉取服务端最新数据。
  4. 服务端数据回来后,做一次合并校验:价格更新、库存不足的标记失效、已下架的标记失效。
  5. 如果本地有未同步的变更,以服务端为准,但要给用户提示。

这套策略的核心是不阻塞用户操作。移动端用户对卡顿极其敏感,任何等待超过 300ms 的操作都会让人觉得"这 App 好卡"。所以本地先改、界面先动,是最基本的体验保障。

3. 核心交互逻辑的拆解与实现

3.1 勾选联动的三种状态

勾选联动是购物车最核心也最容易写错的逻辑。它有三层:单个商品、店铺、全局。三者之间的关系是:

  • 单个商品全选 → 店铺选中。
  • 所有店铺选中 → 全局选中。
  • 全局选中 → 所有店铺和商品都选中。

这里有个细节:店铺的选中状态有三种,不是两种。全选、全不选、部分选中(半选)。半选状态在 UI 上通常显示为一个横杠或者特殊图标。很多人只做了布尔值,结果半选状态显示不出来,用户看着别扭。

实现上,店铺的selected可以是个派生值,不直接存。每次商品勾选变化后,重新计算店铺状态:

function computeShopSelected(items) { const validItems = items.filter(i => !i.invalid); if (validItems.length === 0) return false; const selectedCount = validItems.filter(i => i.selected).length; if (selectedCount === 0) return false; if (selectedCount === validItems.length) return true; return 'indeterminate'; // 半选 }

全局状态同理,遍历所有店铺计算。这里要注意失效商品不参与全选计算。如果用户有一件失效商品没勾,其他都勾了,全局应该显示全选,而不是半选。这个细节直接影响用户对"全选"的预期。

3.2 数量修改的边界处理

数量修改看着简单,其实边界特别多:

  • 最小值是 1,减到 1 就不能再减,或者弹出删除确认。
  • 最大值是库存,超过库存要提示"库存不足"。
  • 输入框要限制只能输数字,且不能有前导零。
  • 用户手动输入时,可能输入空、输入超大数、输入小数,都要处理。
  • 快速连点加减号,要做防抖,避免请求风暴。

我一般会封装一个clampQuantity函数统一处理:

function clampQuantity(input, stock) { let n = parseInt(input, 10); if (isNaN(n) || n < 1) n = 1; if (n > stock) n = stock; return n; }

输入框的失焦事件里调用这个函数,把用户输入规整成合法值。加减按钮则直接基于当前值加减,再 clamp 一次。

提示:数量修改后不要立即发请求,用 300ms 防抖。用户连点五下加号,只发最后一次请求,能省掉大量无效通信。

3.3 价格计算的精度与优惠叠加

价格计算是购物车的"心脏"。前面说了用分做单位,这里展开讲计算流程。

单个商品的小计 = 单价 × 数量。这个简单。复杂的是优惠叠加。常见的优惠有:

  • 店铺满减:满 100 减 10,满 200 减 30。
  • 店铺优惠券:满 150 减 20。
  • 平台满减:跨店满 300 减 50。
  • 单品折扣:某商品打 8 折。

这些优惠的叠加顺序和计算基数都有讲究。一般来说,单品折扣先算,然后是店铺级优惠,最后是平台级优惠。因为平台满减通常基于"店铺优惠后的金额"来算。

计算流程我整理成一张表,方便对照:

步骤计算内容基数说明
1单品小计单价 × 数量用分计算
2单品折扣单品小计如 8 折则 × 0.8
3店铺小计店内选中商品求和折扣后金额
4店铺满减店铺小计满足门槛才减
5店铺优惠券店铺小计与满减可叠加
6平台满减所有店铺优惠后求和跨店计算
7最终应付平台优惠后金额不低于 0

每一步都要用整数分计算,折扣用乘法后取整(一般向下取整,对用户有利)。取整方式要和后端对齐,否则会出现前端显示 99.99、后端算出 100.00 的尴尬。

注意:优惠计算建议前端只做"预估展示",最终以服务端返回为准。前端算错顶多显示不对,服务端算错就是资损。所以结算页一定要用服务端返回的金额覆盖前端计算值。

3.4 失效商品的兜底策略

失效商品包括:已下架、已售罄、已删除、价格变动过大等。这些商品不能参与结算,但也不能直接从购物车删掉,因为用户可能还想看看。

我的处理方式是:

  • 失效商品置底显示,和有效商品用分割线隔开。
  • 失效商品默认不勾选,且不可勾选。
  • 失效商品不参与任何价格计算和全选逻辑。
  • 提供"一键清理失效商品"按钮。
  • 失效商品上标注失效原因,比如"已售罄""已下架"。

这个设计的关键是让用户感知到失效,但不打扰用户。如果失效商品混在有效商品里,用户全选的时候会一脸懵:"为什么总价对不上?"置底加不可选,就解决了这个问题。

4. 完整实操流程与关键代码

4.1 项目初始化与目录结构

假设你用 Vue 3 + Pinia + Vite 来做,初始化命令:

npm create vite@latest cart-demo -- --template vue-ts cd cart-demo npm install pinia

目录结构我建议这样组织:

src/ stores/ cart.ts # 购物车 store utils/ price.ts # 价格计算工具 storage.ts # 本地持久化 components/ CartItem.vue # 单个商品 CartShop.vue # 店铺分组 CartFooter.vue # 底部结算栏 views/ Cart.vue # 购物车页 mock/ cartData.ts # 模拟数据

这个结构的好处是职责清晰。store 管数据,utils 管纯函数,components 管展示。价格计算这种纯逻辑抽到 utils 里,方便写单元测试,也方便复用。

4.2 购物车 store 的完整实现

store 是整个购物车的核心,我把它写完整:

import { defineStore } from 'pinia'; import { clampQuantity } from '@/utils/price'; import { saveCart, loadCart } from '@/utils/storage'; export const useCartStore = defineStore('cart', { state: () => ({ shops: loadCart() || [], }), getters: { // 所有有效商品 validItems(state) { return state.shops.flatMap(s => s.items.filter(i => !i.invalid)); }, // 选中的有效商品 selectedItems(): any[] { return this.validItems.filter((i: any) => i.selected); }, // 总数量 totalCount(): number { return this.selectedItems.reduce((sum: number, i: any) => sum + i.quantity, 0); }, // 总价(分) totalPrice(): number { return this.selectedItems.reduce( (sum: number, i: any) => sum + i.price * i.quantity, 0 ); }, // 全局选中状态 allSelected(state): boolean | string { const valid = state.shops.flatMap(s => s.items.filter(i => !i.invalid)); if (valid.length === 0) return false; const sel = valid.filter(i => i.selected).length; if (sel === 0) return false; if (sel === valid.length) return true; return 'indeterminate'; }, }, actions: { toggleItem(itemId: string) { for (const shop of this.shops) { const item = shop.items.find(i => i.itemId === itemId); if (item && !item.invalid) { item.selected = !item.selected; break; } } this.syncShopSelected(); saveCart(this.shops); }, toggleShop(shopId: string) { const shop = this.shops.find(s => s.shopId === shopId); if (!shop) return; const valid = shop.items.filter(i => !i.invalid); const allSel = valid.every(i => i.selected); valid.forEach(i => (i.selected = !allSel)); saveCart(this.shops); }, toggleAll() { const target = this.allSelected !== true; for (const shop of this.shops) { shop.items.forEach(i => { if (!i.invalid) i.selected = target; }); } saveCart(this.shops); }, updateQuantity(itemId: string, qty: number) { for (const shop of this.shops) { const item = shop.items.find(i => i.itemId === itemId); if (item) { item.quantity = clampQuantity(qty, item.stock); break; } } saveCart(this.shops); }, removeItem(itemId: string) { this.shops = this.shops .map(shop => ({ ...shop, items: shop.items.filter(i => i.itemId !== itemId), })) .filter(shop => shop.items.length > 0); saveCart(this.shops); }, clearInvalid() { this.shops = this.shops .map(shop => ({ ...shop, items: shop.items.filter(i => !i.invalid), })) .filter(shop => shop.items.length > 0); saveCart(this.shops); }, syncShopSelected() { for (const shop of this.shops) { const valid = shop.items.filter(i => !i.invalid); shop.selected = valid.length > 0 && valid.every(i => i.selected); } }, }, });

这段代码有几个关键点值得说。第一,toggleItem之后调用了syncShopSelected,保证店铺状态跟着更新。第二,removeItem之后如果店铺空了,把整个店铺也删掉,避免出现空店铺。第三,所有修改操作最后都调saveCart做持久化。第四,getter 里用flatMap把嵌套结构拍平,方便计算。

4.3 价格计算工具的实现

价格计算单独抽出来,写成纯函数:

// 单位:分 export function calcItemSubtotal(price: number, quantity: number): number { return price * quantity; } export function calcShopSubtotal(items: any[]): number { return items .filter(i => i.selected && !i.invalid) .reduce((sum, i) => sum + i.price * i.quantity, 0); } export function applyShopPromotion(subtotal: number, promotions: any[]): number { let result = subtotal; for (const p of promotions) { if (p.type === 'fullReduce' && subtotal >= p.threshold) { result -= p.reduce; } if (p.type === 'coupon' && subtotal >= p.threshold) { result -= p.amount; } } return Math.max(result, 0); } export function applyPlatformPromotion(total: number, rules: any[]): number { let result = total; for (const r of rules) { if (total >= r.threshold) { result -= r.reduce; } } return Math.max(result, 0); } export function formatPrice(cents: number): string { return (cents / 100).toFixed(2); }

注意Math.max(result, 0),防止优惠叠加后算出负数。虽然理论上不该出现,但防御性编程能避免线上事故。

4.4 底部结算栏的联动

底部结算栏要展示:全选按钮、总价、优惠信息、结算按钮。它和 store 的联动是自动的,因为用的是 getter。

<template> <div class="cart-footer"> <label class="select-all"> <input type="checkbox" :checked="cart.allSelected === true" :indeterminate="cart.allSelected === 'indeterminate'" @change="cart.toggleAll()" /> 全选 </label> <div class="total"> <span>合计:</span> <span class="price">¥{{ formatPrice(cart.totalPrice) }}</span> </div> <button class="checkout" :disabled="cart.totalCount === 0" @click="goCheckout" > 结算({{ cart.totalCount }}) </button> </div> </template>

这里:indeterminate是原生 checkbox 的属性,用来显示半选状态。注意它只能通过 JS 设置,不能通过 HTML 属性设置,所以要用:indeterminate绑定。

结算按钮的禁用逻辑是totalCount === 0,也就是没有选中任何有效商品时禁用。这个细节很重要,否则用户点进去结算页发现是空的,体验很差。

4.5 本地持久化的实现

持久化用 localStorage,但要处理序列化和版本问题:

const CART_KEY = 'cart_v1'; export function saveCart(shops: any[]) { try { localStorage.setItem(CART_KEY, JSON.stringify(shops)); } catch (e) { console.warn('购物车持久化失败', e); } } export function loadCart(): any[] | null { try { const raw = localStorage.getItem(CART_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!Array.isArray(data)) return null; return data; } catch (e) { return null; } }

key 里带版本号v1,是为了将来数据结构变了能平滑升级。如果直接叫cart,将来改结构时老数据会解析失败,用户购物车就丢了。带版本号的话,新版本读cart_v2,读不到再尝试迁移cart_v1,就安全多了。

提示:localStorage 有 5MB 左右的限制,购物车数据一般不会超。但如果你的商品对象特别大(比如存了长描述、多张图片 URL),要注意精简,只存必要字段。

5. 常见问题与排查技巧实录

5.1 价格算不对的排查思路

价格算不对是最常见的问题,我总结了一套排查顺序:

  1. 先看单位:是不是有的地方用元、有的地方用分?混用必错。
  2. 再看浮点:有没有直接用0.1 + 0.2这种运算?有就改成整数分。
  3. 看取整:折扣计算后有没有取整?取整是向上还是向下?前后端是否一致?
  4. 看筛选:计算时有没有正确过滤掉未选中和失效商品?
  5. 看叠加顺序:优惠是不是按正确顺序叠加的?

我遇到过一次,前端显示 199.99,后端返回 200.00,查了半天发现是前端折扣计算用了Math.round,后端用了Math.floor。统一成Math.floor就好了。这种问题一定要前后端对齐取整规则,最好写进接口文档。

5.2 全选状态错乱的定位方法

全选状态错乱,通常是这几个原因:

  • 失效商品参与了全选计算。
  • 店铺状态没有在商品变化后同步更新。
  • 半选状态没处理,只用了布尔值。
  • 异步数据回来后没有重新计算状态。

排查的时候,我会在toggleItem、toggleShop、toggleAll三个 action 里都打日志,打印当前所有商品的选中状态和计算出的全局状态。跑一遍操作,看哪一步状态对不上,问题就定位了。

5.3 数量修改的防抖与竞态

数量修改如果每次都发请求,快速连点会产生竞态:先发的请求后回来,把后发的覆盖了。解决办法有两个:

  • 防抖:300ms 内的连续操作只发最后一次。
  • 请求序号:每次请求带一个自增序号,回来时只接受最新序号的响应。

我一般两个都用。防抖减少请求量,序号保证顺序正确。代码大概这样:

let seq = 0; async function syncQuantity(itemId, qty) { const mySeq = ++seq; const res = await api.updateQuantity(itemId, qty); if (mySeq !== seq) return; // 过期响应,丢弃 // 处理结果 }

5.4 常见问题速查表

问题现象可能原因解决方向
总价有小数尾巴浮点运算改用整数分
全选后总价对不上失效商品参与计算过滤失效商品
半选状态不显示只用了布尔值引入 indeterminate
数量能减到 0没做下限校验clamp 到 1
快速点击数量错乱请求竞态防抖 + 序号
刷新后购物车丢失没持久化localStorage
价格与结算页不一致本地快照过期进页面重新拉取
删除后店铺还在没清理空店铺filter 空店铺

这张表我建议直接贴在工位上,遇到问题先对照一遍,能省不少时间。

5.5 几个我踩过的坑

第一个坑是库存校验的时机。我一开始只在加入购物车时校验库存,结果用户加完之后商品被抢光了,结算时才报错。后来改成进入购物车页和进入结算页都校验一次,库存不足的直接标记失效,体验好很多。

第二个坑是规格变更。同一个商品不同规格其实是不同的 SKU,价格和库存都可能不同。我一开始用商品 ID 做唯一键,结果用户改了规格,购物车里出现两条一样的记录。后来改成用skuId做唯一键,加入时如果 skuId 已存在就累加数量,问题解决。

第三个坑是优惠券的互斥。有些优惠券不能叠加,有些可以。我一开始没做互斥判断,用户选了两张互斥的券,价格算出来是负的。后来加了互斥规则表,选券时动态禁用互斥的选项,才正常。

第四个坑是大数量下的性能。购物车商品超过 50 件时,每次勾选都全量重算,界面会卡。后来把计算拆成"按店铺缓存小计",只有变化的店铺才重算,性能提升明显。

6. 性能优化与体验细节

6.1 长列表的渲染优化

购物车商品多的时候,长列表渲染是性能瓶颈。移动端尤其明显,因为设备性能参差不齐。

我的做法是虚拟列表 + 分组渲染。虚拟列表只渲染可视区域内的商品,滚动时动态替换。分组渲染则是按店铺分组,每个店铺作为一个渲染单元,减少 DOM 节点数量。

如果不想引入虚拟列表库,至少要做到列表项组件化 + key 稳定。key 用skuId而不是索引,这样 Vue 或 React 的 diff 才能正确复用节点。用索引做 key 的话,删除中间一项会导致后面所有项都重新渲染,性能差还容易出 bug。

6.2 图片懒加载与占位

购物车里的商品图不少,全量加载会拖慢首屏。用loading="lazy"或者 Intersection Observer 做懒加载,图片进入视口才加载。同时给图片容器设固定宽高比,避免加载完成后布局抖动。

占位图我一般用一个浅灰色背景加商品名首字,比空白好看,也比转圈图标省事。

6.3 交互反馈的细节

移动端交互反馈特别重要,用户手指点下去要有回应。几个细节:

  • 勾选框点击有缩放动画,150ms 左右。
  • 数量加减按钮按下有背景色变化。
  • 删除操作有确认弹窗,或者左滑删除加撤销提示。
  • 结算按钮点击后立即置灰并显示 loading,防止重复提交。

这些细节单个看都很小,但加起来就是"这个 App 好不好用"的分水岭。

6.4 无障碍与适配

购物车的勾选框要用原生input[type=checkbox]或者加role="checkbox"和aria-checked,方便读屏软件识别。按钮要有足够的点击区域,移动端建议不小于 44×44 像素。

适配方面,用rem或vw做弹性布局,字号用clamp限制范围,避免小屏字太小、大屏字太大。底部结算栏要处理安全区域,用env(safe-area-inset-bottom)给全面屏留出空间。

7. 一些延伸思考

购物车这个模块做完之后,其实可以延伸出不少东西。比如购物车推荐,根据用户加购的商品推荐相似或互补商品;比如凑单提示,当用户差一点就能满减时,提示"再买 XX 元可减 XX";再比如价格监控,用户加购的商品降价了给个提醒。

这些延伸功能的共同点是,都建立在购物车已有的数据结构之上。这也是为什么我一开始强调数据结构要设计好——结构对了,后面加功能就是水到渠成;结构错了,每加一个功能都要重构一遍。

我个人在实际操作中的体会是,购物车这种"看起来简单"的模块,最能体现一个开发者的工程素养。它不难在某个技术点,难在把几十个细节都考虑到、都处理好。写完能跑是一回事,上线不出问题是另一回事。多花时间在数据结构和边界处理上,比事后打补丁划算得多。

最后再分享一个小技巧:做购物车的时候,我会专门建一个"边界用例清单",把能想到的异常情况都列进去——空购物车、全失效、库存为 1、数量为 0、优惠叠加到负数、网络断开、本地存储满……每实现一个功能就对着清单过一遍。这个习惯帮我挡掉了大量线上问题,你也可以试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进