前言

本教程采用 Uniapp + Vue3 + Vite + Uview + Pinia 技术栈,7 天实现电商核心功能,同步讲解项目架构设计、接口联调技巧与多端适配方案,源码可直接复用。


第一章 项目架构设计(贯穿 Day1-Day2)

1.1 技术栈选型与环境搭建(Day1 上午)

核心技术栈

技术

作用

优势

Uni-app

跨端框架

一次编写多端运行(微信 / 支付宝小程序、H5 等)

Vue3 + Vite

前端框架与构建工具

Composition API 更灵活,Vite 编译速度快

Uview

UI 组件库

适配多端的高颜值组件

Pinia

状态管理

替代 Vuex,支持 Vue3,适合购物车等数据管理

Uni-request

网络请求

多端兼容的请求 API

环境搭建步骤

1、安装 HBuilderX(3.90+)与 Node.js(16+)

2、新建项目:HBuilderX → 文件 → 新建 → 项目 → 选择 “uni-app 项目”,模板选 “默认模板(Vue3/Vite)”

3、安装依赖:在项目根目录执行

npm install uview-plus pinia --save

4、配置 Uview:在main.js中引入

import { createSSRApp } from 'vue'

import App from './App.vue'

import uviewPlus from 'uview-plus'

export function createApp() {

const app = createSSRApp(App)

app.use(uviewPlus)

return { app }

}

1.2 项目目录架构(Day1 下午)

ecommerce-mini/

├─ pages/ # 页面目录

│ ├─ index/ # 首页(轮播+商品列表)

│ ├─ cart/ # 购物车页面

│ └─ order/ # 订单页面(结算+提交)

├─ components/ # 公共组件

│ ├─ goods-card.vue # 商品卡片组件

│ └─ address-picker.vue # 地址选择组件

├─ store/ # Pinia状态管理

│ ├─ index.js # 状态管理入口

│ └─ cart.js # 购物车状态

├─ utils/ # 工具函数

│ ├─ request.js # 请求封装

│ └─ format.js # 数据格式化

├─ static/ # 静态资源(轮播图等)

└─ manifest.json # 多端配置文件
关键目录说明
  • store/cart.js:用 Pinia 管理购物车数据,支持持久化存储
  • utils/request.js:统一封装网络请求,处理拦截与适配
  • components/:抽取复用组件,减少代码冗余

第二章 核心功能实现(Day2-Day5)

2.1 首页轮播实现(Day2)

2.1.1 基础结构(pages/index/index.vue)

利用 uni-app 原生swiper组件,支持自动轮播与指示器:

<template>

<view class="index-page">

<!-- 轮播图 -->

<swiper class="swiper"

indicator-dots="true"

indicator-color="#f8f8f8"

indicator-active-color="#007aff"

duration="3000"

autoplay="true">

<swiper-item v-for="(item, index) in bannerList" :key="index">

<image :src="item.imgUrl" class="swiper-img" mode="widthFix"></image>

</swiper-item>

</swiper>

<!-- 商品列表容器 -->

<view class="goods-list" v-if="goodsList.length">

<goods-card v-for="goods in goodsList" :key="goods.id" :goods="goods"></goods-card>

</view>

</view>

</template>

<style scoped>

.swiper { width: 100%; height: 200rpx; }

.swiper-img { width: 100%; height: 100%; }

.goods-list { padding: 20rpx; }

</style>
2.1.2 动态数据加载

在onLoad生命周期中请求轮播数据:

import { ref, onLoad } from 'vue'

import { getBannerList } from '@/api/home.js'

const bannerList = ref([])

onLoad(async () => {

// 调用接口获取轮播数据

const res = await getBannerList()

if (res.code === 200) {

bannerList.value = res.data

}

})

2.2 商品列表实现(Day3)

2.2.1 分页加载逻辑
<template>

<view class="goods-list">

<goods-card v-for="goods in goodsList" :key="goods.id" :goods="goods" @addCart="addCart"></goods-card>

<!-- 加载中提示 -->

<u-loading v-if="loading" mode="circle"></u-loading>

<!-- 无更多数据 -->

<view class="no-more" v-if="noMore">没有更多商品啦</view>

</view>

</template>

<script setup>

import { ref, onLoad, onReachBottom } from 'vue'

import { getGoodsList } from '@/api/goods.js'

import { useCartStore } from '@/store/cart.js'

const cartStore = useCartStore()

const goodsList = ref([])

const page = ref(1)

const pageSize = ref(10)

const loading = ref(false)

const noMore = ref(false)

// 加载商品列表

const loadGoods = async () => {

loading.value = true

const res = await getGoodsList({ page: page.value, pageSize: pageSize.value })

loading.value = false

if (res.code === 200) {

if (page.value === 1) {

goodsList.value = res.data.list

} else {

goodsList.value = [...goodsList.value, ...res.data.list]

}

// 判断是否有更多数据

noMore.value = res.data.list.length < pageSize.value

}

}

// 页面加载时初始化

onLoad(() => {

loadGoods()

})

// 下拉触底加载下一页

onReachBottom(() => {

if (!loading.value && !noMore.value) {

page.value++

loadGoods()

}

})

// 添加到购物车

const addCart = (goods) => {

cartStore.addItem({

id: goods.id,

name: goods.name,

price: goods.price,

imgUrl: goods.imgUrl,

num: 1

})

}

</script>
2.2.2 商品卡片组件(components/goods-card.vue)
<template>

<view class="goods-card" @click="toDetail">

<image :src="goods.imgUrl" class="goods-img"></image>

<view class="goods-info">

<view class="goods-name">{{ goods.name }}</view>

<view class="goods-price">¥{{ goods.price.toFixed(2) }}</view>

<u-button class="add-btn" size="mini" @click.stop="handleAdd">加入购物车</u-button>

</view>

</view>

</template>

<script setup>

const props = defineProps({

goods: {

type: Object,

required: true

}

})

const emit = defineEmits(['addCart'])

const handleAdd = () => {

emit('addCart', props.goods)

}

const toDetail = () => {

uni.navigateTo({ url: `/pages/goods/detail?id=${props.goods.id}` })

}

</script>

2.3 购物车功能实现(Day4)

2.3.1 Pinia 状态管理(store/cart.js)
import { defineStore } from 'pinia'

import { reactive, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {

// 购物车数据(本地缓存初始化)

const state = reactive({

cartItems: uni.getStorageSync('cartItems') || [],

allChose: false

})

// 计算已选中商品总数

const selectedCount = computed(() => {

return state.cartItems.reduce((total, item) => {

return item.isChoose ? total + item.num : total

}, 0)

})

// 计算已选中商品总价

const selectedPrice = computed(() => {

return state.cartItems.reduce((total, item) => {

return item.isChoose ? total + (item.price * item.num) : total

}, 0).toFixed(2)

})

// 添加商品到购物车

const addItem = (goods) => {

const existingItem = state.cartItems.find(item => item.id === goods.id)

if (existingItem) {

existingItem.num += goods.num

} else {

state.cartItems.push({ ...goods, isChoose: true })

}

saveToLocal()

}

// 修改商品数量

const changeNum = (id, num) => {

const item = state.cartItems.find(item => item.id === id)

if (item) item.num = num

saveToLocal()

}

// 切换商品选中状态

const toggleChoose = (id) => {

const item = state.cartItems.find(item => item.id === id)

if (item) item.isChoose = !item.isChoose

updateAllChose()

saveToLocal()

}

// 更新全选状态

const updateAllChose = () => {

state.allChose = state.cartItems.length > 0 && state.cartItems.every(item => item.isChoose)

}

// 全选/取消全选

const toggleAllChose = () => {

state.allChose = !state.allChose

state.cartItems.forEach(item => item.isChoose = state.allChose)

saveToLocal()

}

// 本地缓存购物车数据

const saveToLocal = () => {

uni.setStorageSync('cartItems', state.cartItems)

}

return {

state,

selectedCount,

selectedPrice,

addItem,

changeNum,

toggleChoose,

toggleAllChose

}

})
2.3.2 购物车页面(pages/cart/index.vue)
<template>

<view class="cart-page">

<u-empty v-if="cartStore.state.cartItems.length === 0" mode="cart"></u-empty>

<view class="cart-list" v-else>

<view class="cart-item" v-for="item in cartStore.state.cartItems" :key="item.id">

<u-checkbox :checked="item.isChoose" @change="() => cartStore.toggleChoose(item.id)"></u-checkbox>

<image :src="item.imgUrl" class="item-img"></image>

<view class="item-info">

<view class="item-name">{{ item.name }}</view>

<view class="item-price">¥{{ item.price.toFixed(2) }}</view>

</view>

<u-number-box v-model="item.num" @change="(val) => cartStore.changeNum(item.id, val)" min="1"></u-number-box>

</view>

</view>

<!-- 底部结算栏 -->

<view class="cart-footer" v-if="cartStore.state.cartItems.length > 0">

<u-checkbox :checked="cartStore.state.allChose" @change="cartStore.toggleAllChose">全选</u-checkbox>

<view class="total-info">

<view>合计:¥{{ cartStore.selectedPrice }}</view>

<view>共{{ cartStore.selectedCount }}件商品</view>

</view>

<u-button class="settle-btn" @click="toSettle">去结算</u-button>

</view>

</view>

</template>

<script setup>

import { useCartStore } from '@/store/cart.js'

const cartStore = useCartStore()

const toSettle = () => {

// 获取选中的商品

const selectedItems = cartStore.state.cartItems.filter(item => item.isChoose)

if (selectedItems.length === 0) {

uni.showToast({ title: '请选择商品', icon: 'none' })

return

}

// 跳转结算页,携带选中商品

uni.navigateTo({

url: `/pages/order/settle?selectedItems=${encodeURIComponent(JSON.stringify(selectedItems))}`

})

}

</script>

2.4 订单提交实现(Day5)

2.4.1 结算页面(pages/order/settle.vue)
<template>

<view class="settle-page">

<!-- 地址选择 -->

<view class="address-card" @click="chooseAddress">

<u-icon name="location" size="36" color="#666"></u-icon>

<view class="address-info" v-if="selectedAddress">

<view class="receiver">{{ selectedAddress.receiver }} {{ selectedAddress.phone }}</view>

<view class="address">{{ selectedAddress.fullAddress }}</view>

</view>

<view class="no-address" v-else>请选择收货地址</view>

<u-icon name="arrow-right" size="28" color="#999"></u-icon>

</view>

<!-- 选中商品列表 -->

<view class="goods-list">

<view class="goods-item" v-for="item in selectedItems" :key="item.id">

<image :src="item.imgUrl" class="item-img"></image>

<view class="item-info">

<view class="item-name">{{ item.name }}</view>

<view class="item-price">¥{{ item.price.toFixed(2) }} x {{ item.num }}</view>

</view>

</view>

</view>

<!-- 底部提交栏 -->

<view class="settle-footer">

<view class="total">总计:¥{{ totalPrice.toFixed(2) }}</view>

<u-button class="submit-btn" @click="submitOrder" :loading="submitting">提交订单</u-button>

</view>

</template>

<script setup>

import { ref, onLoad, computed } from 'vue'

import { submitOrder } from '@/api/order.js'

// 接收购物车选中的商品

const selectedItems = ref([])

const selectedAddress = ref(null)

const submitting = ref(false)

// 计算订单总价

const totalPrice = computed(() => {

return selectedItems.value.reduce((total, item) => {

return total + (item.price * item.num)

}, 0)

})

onLoad((options) => {

// 解析传入的选中商品

selectedItems.value = JSON.parse(decodeURIComponent(options.selectedItems))

// 加载默认地址

loadDefaultAddress()

})

// 加载默认地址

const loadDefaultAddress = async () => {

const res = await getDefaultAddress()

if (res.code === 200) {

selectedAddress.value = res.data

}

}

// 选择地址(跳转地址选择页)

const chooseAddress = () => {

uni.navigateTo({ url: '/pages/address/list' })

}

// 提交订单

const submitOrder = async () => {

if (!selectedAddress.value) {

uni.showToast({ title: '请选择收货地址', icon: 'none' })

return

}

submitting.value = true

try {

const res = await submitOrder({

addressId: selectedAddress.value.id,

totalAmount: totalPrice.value,

orderItems: selectedItems.value.map(item => ({

goodsId: item.id,

quantity: item.num,

price: item.price

}))

})

if (res.code === 200) {

uni.showToast({ title: '订单提交成功' })

// 跳转支付页面

uni.navigateTo({ url: `/pages/order/pay?orderNo=${res.data.orderNo}` })

}

} catch (err) {

uni.showToast({ title: '订单提交失败', icon: 'none' })

} finally {

submitting.value = false

}

}

</script>

第三章 接口联调实战(Day6)

3.1 请求封装(utils/request.js)

统一处理请求拦截、响应拦截与多端适配:

import { baseURL } from '@/config/index.js'

// 创建请求实例

const request = (options = {}) => {

// 设置默认请求头

options.header = {

'Content-Type': 'application/json',

'Authorization': `Bearer ${uni.getStorageSync('token') || ''}`,

...options.header

}

// 条件编译:H5端使用fetch优化

// #ifdef H5

if (window.fetch && !options.forceUniRequest) {

return new Promise((resolve, reject) => {

fetch(`${baseURL}${options.url}`, {

method: options.method || 'GET',

headers: options.header,

body: options.data ? JSON.stringify(options.data) : null

})

.then(response => response.json())

.then(data => {

if (data.code === 401) {

// 登录失效处理

uni.navigateTo({ url: '/pages/login/index' })

reject(data)

}

resolve(data)

})

.catch(err => reject(err))

})

}

// #endif

// 其他端使用uni.request

return new Promise((resolve, reject) => {

uni.request({

url: `${baseURL}${options.url}`,

method: options.method || 'GET',

data: options.data,

header: options.header,

timeout: 10000,

success: (res) => {

// 状态码非200拦截

if (res.statusCode !== 200) {

uni.showToast({ title: `请求失败:${res.statusCode}`, icon: 'none' })

reject(res)

return

}

// 业务码401(登录失效)处理

if (res.data.code === 401) {

uni.navigateTo({ url: '/pages/login/index' })

reject(res.data)

return

}

resolve(res.data)

},

fail: (err) => {

uni.showToast({ title: '网络请求失败', icon: 'none' })

reject(err)

}

})

})

}

// 封装GET请求

export const get = (url, params = {}, options = {}) => {

return request({ url, method: 'GET', data: params, ...options })

}

// 封装POST请求

export const post = (url, data = {}, options = {}) => {

return request({ url, method: 'POST', data, ...options })

}

export default request

3.2 业务 API 封装(api/order.js)

import { post, get } from '@/utils/request.js'

// 获取默认地址

export const getDefaultAddress = () => {

return get('/address/default')

}

// 提交订单

export const submitOrder = (data) => {

return post('/order/submit', data)

}

// 获取订单详情

export const getOrderDetail = (orderNo) => {

return get(`/order/detail?orderNo=${orderNo}`)

}

3.3 接口联调注意事项

1、跨域处理:后端配置 CORS(Access-Control-Allow-Origin: *),小程序端需配置域名白名单

2、并发控制:微信小程序限制 10 个并发请求,高频场景用防抖(如搜索)

3、错误处理:统一捕获 401(登录失效)、500(服务器错误)等状态码

4、Mock 数据:开发初期用 Mock.js 模拟接口,示例:

// 模拟轮播图接口

Mock.mock('/api/banner/list', 'get', {

code: 200,

data: [

{ id: 1, imgUrl: '/static/banner1.jpg' },

{ id: 2, imgUrl: '/static/banner2.jpg' }

]

})

第四章 多端适配方案(Day7)

4.1 样式适配

1、单位统一:全部使用 rpx(750rpx = 屏幕宽度),避免 px

2、Flex 布局:替代浮动布局,适配不同屏幕

3、条件编译样式

/* 微信小程序特有样式 */

/* #ifdef MP-WEIXIN */

.nav-bar { padding-top: 20rpx; }

/* #endif */

/* H5特有样式 */

/* #ifdef H5 */

.nav-bar { padding-top: 40rpx; }

/* #endif */

4.2 组件与 API 适配

4.2.1 组件适配
  • 使用 Uview 组件库(天然适配多端),避免原生平台特有组件
  • 自定义组件添加多端兼容判断:
<!-- 支付宝小程序使用button,其他端使用u-button -->

<!-- #ifdef MP-ALIPAY -->

<button class="alipay-btn">支付</button>

<!-- #endif -->

<!-- #ifndef MP-ALIPAY -->

<u-button class="normal-btn">支付</u-button>

<!-- #endif -->
4.2.2 API 适配
  • 网络请求:H5 端优先用 fetch,其他端用 uni.request
  • 存储 API:统一用 uni.setStorageSync/uni.getStorageSync,替代平台特有 API
  • 支付 API:条件编译处理不同平台支付:
// 发起支付

const pay = async (orderNo) => {

const res = await getPayParams(orderNo)

// 微信小程序支付

// #ifdef MP-WEIXIN

uni.requestPayment({

timeStamp: res.data.timeStamp,

nonceStr: res.data.nonceStr,

package: res.data.package,

signType: 'MD5',

paySign: res.data.paySign,

success: () => uni.showToast({ title: '支付成功' })

})

// #endif

// 支付宝小程序支付

// #ifdef MP-ALIPAY

my.tradePay({

tradeNO: res.data.tradeNo,

success: () => uni.showToast({ title: '支付成功' })

})

// #endif

}

4.3 多端测试清单

平台

测试重点

工具

微信小程序

域名白名单、支付功能、分包加载

微信开发者工具

支付宝小程序

组件兼容性、支付参数

支付宝开发者工具

H5

跨域、路由模式、响应式布局

浏览器 DevTools


第五章 源码与部署

5.1 完整源码结构

ecommerce-mini/

├─ pages/ # 页面(首页/购物车/订单)

├─ components/ # 公共组件(商品卡片/地址选择)

├─ store/ # Pinia状态管理(cart.js核心)

├─ utils/ # 工具函数(request.js/format.js)

├─ api/ # 接口封装(home/goods/order.js)

├─ static/ # 静态资源(banner1.jpg等)

├─ config/ # 配置文件(baseURL等)

├─ manifest.json # 多端配置

└─ package.json # 依赖配置

5.2 部署步骤

1、微信小程序

  • 在 HBuilderX 中点击 “发行”→“微信小程序”
  • 用微信开发者工具打开 dist/dev/mp-weixin 目录
  • 上传代码至微信公众平台审核

2、H5:发行→H5→选择部署路径→上传至服务器

5.3 注意事项

  • 小程序端需在开发者平台配置合法域名
  • 支付功能需申请对应平台的支付权限
  • 上线前执行多端兼容性测试

结语

通过 7 天的学习,你已掌握 uni-app 电商小程序的核心开发能力:从架构设计到功能实现,从接口联调到多端适配。实际开发中可基于本教程扩展更多功能(如商品搜索、个人中心),源码可根据需求调整优化。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐