第7章 7.5 综合实战:电商完整版(多端)

🎯 上一章我们搞定了「错误监控与日志」——就像给汽车装了黑匣子,终于能知道哪里出了问题。但光知道问题还不够,我们得真正能「造车」才行。

这一章,咱们要把前几章学的所有东西串起来,从页面搭建到数据请求,从状态管理到错误处理,做一个能跑起来的电商首页。就像学做菜,光知道刀怎么用还不够,得真炒出一盘菜来。

🎯 开场 3 分钟:为什么要学这个?

你有没有遇到过这种情况:

  • 照着教程敲代码,敲完发现跑不起来,报错都不知道去哪看
  • 想做个「购物车」功能,搜了半天博客,越看越懵
  • 好不容易调通了一个接口,但换了个数据格式就全挂了

问题在哪? 主要是缺一个「完整走一遍」的经验。

就像学游泳,教练讲再多动作要领,不如自己下水扑腾一次。这一章,我们就把电商 App 最核心的几个页面做出来:商品列表、商品详情、购物车。覆盖数据请求 → 页面渲染 → 状态管理 → 错误处理全流程。

学完你能自己动手做出一个能跑的多端电商原型,下一章的地图定位功能,也能无缝集成进去。

🧱 基础 2\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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()。这像什么?

就像点外卖:

  1. 你告诉商家(后端):我要一份宫保鸡丁(发起请求)
  2. 商家做好了装盒(后端处理)
  3. 骑手送过来(网络传输)
  4. 你打开盒子吃饭(前端渲染数据)

代码长这样:

// 向后端请求商品列表
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 并跳转购物车。

一句话解释:通过页面间传参(onLoadoptions)拿到商品 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 个核心点:

  1. uniapp 项目结构:pages/components/static 各司其职,就像一栋楼的户型图
  2. 数据请求 + 响应式:用 uni.request 拿数据,用 Vue 语法绑定数据,页面自动更新
  3. Vuex 状态管理:把需要共享的数据放「中央仓库」,哪里需要哪里拿

延伸学习资源:

互动钩子:

你在做电商项目时,遇到过最头疼的问题是什么?是我的页面数据渲染不出来,还是 Vuex 状态不更新?评论区聊聊,老粉优先回复!


💡 下章预告:学会了电商核心页面的数据流转,下一章我们要给 App 装上「眼睛」——接入地图和定位功能,让用户知道附近有哪些门店,还能导航过去。敬请期待「第 8 章 8.1 地图与定位」!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。