在电商类小程序或App中,购物车模块几乎无处不在。它的难点在于多端(微信、支付宝、H5等)表现一致性、复杂的状态联动以及频繁的增删改查操作。本文将基于Uniapp + Vue3组合式API + Pinia这一现代技术栈,从零搭建一个功能完整、代码清晰的跨端购物车,并深入讲解条件编译、持久化存储和性能优化技巧。
一、项目初始化与技术选型
使用HBuilderX创建基于Vue3的Uniapp项目。在manifest.json中勾选Vue3选项,并确保在main.js中正确挂载Pinia。
安装Pinia:在项目根目录执行 npm install pinia(或使用HBuilderX的可视化界面安装)。然后在main.js中引入:
// main.js
import App from './App'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return {
app,
pinia
}
}
此结构兼容Uniapp的SSR编译模式,保证Pinia在服务端和客户端均可使用。
二、商品数据结构设计
购物车中每个商品条目包含基础信息、选中状态、购买数量及规格。我们定义通用的类型接口(使用JSDoc或TypeScript皆可,本文采用JavaScript配合清晰的注释)。
// types/cart.js (仅示意,非必需)
/**
* @typedef {Object} CartItem
* @property {string} id - 商品ID
* @property {string} name - 商品名称
* @property {number} price - 单价(分)
* @property {string} image - 商品图片
* @property {string} spec - 当前选择规格描述
* @property {number} stock - 库存
* @property {number} quantity - 购买数量
* @property {boolean} checked - 是否选中
*/
所有金额使用分为单位,避免浮点数精度问题。前端展示时再格式化为元。
三、Pinia Store:购物车核心逻辑
创建store/cart.js,利用Pinia的defineStore和组合式API风格编写所有状态与方法。
// store/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// 状态
const items = ref([])
// 计算总价(分)
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => {
if (item.checked) {
return sum + item.price * item.quantity
}
return sum
}, 0)
})
// 选中商品总数
const checkedCount = computed(() => {
return items.value.filter(item => item.checked).length
})
// 是否全选
const isAllChecked = computed(() => {
return items.value.length > 0 && items.value.every(item => item.checked)
})
// 方法:添加商品到购物车
function addToCart(product, spec = '默认', quantity = 1) {
const existIndex = items.value.findIndex(
item => item.id === product.id && item.spec === spec
)
if (existIndex > -1) {
const item = items.value[existIndex]
const newQty = Math.min(item.quantity + quantity, item.stock)
item.quantity = newQty
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
spec,
stock: product.stock,
quantity: Math.min(quantity, product.stock),
checked: true
})
}
// 可选:持久化到本地存储(见后文)
}
// 更新数量
function updateQuantity(id, spec, quantity) {
const item = items.value.find(i => i.id === id && i.spec === spec)
if (item) {
const qty = parseInt(quantity) || 1
item.quantity = Math.min(Math.max(qty, 1), item.stock)
}
}
// 切换选中状态
function toggleChecked(id, spec) {
const item = items.value.find(i => i.id === id && i.spec === spec)
if (item) {
item.checked = !item.checked
}
}
// 全选/全不选
function toggleAllChecked() {
const newStatus = !isAllChecked.value
items.value.forEach(item => {
item.checked = newStatus
})
}
// 删除商品
function removeItem(id, spec) {
items.value = items.value.filter(i => !(i.id === id && i.spec === spec))
}
// 清空已选商品
function removeChecked() {
items.value = items.value.filter(item => !item.checked)
}
return {
items,
totalPrice,
checkedCount,
isAllChecked,
addToCart,
updateQuantity,
toggleChecked,
toggleAllChecked,
removeItem,
removeChecked
}
})
所有方法都直接操作响应式数组,Pinia自动追踪变化并通知组件更新。
四、商品列表页:加入购物车交互
模拟一个商品列表,每个商品展示名称、价格、库存,并提供规格选择和数量输入。
<!-- pages/goods/list.vue -->
<template>
<view class="goods-list">
<view v-for="product in products" :key="product.id" class="goods-item">
<image :src="product.image" mode="aspectFill"></image>
<view class="info">
<text class="name">{{ product.name }}</text>
<text class="price">¥{{ (product.price / 100).toFixed(2) }}</text>
<text class="stock">库存: {{ product.stock }}</text>
</view>
<view class="spec">
<picker :range="product.specs" @change="(e) => selectSpec(product.id, product.specs[e.detail.value])">
<text>{{ selectedSpecs[product.id] || product.specs[0] }}</text>
</picker>
</view>
<input type="number" v-model.number="quantities[product.id]" placeholder="数量" min="1" />
<button @click="addToCartHandler(product)">加入购物车</button>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useCartStore } from '@/store/cart'
const cartStore = useCartStore()
// 模拟商品数据(实际来自API)
const products = ref([
{ id: 'p1', name: '简约T恤', price: 7990, image: '/static/t-shirt.jpg', stock: 20, specs: ['S', 'M', 'L', 'XL'] },
{ id: 'p2', name: '牛仔裤', price: 15990, image: '/static/jeans.jpg', stock: 15, specs: ['28码', '30码', '32码'] },
])
// 记录每个商品当前选中的规格和数量
const selectedSpecs = reactive({})
const quantities = reactive({})
// 初始化默认值
products.value.forEach(p => {
if (!selectedSpecs[p.id]) selectedSpecs[p.id] = p.specs[0]
if (!quantities[p.id]) quantities[p.id] = 1
})
function selectSpec(productId, spec) {
selectedSpecs[productId] = spec
}
function addToCartHandler(product) {
const spec = selectedSpecs[product.id]
const qty = quantities[product.id] || 1
cartStore.addToCart(
{ id: product.id, name: product.name, price: product.price, image: product.image, stock: product.stock },
spec,
qty
)
uni.showToast({ title: '已加入购物车', icon: 'success' })
quantities[product.id] = 1 // 重置数量
}
</script>
这里使用了组合式API的reactive和ref管理本地状态。picker组件用于规格选择。
五、购物车页面:完整功能实现
购物车页面展示所有已添加商品,支持修改数量、勾选、删除和全选操作。
<!-- pages/cart/index.vue -->
<template>
<view class="cart-page">
<block v-if="cartStore.items.length > 0">
<view class="cart-list">
<view v-for="item in cartStore.items" :key="item.id + item.spec" class="cart-item">
<view class="checkbox" @click="cartStore.toggleChecked(item.id, item.spec)">
<text>{{ item.checked ? '☑' : '☐' }}</text>
</view>
<image :src="item.image" mode="aspectFill" />
<view class="info">
<text class="name">{{ item.name }}</text>
<text class="spec">{{ item.spec }}</text>
<text class="price">¥{{ (item.price / 100).toFixed(2) }}</text>
</view>
<view class="quantity-control">
<button @click="cartStore.updateQuantity(item.id, item.spec, item.quantity - 1)" :disabled="item.quantity <= 1">-</button>
<input type="number" :value="item.quantity" @input="e => cartStore.updateQuantity(item.id, item.spec, e.detail.value)" />
<button @click="cartStore.updateQuantity(item.id, item.spec, item.quantity + 1)" :disabled="item.quantity >= item.stock">+</button>
</view>
<button class="delete-btn" @click="cartStore.removeItem(item.id, item.spec)">删除</button>
</view>
</view>
<view class="footer-bar">
<view class="select-all" @click="cartStore.toggleAllChecked()">
<text>{{ cartStore.isAllChecked ? '☑' : '☐' }}</text>
<text>全选</text>
</view>
<view class="total">
合计: ¥{{ (cartStore.totalPrice / 100).toFixed(2) }}
</view>
<button class="checkout-btn" :disabled="cartStore.checkedCount === 0">
结算({{ cartStore.checkedCount }})
</button>
<button class="delete-checked" @click="cartStore.removeChecked()">删除选中</button>
</view>
</block>
<view v-else class="empty">
<text>购物车为空</text>
</view>
</view>
</template>
<script setup>
import { useCartStore } from '@/store/cart'
const cartStore = useCartStore()
</script>
页面完全由Pinia store驱动,交互简洁明了。输入修改数量时直接调用updateQuantity,库存合法性在store中控制。
六、多端条件编译与样式隔离
Uniapp允许通过#ifdef和#ifndef实现不同平台的代码块。例如,微信小程序和App可能需要不同的UI表现,或使用特定的API。
示例:在购物车页面,H5端可使用原生滚动条优化,而小程序使用scroll-view。
<template>
<!-- #ifdef H5 -->
<div class="cart-scroll-h5">
<!-- 购物车列表 -->
</div>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<scroll-view scroll-y class="cart-scroll-mp">
<!-- 购物车列表 -->
</scroll-view>
<!-- #endif -->
</template>
对于样式,可以通过/* #ifdef H5 */在<style>块中编写平台特定CSS。也可以通过JS动态判断平台:
// 在脚本中根据平台调整行为
const isH5 = uni.getSystemInfoSync().platform === 'web'
if (isH5) {
// H5专属逻辑
}
七、持久化存储:让购物车数据不丢失
用户关闭应用或页面后,购物车数据应保留。我们可以利用Pinia的插件机制或手动在store中整合uni.setStorageSync。
扩展store/cart.js,添加持久化逻辑:
// 在store/cart.js 的defineStore内部添加:
import { onMounted, watch } from 'vue' // 若使用组合式风格,需在setup内
// 在defineStore的setup函数中添加以下状态与逻辑:
const STORAGE_KEY = 'cart_items'
// 从本地存储恢复数据
function loadFromStorage() {
try {
const saved = uni.getStorageSync(STORAGE_KEY)
if (saved) {
items.value = JSON.parse(saved)
}
} catch (e) {
console.error('读取购物车缓存失败', e)
}
}
// 监听items变化并保存
watch(
items,
(newVal) => {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(newVal))
},
{ deep: true }
)
// 在页面加载时调用
loadFromStorage()
注意:使用watch需要从vue导入,并在store的setup函数中直接调用(因为Pinia的setup函数相当于组件的setup,支持生命周期钩子)。如果担心频繁存储,可以添加防抖处理,但购物车操作频率一般不高,直接存即可。
八、性能优化与异常处理
1. 库存实时校验:在结算前,应再次调用后端接口校验库存,因为库存可能已变化。可在结算按钮的点击事件中发起请求,如果库存不足则提示用户并更新购物车数量。
async function checkout() {
const checkedItems = cartStore.items.filter(i => i.checked)
const res = await uni.request({
url: '/api/check-stock',
method: 'POST',
data: { items: checkedItems.map(i => ({ id: i.id, spec: i.spec, quantity: i.quantity })) }
})
// 根据后端返回更新本地库存和数量(如果后端返回最新库存信息)
}
2. 列表长列表优化:如果购物车条目可能非常多(如超过50条),考虑使用虚拟列表或仅渲染当前可见区域。Uniapp的scroll-view配合recycle-view(或uni-ui的uni-list)可以提升渲染性能。
3. 图片懒加载:为商品图片使用lazy-load属性,尤其在列表中。
<image :src="item.image" mode="aspectFill" lazy-load />
4. 按钮防抖:数量增减按钮或删除按钮可能被快速连续点击,通过简单的节流或防抖避免多次触发store操作。
// 使用节流函数包装方法(可自行实现或引入工具库)
const throttledRemove = useThrottleFn(cartStore.removeItem, 300)
九、完整项目预览与总结
整合以上代码,我们得到了一个功能完备、多端可用的购物车模块。它充分利用了Uniapp的跨端能力、Vue3组合式API的逻辑复用优势以及Pinia的简洁状态管理。核心特点包括:
- 清晰的数据流:组件通过store方法修改状态,状态变化自动驱动UI更新。
- 规格与库存联动:加入购物车时严格校验库存,修改数量时不会超出范围。
- 多平台适配:通过条件编译处理不同端的滚动容器和交互细节。
- 本地持久化:简单实用的存储方案,保证数据不丢失。
实际开发中,你可以在此基础上扩展优惠券计算、运费逻辑、赠品机制等。更重要的是,这套架构模式可以平滑迁移到任何需要复杂状态管理的跨端业务中。
掌握Uniapp + Vue3 + Pinia的组合,你将能以极低的成本同时输出高质量的小程序、H5和App应用。购物车虽小,却是一块极佳的练兵石。

