第7章 7.5 综合实战:电商完整版(多端)
🎯 上一章我们搞定了「错误监控与日志」——就像给汽车装了黑匣子,终于能知道哪里出了问题。但光知道问题还不够,我们得真正能「造车」才行。
这一章,咱们要把前几章学的所有东西串起来,从页面搭建到数据请求,从状态管理到错误处理,做一个能跑起来的电商首页。就像学做菜,光知道刀怎么用还不够,得真炒出一盘菜来。
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这种情况:
- 照着教程敲代码,敲完发现跑不起来,报错都不知道去哪看
- 想做个「购物车」功能,搜了半天博客,越看越懵
- 好不容易调通了一个接口,但换了个数据格式就全挂了
问题在哪? 主要是缺一个「完整走一遍」的经验。
就像学游泳,教练讲再多动作要领,不如自己下水扑腾一次。这一章,我们就把电商 App 最核心的几个页面做出来:商品列表、商品详情、购物车。覆盖数据请求 → 页面渲染 → 状态管理 → 错误处理全流程。
学完你能自己动手做出一个能跑的多端电商原型,下一章的地图定位功能,也能无缝集成进去。
🧱 基础 2\n\n
\n\n
\n\n5 分钟:核心概念(生活视角)
7.5.1 uniapp 项目结构:理解一间毛坯房
盖房子之前,得先看看户型图。一个 uniapp 项目大概长这样:
├── pages/ # 页面文件夹(几室几厅)
│ ├── index/ # 首页
│ ├── detail/ # 商品详情页
│ └── cart/ # 购物车
├── components/ # 组件文件夹(可复用的家具)
├── static/ # 静态资源(装修材料)
├── App.vue # 应用入口
├── main.js # 启动文件
└── manifest.json # 配置文件(物业协议)
说白了:uniapp 的项目结构就是一个「装修好的小区」——pages 是每户的房间,components 是可以搬来搬去的家具,static 是你囤的建材。
7.5.2 数据请求:理解「外卖点餐」流程
在 uniapp 里从后端拿数据,用的是 uni.request()。这像什么?
就像点外卖:
- 你告诉商家(后端):我要一份宫保鸡丁(发起请求)
- 商家做好了装盒(后端处理)
- 骑手送过来(网络传输)
- 你打开盒子吃饭(前端渲染数据)
代码长这样:
// 向后端请求商品列表
uni.request({
url: 'https://api.example.com/goods/list', // 商家地址
method: 'GET', // 点餐方式
data: { page: 1, size: 10 }, // 口味要求
success: (res) => {
// 饭到了,开始吃
console.log('收到数据:', res.data)
},
fail: (err) => {
// 外卖丢了,打电话投诉
console.error('请求失败:', err)
}
})
7.5.3 响应式数据:理解「双向绑定」
uniapp 的页面数据管理,用的是「响应式」——就像你和室友合租的冰箱:
- 你往冰箱放了一瓶可乐(修改数据)
- 室友立刻知道有可乐了(页面自动更新)
在 Vue 语法里这么做:
export default {
data() {
return {
goodsList: [], // 商品列表(冰箱里的东西)
loading: false, // 加载状态(厨房忙不忙)
error: null // 错误信息(有没有烧糊)
}
},
methods: {
async fetchGoods() {
this.loading = true // 厨房开始忙了
try {
const res = await this.$api.getGoods()
this.goodsList = res.data // 冰箱里上新货了
} catch (e) {
this.error = e.message // 烧糊了,记一笔
} finally {
this.loading = false // 厨房收工了
}
}
}
}
注意! this.goodsList = res.data 这一步改了数据,页面上的商品列表会自动变——你不用手动刷新 DOM,这就是响应式的威力。
7.5.4 状态管理:理解「全局共享」
想象一个场景:用户登录了,他的「用户名」需要在多个页面显示(首页、购物车、我的)。
如果每个页面都单独请求一遍,累不累?这时需要一个「公共白板」——谁想写谁写,谁想读谁读。
uniapp 里有两种方式:
方式 1:Vuex(大型超市的仓库)
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
userInfo: null, // 买家信息
cartList: [] // 购物车
},
mutations: {
SET_USER(state, user) {
state.userInfo = user
},
ADD_TO_CART(state, goods) {
state.cartList.push(goods)
}
},
actions: {
login({ commit }, credentials) {
// 登录逻辑...
commit('SET_USER', { name: '小明', id: 1 })
}
}
})
方式 2:Provide/Inject(家庭内部共享)
// 祖先组件
export default {
provide() {
return { userInfo: this.userInfo }
}
}
// 后代组件
export default {
inject: ['userInfo'],
created() {
console.log('有人登录了:', this.userInfo)
}
}
7.5.5 条件渲染与列表渲染:理解「动态幕布」
页面不是死的,数据变了,显示的东西也要变。
// 条件渲染:有没有数据,显示不一样的东西
<view v-if="goodsList.length > 0">
<text>共有 {{ goodsList.length }} 件商品</text>
</view>
<view v-else>
<text>购物车是空的</text>
</view>
// 列表渲染:数组里的每个元素,都能生成一段 HTML
<view v-for="(item, index) in goodsList" :key="item.id">
<text>{{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}</text>
</view>
类比:条件渲染就像「雾霾天显示口罩广告,晴天不显示」;列表渲染就像「点名册上 N 个人,就念 N 遍名字」。
🔥 实战 35 分钟:三个递进项目
📦 项目 1:5 分钟「商品列表页」(跟着抄就能跑)
目标:从模拟接口获取商品列表,展示在页面上。
// pages/index/index.vue
<template>
<view class="container">
<!-- 加载中状态 -->
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error">
<text>出错了:{{ error }}</text>
<button @click="fetchGoods">点我重试</button>
</view>
<!-- 商品列表 -->
<view v-else>
<view v-for="goods in goodsList" :key="goods.id" class="goods-card">
<image :src="goods.image" mode="aspectFill" class="goods-image"></image>
<view class="goods-info">
<text class="goods-name">{{ goods.name }}</text>
<text class="goods-price">¥{{ goods.price }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
goodsList: [],
loading: false,
error: null
}
},
onLoad() {
this.fetchGoods()
},
methods: {
async fetchGoods() {
this.loading = true
this.error = null
try {
// 模拟请求(实际项目替换成真实接口)
const mockData = [
{ id: 1, name: 'iPhone 15', price: 5999, image: '/static/phone.jpg' },
{ id: 2, name: 'MacBook Pro', price: 9999, image: '/static/laptop.jpg' },
{ id: 3, name: 'AirPods Pro', price: 1899, image: '/static/earphone.jpg' }
]
// 模拟 500ms 延迟
await new Promise(resolve => setTimeout(resolve, 500))
this.goodsList = mockData
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
}
}
}
</script>
<style>
.container { padding: 20rpx; }
.goods-card {
display: flex;
padding: 20rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
.goods-image { width: 200rpx; height: 200rpx; }
.goods-info { margin-left: 20rpx; display: flex; flex-direction: column; }
.goods-name { font-size: 32rpx; font-weight: bold; }
.goods-price { color: #ff6b6b; font-size: 28rpx; margin-top: 20rpx; }
.loading, .error { text-align: center; padding: 100rpx; }
</style>
预期输出:页面展示 3 个商品卡片,包含图片、名称、价格。
一句话解释:用 v-for 循环渲染商品列表,用 v-if/v-else 处理加载中/错误/正常三种状态。
📦 项目 2:15 分钟「购物车功能」(加真实需求)
目标:实现购物车的增删改,支持本地持久化存储。
需求点:
1. 点击商品加入购物车
2. 购物车页面显示已选商品和总价
3. 支持删除商品
4. 数据存储在本地(关闭 App 再打开还在)
// store/cart.js - 购物车状态管理
const STORAGE_KEY = 'cart_data'
export default {
state: {
items: []
},
getters: {
totalPrice(state) {
return state.items.reduce((sum, item) => sum + item.price * item.count, 0)
},
totalCount(state) {
return state.items.reduce((sum, item) => sum + item.count, 0)
}
},
mutations: {
ADD_ITEM(state, goods) {
const exist = state.items.find(item => item.id === goods.id)
if (exist) {
exist.count += 1
} else {
state.items.push({ ...goods, count: 1 })
}
this.commit('SAVE_TO_STORAGE')
},
REMOVE_ITEM(state, goodsId) {
state.items = state.items.filter(item => item.id !== goodsId)
this.commit('SAVE_TO_STORAGE')
},
CLEAR_CART(state) {
state.items = []
this.commit('SAVE_TO_STORAGE')
},
LOAD_FROM_STORAGE(state) {
const data = uni.getStorageSync(STORAGE_KEY)
if (data) state.items = data
},
SAVE_TO_STORAGE(state) {
uni.setStorageSync(STORAGE_KEY, state.items)
}
},
actions: {
initCart({ commit }) {
commit('LOAD_FROM_STORAGE')
}
}
}
// pages/cart/cart.vue - 购物车页面
<template>
<view class="container">
<view v-if="cartItems.length === 0" class="empty">
<text>购物车是空的,去逛逛吧~</text>
</view>
<view v-else>
<view v-for="item in cartItems" :key="item.id" class="cart-item">
<text class="item-name">{{ item.name }}</text>
<text class="item-price">¥{{ item.price }} × {{ item.count }}</text>
<button size="mini" type="warn" @click="removeItem(item.id)">删除</button>
</view>
<view class="summary">
<text>共 {{ totalCount }} 件商品</text>
<text class="total">总价:¥{{ totalPrice }}</text>
</view>
<button type="primary" @click="checkout">结算</button>
<button plain @click="clearCart">清空购物车</button>
</view>
</view>
</template>
<script>
import store from '@/store'
export default {
computed: {
cartItems() {
return store.state.cart.items
},
totalPrice() {
return store.getters.totalPrice
},
totalCount() {
return store.getters.totalCount
}
},
onShow() {
// 每次进入页面刷新数据
store.dispatch('initCart')
},
methods: {
removeItem(id) {
store.commit('REMOVE_ITEM', id)
},
clearCart() {
uni.showModal({
title: '确认清空?',
success: (res) => {
if (res.confirm) {
store.commit('CLEAR_CART')
}
}
})
},
checkout() {
uni.showToast({ title: '结算功能开发中', icon: 'none' })
}
}
}
</script>
<style>
.container { padding: 20rpx; }
.cart-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 12rpx;
}
.item-name { flex: 1; font-size: 28rpx; }
.item-price { margin: 0 20rpx; color: #ff6b6b; }
.summary {
display: flex;
justify-content: space-between;
padding: 30rpx;
margin: 20rpx 0;
background: #f8f8f8;
}
.total { color: #ff6b6b; font-weight: bold; font-size: 32rpx; }
.empty { text-align: center; padding: 200rpx; color: #999; }
</style>
预期输出:购物车页面显示已添加的商品列表,支持删除、清空,能实时计算总价。
一句话解释:用 uni.setStorageSync 把购物车数据存在本地,用 Vuex 统一管理「增删改查」。
📦 项目 3:15 分钟「商品详情页 + 加入购物车」
目标:点击商品列表进入详情页,一键加入购物车。
// pages/detail/detail.vue
<template>
<view class="container" v-if="goods">
<image :src="goods.image" mode="widthFix" class="main-image"></image>
<view class="info">
<text class="price">¥{{ goods.price }}</text>
<text class="name">{{ goods.name }}</text>
<text class="desc">{{ goods.description }}</text>
</view>
<view class="bottom-bar">
<view class="icon-btn" @click="goHome">
<text>首页</text>
</view>
<view class="icon-btn" @click="goCart">
<text>购物车</text>
</view>
<button class="add-btn" @click="addToCart">加入购物车</button>
</view>
</view>
</template>
<script>
import store from '@/store'
export default {
data() {
return {
goods: null
}
},
onLoad(options) {
// 从上一个页面拿到商品 ID
const goodsId = options.id
this.fetchGoodsDetail(goodsId)
},
methods: {
async fetchGoodsDetail(id) {
// 模拟获取详情
const mockGoods = {
id: Number(id),
name: 'iPhone 15 256GB',
price: 5999,
image: '/static/phone.jpg',
description: 'A16 仿生芯片 | 6.1 英寸超视网膜 XDR 显示屏 | 4800 万像素主摄'
}
this.goods = mockGoods
},
addToCart() {
if (!this.goods) return
store.commit('ADD_ITEM', this.goods)
uni.showToast({
title: '已加入购物车',
icon: 'success'
})
},
goHome() {
uni.switchTab({ url: '/pages/index/index' })
},
goCart() {
uni.switchTab({ url: '/pages/cart/cart' })
}
}
}
</script>
<style>
.container { padding-bottom: 120rpx; }
.main-image { width: 100%; }
.info { padding: 30rpx; }
.price { color: #ff6b6b; font-size: 40rpx; font-weight: bold; }
.name { display: block; font-size: 32rpx; margin: 20rpx 0; }
.desc { color: #666; font-size: 26rpx; line-height: 1.6; }
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 20rpx;
background: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.1);
}
.icon-btn {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #666;
}
.add-btn {
flex: 2;
background: #ff6b6b;
color: #fff;
border-radius: 50rpx;
margin-left: 20rpx;
}
</style>
预期输出:详情页展示商品完整信息,底部有「加入购物车」按钮,点击后显示 toast 并跳转购物车。
一句话解释:通过页面间传参(onLoad 的 options)拿到商品 ID,获取详情后用 Vuex 的 mutation 加入购物车。
💪 进阶 20 分钟:常见坑 + 调试技巧
❌ 坑 1:异步请求数据,页面渲染了但数据还没到
// 错误示例:数据还没回来,就去渲染
async onLoad() {
const res = await this.fetchGoods()
this.goodsList = res.data // 此时页面已经渲染完了
},
template: `
<view>{{ goodsList[0].name }}</view> // 报错:goodsList 是空的
`
// 正确示例:用 v-if 等待数据
<view v-if="goodsList.length > 0">
{{ goodsList[0].name }}
</view>
❌ 坑 2:Vuex 状态变了,但页面没更新
// 错误示例:直接改数组某个元素
this.$store.state.cart.items[0].count = 10 // 不生效!
// 正确示例:通过 mutation 修改
this.$store.commit('UPDATE_COUNT', { id: 1, count: 10 })
// 在 store 里
mutations: {
UPDATE_COUNT(state, payload) {
const item = state.items.find(i => i.id === payload.id)
if (item) item.count = payload.count
}
}
❌ 坑 3:本地存储 key 名字打错了
// 错误示例:两个地方 key 不一致
uni.setStorageSync('cart', data) // 存的时候用 'cart'
uni.getStorageSync('cart_data') // 取的时候用 'cart_data' ❌
// 正确示例:统一用常量
const CART_KEY = 'my_shopping_cart'
uni.setStorageSync(CART_KEY, data)
uni.getStorageSync(CART_KEY)
❌ 坑 4:for 循环没加 key
<!-- 错误示例:Vue 会报警告 -->
<view v-for="item in list">{{ item.name }}</view>
<!-- 正确示例:加唯一的 key -->
<view v-for="(item, index) in list" :key="item.id">{{ item.name }}</view>
❌ 坑 5:真机调试时请求失败,提示「合法域名」
这是因为 uniapp 对非 HTTPS 请求有限制。临时解决方案:
// manifest.json -> App常用其他设置 -> 勾选「不校验合法域名」
正式方案:后端配置 HTTPS,购买域名并备案。
🔧 调试技巧:console.log 还能这么用
// 基本用法
console.log('商品列表:', this.goodsList)
// 格式化输出
console.log('当前商品: %s, 价格: %d', goods.name, goods.price)
// 打印对象(好看一点)
console.table(this.cartItems)
// 警告(黄色)和错误(红色)
console.warn('库存不足')
console.error('请求超时')
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):换一个商品数据
- 输入:把项目 1 的 mockData 换成你喜欢的 3 个商品
- 预期输出:页面显示你自定义的商品
练习 2(2 分钟):加一个判断
- 输入:在项目 1 的基础上,如果商品数量大于 5 件,显示「商品种类丰富」
- 预期输出:列表上方显示提示文字
练习 3(3 分钟):处理新数据格式
- 输入:假设接口返回的数据格式是 { code: 200, data: [...] },修改 fetchGoods 函数解析它
- 预期输出:列表正常显示(注意数据路径变了)
练习 4(3 分钟):串联项目 2 和 3
- 输入:把项目 3 的「加入购物车」改成「立即购买」——点击后直接跳转结算页
- 预期输出:点击后 uni.showToast,然后跳转到购物车
练习 5(挑战题,5 分钟):看图找错
- 题目:用户反馈「加入购物车后数字没变」,截图是一个 console 报错 Cannot read property 'push' of undefined
- 输入:分析报错原因并修复
- 提示:检查 cart.js 里 ADD_ITEM mutation 的实现
作业题(30 分钟 - 2 小时)
做一个「商品收藏夹」功能
- 需求描述:用户可以收藏喜欢的商品,收藏列表单独一个页面,支持增删查
- 功能点:
1. 详情页有「收藏」按钮(心形 icon),点击切换收藏状态
2. 新建pages/favorite/favorite.vue页面,展示收藏列表
3. 收藏数据本地持久化(关闭 App 再打开还在) - 加分项:
1. 收藏时显示动画效果
2. 收藏列表为空时显示空状态插图 - 验收标准:
- 能正常添加/移除收藏
- 收藏列表页面数据准确
- 关闭小程序再打开,收藏数据还在
📚 总结 + 资源
本文学了 3 个核心点:
- uniapp 项目结构:pages/components/static 各司其职,就像一栋楼的户型图
- 数据请求 + 响应式:用
uni.request拿数据,用 Vue 语法绑定数据,页面自动更新 - Vuex 状态管理:把需要共享的数据放「中央仓库」,哪里需要哪里拿
延伸学习资源:
- uniapp 官方文档 - 生命周期「搞懂 onLoad/onShow 的区别」
- Vuex 官方教程「状态管理更深入的用法」
- uniapp 实战:小程序 + H5 + App 三端合一「实体书,系统学习」
互动钩子:
你在做电商项目时,遇到过最头疼的问题是什么?是我的页面数据渲染不出来,还是 Vuex 状态不更新?评论区聊聊,老粉优先回复!
💡 下章预告:学会了电商核心页面的数据流转,下一章我们要给 App 装上「眼睛」——接入地图和定位功能,让用户知道附近有哪些门店,还能导航过去。敬请期待「第 8 章 8.1 地图与定位」!

评论(0)