uni-app 购物车系统

核心知识点

购物车系统架构

购物车系统是电商应用中的核心功能之一,它允许用户临时存储想要购买的商品,并在结算前进行管理。在 uni-app 中实现购物车系统需要考虑以下几个方面:

  1. 数据结构设计

    • 购物车商品数据模型
    • 商品数量、价格、选中状态等属性
    • 购物车数据的本地存储和同步
  2. 核心功能模块

    • 商品添加与删除
    • 商品数量调整
    • 商品选中状态管理
    • 价格计算(单价、总价、优惠等)
    • 跨端数据同步
  3. 性能优化

    • 购物车数据缓存策略
    • 批量操作处理
    • 渲染性能优化

数据存储方案

在 uni-app 中,购物车数据可以通过以下方式存储:

  1. 本地存储

    • uni.setStorageSync()uni.getStorageSync() 用于持久化存储
    • 适合存储用户的购物车数据,确保应用重启后数据不丢失
  2. Vuex 状态管理

    • 用于应用运行时的状态管理
    • 提供响应式的数据更新和组件间数据共享
    • 适合管理购物车的实时状态,如选中状态、数量变化等
  3. 服务器存储

    • 对于需要跨设备同步的场景
    • 适合存储用户的购物车数据到云端

跨端适配考虑

uni-app 支持多端发布,在实现购物车系统时需要考虑以下跨端适配问题:

  1. 样式适配

    • 不同平台的样式差异
    • 响应式布局设计
  2. API 差异

    • 不同平台的存储 API 差异
    • 支付流程的平台差异
  3. 性能差异

    • 不同平台的渲染性能
    • 数据处理能力差异

实用案例:实现完整的购物车功能

1. 购物车数据模型设计

首先,我们需要设计购物车的数据模型,包括商品信息、数量、选中状态等。

// 购物车商品数据模型
const cartItemModel = {
  id: String,           // 商品ID
  name: String,         // 商品名称
  price: Number,        // 商品价格
  quantity: Number,     // 商品数量
  selected: Boolean,    // 是否选中
  image: String,        // 商品图片
  stock: Number,        // 商品库存
  skuId: String,        // SKU ID(可选)
  skuName: String,      // SKU 名称(可选)
  attrs: Array          // 商品属性(可选)
}

// 购物车数据模型
const cartModel = {
  items: Array,         // 商品列表
  totalPrice: Number,   // 总价格
  selectedCount: Number // 选中商品数量
}

2. Vuex 状态管理实现

创建 Vuex 模块来管理购物车状态:

// store/modules/cart.js
const state = {
  items: [],
  isEditMode: false
}

const mutations = {
  // 设置购物车数据
  SET_CART_ITEMS(state, items) {
    state.items = items
  },
  
  // 添加商品到购物车
  ADD_TO_CART(state, product) {
    const existingItem = state.items.find(item => item.id === product.id)
    if (existingItem) {
      // 如果商品已存在,增加数量
      existingItem.quantity += product.quantity
    } else {
      // 如果商品不存在,添加新商品
      state.items.push({
        ...product,
        selected: true
      })
    }
    // 保存到本地存储
    uni.setStorageSync('cartItems', state.items)
  },
  
  // 更新商品数量
  UPDATE_QUANTITY(state, { id, quantity }) {
    const item = state.items.find(item => item.id === id)
    if (item) {
      item.quantity = quantity
      // 保存到本地存储
      uni.setStorageSync('cartItems', state.items)
    }
  },
  
  // 切换商品选中状态
  TOGGLE_SELECT(state, id) {
    const item = state.items.find(item => item.id === id)
    if (item) {
      item.selected = !item.selected
      // 保存到本地存储
      uni.setStorageSync('cartItems', state.items)
    }
  },
  
  // 切换全选状态
  TOGGLE_SELECT_ALL(state, selected) {
    state.items.forEach(item => {
      item.selected = selected
    })
    // 保存到本地存储
    uni.setStorageSync('cartItems', state.items)
  },
  
  // 删除购物车商品
  REMOVE_FROM_CART(state, id) {
    state.items = state.items.filter(item => item.id !== id)
    // 保存到本地存储
    uni.setStorageSync('cartItems', state.items)
  },
  
  // 清空购物车
  CLEAR_CART(state) {
    state.items = []
    // 清空本地存储
    uni.removeStorageSync('cartItems')
  },
  
  // 切换编辑模式
  TOGGLE_EDIT_MODE(state) {
    state.isEditMode = !state.isEditMode
  }
}

const actions = {
  // 初始化购物车
  initCart({ commit }) {
    const cartItems = uni.getStorageSync('cartItems') || []
    commit('SET_CART_ITEMS', cartItems)
  },
  
  // 添加商品到购物车
  addToCart({ commit }, product) {
    commit('ADD_TO_CART', product)
  },
  
  // 更新商品数量
  updateQuantity({ commit }, payload) {
    commit('UPDATE_QUANTITY', payload)
  },
  
  // 切换商品选中状态
  toggleSelect({ commit }, id) {
    commit('TOGGLE_SELECT', id)
  },
  
  // 切换全选状态
  toggleSelectAll({ commit }, selected) {
    commit('TOGGLE_SELECT_ALL', selected)
  },
  
  // 删除购物车商品
  removeFromCart({ commit }, id) {
    commit('REMOVE_FROM_CART', id)
  },
  
  // 清空购物车
  clearCart({ commit }) {
    commit('CLEAR_CART')
  },
  
  // 切换编辑模式
  toggleEditMode({ commit }) {
    commit('TOGGLE_EDIT_MODE')
  }
}

const getters = {
  // 获取购物车商品数量
  cartCount: state => {
    return state.items.reduce((total, item) => total + item.quantity, 0)
  },
  
  // 获取选中商品数量
  selectedCount: state => {
    return state.items
      .filter(item => item.selected)
      .reduce((total, item) => total + item.quantity, 0)
  },
  
  // 获取选中商品总价
  selectedTotal: state => {
    return state.items
      .filter(item => item.selected)
      .reduce((total, item) => total + item.price * item.quantity, 0)
  },
  
  // 是否全选
  isAllSelected: state => {
    if (state.items.length === 0) return false
    return state.items.every(item => item.selected)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}

3. 购物车组件实现

创建购物车页面组件,实现购物车的 UI 展示和交互功能:

<template>
  <view class="cart-container">
    <view v-if="items.length === 0" class="empty-cart">
      <image src="/static/empty-cart.png" mode="aspectFit"></image>
      <text>购物车是空的</text>
      <navigator url="/pages/product/list/list" class="go-shopping">去逛逛</navigator>
    </view>
    
    <view v-else class="cart-content">
      <!-- 购物车商品列表 -->
      <view class="cart-list">
        <view v-for="item in items" :key="item.id" class="cart-item">
          <!-- 商品选中框 -->
          <view class="checkbox" @click="toggleSelect(item.id)">
            <view class="checkbox-inner" :class="{ 'checked': item.selected }">
              <text v-if="item.selected">✓</text>
            </view>
          </view>
          
          <!-- 商品图片 -->
          <view class="item-image">
            <image :src="item.image" mode="aspectFill"></image>
          </view>
          
          <!-- 商品信息 -->
          <view class="item-info">
            <text class="item-name">{{ item.name }}</text>
            <text v-if="item.skuName" class="item-sku">{{ item.skuName }}</text>
            <view class="item-bottom">
              <text class="item-price">¥{{ item.price.toFixed(2) }}</text>
              
              <!-- 数量控制 -->
              <view class="quantity-control">
                <view class="quantity-btn" @click="decreaseQuantity(item)" :class="{ 'disabled': item.quantity <= 1 }">-
                </view>
                <view class="quantity-input">{{ item.quantity }}</view>
                <view class="quantity-btn" @click="increaseQuantity(item)" :class="{ 'disabled': item.quantity >= item.stock }">+
                </view>
              </view>
            </view>
          </view>
        </view>
      </view>
      
      <!-- 底部操作栏 -->
      <view class="cart-footer">
        <!-- 全选 -->
        <view class="footer-left" @click="toggleSelectAll">
          <view class="checkbox" @click.stop="toggleSelectAll">
            <view class="checkbox-inner" :class="{ 'checked': isAllSelected }">
              <text v-if="isAllSelected">✓</text>
            </view>
          </view>
          <text>全选</text>
        </view>
        
        <!-- 价格和操作 -->
        <view class="footer-right">
          <view class="total-price">
            <text>合计:</text>
            <text class="price">¥{{ selectedTotal.toFixed(2) }}</text>
          </view>
          
          <view class="footer-buttons">
            <view v-if="!isEditMode" class="delete-btn" @click="toggleEditMode">编辑</view>
            <view v-else class="delete-btn" @click="deleteSelectedItems">删除</view>
            
            <view v-if="!isEditMode" class="checkout-btn" @click="checkout" :class="{ 'disabled': selectedCount === 0 }">
              结算({{ selectedCount }})
            </view>
            <view v-else class="checkout-btn" @click="toggleEditMode">完成</view>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import { mapState, mapGetters, mapActions } from 'vuex'

export default {
  data() {
    return {
      // 本地状态
    }
  },
  computed: {
    ...mapState('cart', ['items', 'isEditMode']),
    ...mapGetters('cart', ['cartCount', 'selectedCount', 'selectedTotal', 'isAllSelected'])
  },
  onLoad() {
    // 初始化购物车
    this.initCart()
  },
  methods: {
    ...mapActions('cart', [
      'initCart',
      'addToCart',
      'updateQuantity',
      'toggleSelect',
      'toggleSelectAll',
      'removeFromCart',
      'clearCart',
      'toggleEditMode'
    ]),
    
    // 增加商品数量
    increaseQuantity(item) {
      if (item.quantity < item.stock) {
        this.updateQuantity({
          id: item.id,
          quantity: item.quantity + 1
        })
      } else {
        uni.showToast({
          title: '已达到库存上限',
          icon: 'none'
        })
      }
    },
    
    // 减少商品数量
    decreaseQuantity(item) {
      if (item.quantity > 1) {
        this.updateQuantity({
          id: item.id,
          quantity: item.quantity - 1
        })
      }
    },
    
    // 删除选中商品
    deleteSelectedItems() {
      const selectedItems = this.items.filter(item => item.selected)
      if (selectedItems.length === 0) {
        uni.showToast({
          title: '请选择要删除的商品',
          icon: 'none'
        })
        return
      }
      
      uni.showModal({
        title: '确认删除',
        content: `确定要删除选中的${selectedItems.length}件商品吗?`,
        success: (res) => {
          if (res.confirm) {
            selectedItems.forEach(item => {
              this.removeFromCart(item.id)
            })
            uni.showToast({
              title: '删除成功',
              icon: 'success'
            })
          }
        }
      })
    },
    
    // 结算
    checkout() {
      if (this.selectedCount === 0) {
        uni.showToast({
          title: '请选择要结算的商品',
          icon: 'none'
        })
        return
      }
      
      // 获取选中的商品
      const selectedItems = this.items.filter(item => item.selected)
      
      // 跳转到结算页面
      uni.navigateTo({
        url: `/pages/order/checkout?items=${encodeURIComponent(JSON.stringify(selectedItems))}`
      })
    }
  }
}
</script>

<style scoped>
.cart-container {
  flex: 1;
  background-color: #f5f5f5;
}

.empty-cart {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 100rpx 0;
}

.empty-cart image {
  width: 200rpx;
  height: 200rpx;
  margin-bottom: 40rpx;
}

.empty-cart text {
  font-size: 32rpx;
  color: #999;
  margin-bottom: 40rpx;
}

.go-shopping {
  padding: 20rpx 60rpx;
  background-color: #007aff;
  color: #fff;
  border-radius: 40rpx;
  font-size: 28rpx;
}

.cart-list {
  padding-bottom: 120rpx;
}

.cart-item {
  display: flex;
  padding: 20rpx;
  background-color: #fff;
  margin-bottom: 10rpx;
}

.checkbox {
  margin-right: 20rpx;
  display: flex;
  align-items: center;
}

.checkbox-inner {
  width: 32rpx;
  height: 32rpx;
  border: 2rpx solid #ddd;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20rpx;
  color: #fff;
}

.checkbox-inner.checked {
  background-color: #007aff;
  border-color: #007aff;
}

.item-image {
  width: 160rpx;
  height: 160rpx;
  margin-right: 20rpx;
}

.item-image image {
  width: 100%;
  height: 100%;
  border-radius: 8rpx;
}

.item-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item-name {
  font-size: 32rpx;
  color: #333;
  margin-bottom: 10rpx;
  line-height: 44rpx;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.item-sku {
  font-size: 24rpx;
  color: #999;
  margin-bottom: 10rpx;
}

.item-bottom {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.item-price {
  font-size: 36rpx;
  color: #ff4d4f;
  font-weight: bold;
}

.quantity-control {
  display: flex;
  align-items: center;
}

.quantity-btn {
  width: 48rpx;
  height: 48rpx;
  border: 2rpx solid #ddd;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 32rpx;
}

.quantity-btn.disabled {
  color: #ccc;
}

.quantity-input {
  width: 80rpx;
  height: 48rpx;
  border-top: 2rpx solid #ddd;
  border-bottom: 2rpx solid #ddd;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28rpx;
}

.cart-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 100rpx;
  background-color: #fff;
  border-top: 1rpx solid #eee;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20rpx;
}

.footer-left {
  display: flex;
  align-items: center;
}

.footer-left text {
  margin-left: 10rpx;
  font-size: 28rpx;
  color: #333;
}

.footer-right {
  display: flex;
  align-items: center;
}

.total-price {
  font-size: 28rpx;
  margin-right: 30rpx;
}

.total-price .price {
  font-size: 36rpx;
  color: #ff4d4f;
  font-weight: bold;
}

.footer-buttons {
  display: flex;
}

.delete-btn {
  width: 120rpx;
  height: 60rpx;
  border: 2rpx solid #ddd;
  border-radius: 30rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28rpx;
  color: #666;
  margin-right: 20rpx;
}

.checkout-btn {
  width: 180rpx;
  height: 60rpx;
  background-color: #007aff;
  color: #fff;
  border-radius: 30rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28rpx;
}

.checkout-btn.disabled {
  background-color: #ccc;
}
</style>

4. 商品详情页添加购物车功能

在商品详情页中添加加入购物车的功能:

<template>
  <view class="product-detail">
    <!-- 商品信息 -->
    <view class="product-info">
      <image :src="product.image" mode="aspectFill"></image>
      <text class="product-name">{{ product.name }}</text>
      <text class="product-price">¥{{ product.price.toFixed(2) }}</text>
    </view>
    
    <!-- 商品属性选择 -->
    <view class="product-attrs">
      <!-- 属性选择逻辑 -->
    </view>
    
    <!-- 底部操作栏 -->
    <view class="bottom-bar">
      <view class="cart-icon" @click="goToCart">
        <image src="/static/cart.png" mode="aspectFit"></image>
        <view v-if="cartCount > 0" class="cart-badge">{{ cartCount }}</view>
      </view>
      <view class="buy-now" @click="buyNow">立即购买</view>
      <view class="add-to-cart" @click="addToCart">加入购物车</view>
    </view>
  </view>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'

export default {
  data() {
    return {
      product: {
        id: '1',
        name: '示例商品',
        price: 99.99,
        image: '/static/product.jpg',
        stock: 100
      },
      quantity: 1
    }
  },
  computed: {
    ...mapGetters('cart', ['cartCount'])
  },
  methods: {
    ...mapActions('cart', ['addToCart']),
    
    // 加入购物车
    addToCart() {
      const productToAdd = {
        ...this.product,
        quantity: this.quantity
      }
      
      this.addToCart(productToAdd)
      
      uni.showToast({
        title: '已加入购物车',
        icon: 'success'
      })
    },
    
    // 立即购买
    buyNow() {
      const productToBuy = {
        ...this.product,
        quantity: this.quantity,
        selected: true
      }
      
      // 跳转到结算页面
      uni.navigateTo({
        url: `/pages/order/checkout?items=${encodeURIComponent(JSON.stringify([productToBuy]))}`
      })
    },
    
    // 跳转到购物车
    goToCart() {
      uni.switchTab({
        url: '/pages/cart/cart'
      })
    }
  }
}
</script>

<style scoped>
/* 样式省略 */
</style>

5. 购物车数据同步与跨端适配

为了确保购物车数据在不同平台上的一致性,我们需要处理跨端数据同步问题:

// utils/cartSync.js

/**
 * 同步购物车数据到服务器
 * @param {Array} cartItems 购物车商品列表
 * @returns {Promise}
 */
export const syncCartToServer = async (cartItems) => {
  try {
    // 这里需要根据实际的后端 API 进行调整
    const res = await uni.request({
      url: 'https://api.example.com/cart/sync',
      method: 'POST',
      data: {
        items: cartItems
      },
      header: {
        'Authorization': 'Bearer ' + uni.getStorageSync('token')
      }
    })
    
    return res.data
  } catch (error) {
    console.error('同步购物车数据失败:', error)
    return null
  }
}

/**
 * 从服务器获取购物车数据
 * @returns {Promise<Array>}
 */
export const getCartFromServer = async () => {
  try {
    // 这里需要根据实际的后端 API 进行调整
    const res = await uni.request({
      url: 'https://api.example.com/cart/get',
      method: 'GET',
      header: {
        'Authorization': 'Bearer ' + uni.getStorageSync('token')
      }
    })
    
    return res.data.items || []
  } catch (error) {
    console.error('获取购物车数据失败:', error)
    return []
  }
}

/**
 * 跨端适配的购物车数据存储
 * @param {Array} cartItems 购物车商品列表
 */
export const saveCartData = (cartItems) => {
  try {
    // 针对不同平台进行适配
    #ifdef H5
    // H5 平台使用 localStorage
    localStorage.setItem('cartItems', JSON.stringify(cartItems))
    #else
    // 其他平台使用 uni.setStorageSync
    uni.setStorageSync('cartItems', cartItems)
    #endif
  } catch (error) {
    console.error('保存购物车数据失败:', error)
  }
}

/**
 * 跨端适配的购物车数据获取
 * @returns {Array}
 */
export const getCartData = () => {
  try {
    // 针对不同平台进行适配
    #ifdef H5
    // H5 平台使用 localStorage
    const cartItems = localStorage.getItem('cartItems')
    return cartItems ? JSON.parse(cartItems) : []
    #else
    // 其他平台使用 uni.getStorageSync
    return uni.getStorageSync('cartItems') || []
    #endif
  } catch (error) {
    console.error('获取购物车数据失败:', error)
    return []
  }
}

性能优化策略

1. 购物车数据缓存

  • 使用本地存储缓存购物车数据,减少网络请求
  • 实现数据过期机制,确保数据新鲜度
  • 考虑使用 indexedDB 存储大量购物车数据

2. 批量操作处理

  • 实现购物车商品的批量添加、删除和更新
  • 减少频繁的存储操作,使用防抖或节流

3. 渲染性能优化

  • 使用虚拟列表渲染大量购物车商品
  • 避免不必要的计算和渲染
  • 合理使用 computed 和 watch

4. 网络优化

  • 实现购物车数据的增量同步
  • 使用 WebSocket 实现实时数据更新
  • 合理设置缓存策略

总结

本教程详细介绍了在 uni-app 中实现购物车系统的方法,包括:

  1. 核心知识点:购物车系统架构、数据存储方案、跨端适配考虑
  2. 实现方法
    • Vuex 状态管理
    • 购物车组件开发
    • 商品添加与管理
    • 数量控制与价格计算
  3. 实用案例:完整的购物车功能实现
  4. 性能优化:数据缓存、批量操作、渲染性能优化

通过本教程的学习,你应该能够掌握在 uni-app 中实现购物车系统的方法,并能够根据实际项目需求进行定制和扩展。购物车系统是电商应用的核心功能之一,合理的设计和实现可以显著提升用户体验和转化率。

在实际开发中,还需要考虑更多因素,如商品库存检查、价格变动处理、优惠活动集成等,这些都需要根据具体的业务需求进行调整和优化。

« 上一篇 uni-app 订单系统 下一篇 » uni-app 物流系统