简介:一份完整的微信小程序酒水商城案例源码包,面向已有基础语法知识、希望投入真实项目练习的小程序员,适合电商类小程序开发与教学参考。压缩包总计58个文件,其中包含PNG图片素材、WXML页面结构、WXSS样式表、JavaScript逻辑代码以及JSON配置文件等,整体体积仅1.83MB,结构精简,便于逐模块拆解学习。案例围绕商城核心流程展开,从商品展示、规格选择、加入购物车到订单确认与微信支付,覆盖页面布局与响应式设计、setData数据更新、wx.request后端交互、点击与滑动事件处理、列表动画及页面导航路由等关键知识点,可让读者快速理解中小型电商项目的架构与常用开发模式。结合微信开发者工具还能实现真机预览、性能监控与错误日志查阅,便于对照源码练习调试技巧。目前已有233人参与学习,源码目录划分清晰,含有入口配置、页面模块与工具封装,既适合配合视频课程逐步仿写,也可作为自行搭建酒水或类似商品商城小程序的启动模板。
1. 酒水商城小程序源码:拿到手先搞清楚这是什么
想跑通一个带商品列表、购物车、下单流程的微信小程序商城,但网上搜到的模板大多是只有几个静态页面的切片稿。这份《微信小程序开发-酒水商城案例源码.zip》不一样,它是一套原生微信小程序写的完整酒水商城案例,解压后用微信开发者工具直接打开,就能看到从首页导购到购物车再到提交订单的完整闭环。源码工程的意义就在这:你不用从零搭框架,先把它跑起来,再顺着代码改业务,比自己重头啃文档快很多。
这套案例适合三类人:一是刚入门小程序开发、想看看真实商城项目是怎么组织页面和数据的;二是做课程设计或毕业设计,需要一套能现场演示的商城 demo;三是接外包时需要一个底子干净的商城模板,往上套品类和 UI。后面的章节我会按项目结构、登录授权、购物车下单、常见坑和上真机的顺序拆,每个环节都给到能直接抄的代码和参数说明。
2. 拆解源码工程:从 app.json 入口到商品列表的数据流
拿到 zip 先别急着点“编译”,第一步是看清项目骨架。小程序工程的入口是根目录下的app.json,它决定了页面注册、底部导航和全局窗口样式。很多新手上来就改页面,改了半天发现 tabBar 不显示或者页面白屏,基本都是全局配置文件出了问题。
2.1 页面目录与全局配置:先读懂工程的“总控文件”
一个标准的原生小程序商城工程,目录通常包含pages(页面)、components(自定义组件)、utils(工具函数)、images(静态资源)这几个顶层文件夹。pages下面每个模块对应一个子目录,比如pages/index是首页、pages/cart是购物车、pages/mine是我的页面。
先看app.json,这是全局配置的核心:
{ "pages": [ "pages/index/index", "pages/category/category", "pages/cart/cart", "pages/mine/mine", "pages/goods-detail/index", "pages/order/confirm", "pages/order/list" ], "tabBar": { "color": "#666666", "selectedColor": "#c30d0d", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/cart/cart", "text": "购物车" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "window": { "navigationBarTitleText": "酒水商城", "navigationBarBackgroundColor": "#333333", "navigationBarTextStyle": "white" }, "sitemapLocation": "sitemap.json" }pages数组里第一项就是小程序的启动页面。tabBar 的list最多配 5 个 tab,这里用了 4 个,覆盖了商城最常用的四个入口。window里的navigationBarTitleText是全局导航栏标题,但单个页面可以在自己的.json里覆盖它。有一点要特别注意:tabBar 的图标iconPath必须是本地图片路径,不能填网络 URL,而且图片尺寸建议 81px × 81px,过大或过小都会被微信压缩变形。
// utils/request.js const request = (options) => { return new Promise((resolve, reject) => { wx.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else if (res.statusCode === 401) { wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/index' }) reject(new Error('未登录')) } else { wx.showToast({ title: '请求失败', icon: 'none' }) reject(res) } }, fail: (err) => reject(err) }) }) } module.exports = request这段是整套源码里最值得先读的文件,它把所有wx.request封装成了 Promise,统一处理了 401 未登录跳转和错误提示。注意method和data都给了默认值,所以调用方只需要传url就能发 GET 请求。我在实际项目里还会往 header 里塞 token,写法是header: { 'Authorization': wx.getStorageSync('token') },这个案例源码里也留了同样的位置。
2.2 商品列表页的数据装载:分页参数与 setData 局部更新
看完全局配置,第二个要读的是首页pages/index/index.js。商城首页最常见的逻辑是 loading 状态、分页加载、下拉刷新三件事。下面这段是商品列表加载的典型写法:
const request = require('../../utils/request') Page({ data: { goodsList: [], loading: true, page: 1, pageSize: 10, hasMore: true }, onLoad() { this.loadGoods() }, loadGoods() { if (!this.data.hasMore || this.data.loading) return this.setData({ loading: true }) request({ url: '/api/goods/list', method: 'GET', data: { page: this.data.page, pageSize: this.data.pageSize, category: 'wine' } }).then((res) => { const list = res.data.list this.setData({ goodsList: this.data.goodsList.concat(list), page: this.data.page + 1, hasMore: res.data.hasMore, loading: false }) }) } })这段代码有三个关键点。第一,hasMore和loading双开关防止重复请求,用户快速滑动触发多次onReachBottom时,只有上一次请求结束且还有下一页才会继续加载。第二,用concat拼新列表而不是整体赋值,保留已加载的数据。第三,category: 'wine'是酒水商城按品类过滤的参数,改成'beer'或'red-wine'就能切换分类。
setData的性能直接决定页面流畅度。我见过有人把整个goodsList在每次请求后都setData一遍,数据量到 100 条时明显卡顿。源码工程里用的是增量concat+ 整体覆盖,在小程序性能可接受范围内。如果商品数量更大,可以考虑按分页块渲染,也就是只渲染当前可视区域的数据切片。商品列表渲染到 WXML 时,wx:key一定要绑定商品 id,不然列表项复用会出状态错乱。
3. 登录与手机号授权:从 wx.login 到 code2Session 的完整链路
酒水商城下单必须绑定用户身份,源码里登录模块用的是微信生态最标准的 wx.login + 服务端 code2Session 方案。今年微信对用户隐私收得很紧,这块的代码写法直接影响审核能不能过。
3.1 wx.login 与静默登录:为什么校验一定要后端配合
很多新手以为wx.login能直接拿到用户身份,其实它只返回一个临时code,这个 code 必须由后端拿去向微信接口换取openid和session_key。前端不能直接调微信的这个接口,因为调用需要appid和appSecret,这两个 key 一旦暴露在小程序代码包里,任何人都能反编译拿到。
wx.login({ timeout: 5000, success(res) { if (res.code) { wx.request({ url: 'https://api.example.com/api/login', method: 'POST', data: { code: res.code }, success(loginRes) { const { token, expiresIn } = loginRes.data.data wx.setStorageSync('token', token) wx.setStorageSync('tokenExpire', Date.now() + expiresIn * 1000) } }) } else { console.log('登录失败', res.errMsg) } } })wx.login的timeout参数是超时时间,一般 5000 毫秒足够,弱网环境可以调到 10000。res.code的有效期只有 5 分钟,而且只能用一次,用完后作废,所以拿到后要立刻传给后端。后端的动作是用这个 code 调微信的code2Session接口,拿到openid,再自己签一个token返回给前端。源码里把这个 token 同时存了值和过期时间,我建议你也保留这个习惯,因为后面判断登录态过期要用到。
这里要区分两个容易搞混的概念:openid是用户在某个小程序下的唯一 ID,同一个用户在你另一个小程序里是另一个openid;unionid是同一微信开放平台账号下多个应用共享的 ID。酒水商城这种单小程序场景,用openid就够了。
3.2 手机号快速验证组件:拿不到真实手机号的兜底方案
商城下单要填收货人手机号,微信提供了open-type="getPhoneNumber"的按钮来一键获取。但这里有一个最近很多人踩的坑:这个能力现在收取费用,而且个人主体小程序无法开通,只有企业主体且完成微信认证后才能调用。
<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">微信一键登录</button>onGetPhoneNumber(e) { if (e.detail.code) { request({ url: '/api/phone', method: 'POST', data: { code: e.detail.code } }).then((res) => { wx.setStorageSync('userPhone', res.data.phone) }) } else { wx.showToast({ title: '需要授权手机号才能下单', icon: 'none' }) } }注意新版接口返回的是e.detail.code,不是e.detail.encryptedData和iv。拿到 code 后由后端调phonenumber.getPhoneNumber接口换手机号,前端永远不接触解密逻辑。如果e.detail.code为空,说明用户点了拒绝授权,这时不能反复弹窗骚扰用户,正确的做法是引导用户到“设置”页手动开启手机号授权,或者退一步让用户手动输入手机号。
提示:手机号快速验证是按成功调用次数计费的,联调测试时不要反复触发,否则一个下午就能产生几十次计费记录。企业主体开发者可以在开发者后台“能力-手机号快速验证”里看到调用量和费用明细。
3.3 登录态保持:token 过期与静默续期
登录态不能只在登录时刷一次。用户的 token 过期后,如果每次都要重新走一遍登录流程,体验会很差。源码里处理这个问题的套路是在request封装里加一层 token 过期判断:
const isTokenExpired = () => { const expire = wx.getStorageSync('tokenExpire') if (!expire) return true return Date.now() > expire } const silentLogin = () => { return new Promise((resolve) => { wx.login({ success(res) { wx.request({ url: 'https://api.example.com/api/refresh', method: 'POST', data: { code: res.code }, success: (loginRes) => { wx.setStorageSync('token', loginRes.data.data.token) resolve() } }) } }) }) }这个方案的核心是:发起业务请求前先检查tokenExpire,如果快要过期或已过期,先调silentLogin换新 token,再继续原来的业务请求。微信官方并没有提供刷新 token 的接口,常见做法就是重新wx.login拿新 code 去换,这一点是“静默续期”的本质。session_key的失效对前端是黑匣子,后端如果返回session_key 无效之类的错误码,前端能做的也只是重新走一遍wx.login。
4. 购物车与下单:SKU 联动、库存校验与支付闭环的模拟
购物车是商城 demo 里最见功力的模块。酒水类目有个特点:同一款商品有不同规格——容量(500ml/750ml/整箱)、度数(12 度/42 度)、包装(单瓶/礼盒装),规格不同价格和库存都不同。源码里把规格和购物车数据结构做了绑定,这块读懂了,换到服装、美妆类目也能复用。
4.1 购物车数据结构设计:为什么用对象而不是数组
购物车里同一个商品的不同规格要分开存,数据结构设计得不好,后面算总价和改数量时会到处写 patch 代码。源码里用的是以“goodsId_规格”为 key 的对象结构:
const cartMap = { '1001_750ml': { goodsId: '1001', spec: '750ml', price: 199, count: 2, stock: 10 }, '1001_case': { goodsId: '1001', spec: '整箱6瓶', price: 1099, count: 1, stock: 5 }, '1002_500ml': { goodsId: '1002', spec: '500ml', price: 89, count: 3, stock: 20 } }以goodsId + 规格拼接字符串作为 key,有几个好处。修改数量时直接按 key 取值:
addCount(key) { const item = this.data.cartMap[key] if (item.count >= item.stock) { wx.showToast({ title: '库存不足', icon: 'none' }) return } this.setData({ [`cartMap.${key}.count`]: item.count + 1 }) }细看这段:setData的路径写法cartMap.${key}.count只更新目标字段,不会整个对象重建,性能和代码可读性都好。库存校验放在前端只是体验优化,真正要在下单接口里由后端再校验一次,因为前端可以改数据绕过限制。
对象和数组的差别在于:数组要findIndex遍历才能定位元素,元素多了以后每次操作都是 O(n),而且删除和插入容易出索引错位;对象直接按键取值是 O(1)。用对象唯一要注意的是setData时不能直接this.data.cartMap[key].count = 1,必须走上面这种带路径的写法才能触发视图更新。
下面是购物车字段的约定,新人照着存不会错:
| 字段 | 类型 | 说明 |
|---|---|---|
| goodsId | string | 商品唯一 ID |
| spec | string | 规格描述,如 750ml / 整箱6瓶 |
| price | number | 单价,单位是分,避免浮点误差 |
| count | number | 购买数量 |
| stock | number | 该规格实时库存 |
| selected | boolean | 是否勾选,结算时过滤 |
价格用“分”为单位存储是商城开发的通病教训,浮点数直接计算会出现 0.1 + 0.2 = 0.30000000000000004 这类问题。源码里所有价格运算我都建议先转成整数分再算,展示时再除以 100。
4.2 SKU 选择与库存校验:酒水规格切换的联动逻辑
商品详情页的规格选择是另一个容易写崩的点。选中不同容量、不同包装,商品图、价格、库存和加购按钮状态都要跟着变。核心是一个switchSpec方法:
switchSpec(goodsId, specItem) { const stock = specItem.stock if (stock <= 0) { this.setData({ selectedSpec: null, stockTips: '该规格暂时缺货' }) return } this.setData({ selectedSpec: specItem.spec, currentPrice: specItem.price, currentStock: stock, selectedImage: specItem.image, canBuy: true }) }这里有个容易被忽略的细节:selectedSpec存的是规格字符串而不是商品 id。因为在返回给后端下单时,后端需要的是完整的goodsId + spec组合,用字符串可以避免同时维护两个状态。stock <= 0时置灰“加入购物车”按钮,同时提示“到货通知”,这是电商比较友好的交互。
规格联动还有一个边界:用户先选了 750ml,然后把商品加购了,再切回 500ml 加购,购物车里的 key 必须能区分这两种规格。所以上面购物车 key 才用1001_750ml和1001_case这种复合串。源码工程里如果发现同一商品只能存在一个购物车条目,那大概率是把 key 只拼了goodsId,这是要先修的 bug。
4.3 下单流程与支付模拟:个人主体没有商户号怎么办
真正下单要走微信支付wx.requestPayment,但这个接口必须有微信支付商户号才能调通。个人主体小程序不支持申请商户号,所以案例源码里几乎所有商城的处理方式都是模拟支付闭环。
mockPay(orderId, totalPrice) { wx.showModal({ title: '模拟支付', content: `订单 ${orderId} 需支付 ${totalPrice / 100} 元(个人主体无商户号,模拟支付成功)`, confirmText: '模拟支付成功', cancelText: '取消' }).then((res) => { if (res.confirm) { request({ url: '/api/order/status', method: 'POST', data: { orderId: orderId, status: 'paid' } }).then(() => { wx.navigateTo({ url: '/pages/order/list' }) }) } }) }这个设计的思路是:下单接口该调的还是调,后端把订单状态改为“待支付”,前端弹一个模拟支付弹窗,用户确认后把订单状态改成“已支付”。等到真正接了商户号,只需要把mockPay替换成wx.requestPayment,传入timeStamp、nonceStr、package、signType、paySign这 5 个参数,其余流程都不动。
wx.requestPayment的参数里有几个容易误解的地方:package不是订单号,而是prepay_id=xxxx这种格式的字符串;paySign是后端用商户密钥做的签名,前端永远无法自己构造。这也是为什么支付逻辑必须由后端统一下单后返回参数,而不是前端直接调微信接口。
订单状态流转一般是这样:待支付 → 已支付 → 已发货 → 已完成,取消的订单走待支付 → 已取消。源码里如果状态是数字而不是字符串,你别慌,很多后端习惯用 0/1/2/3 映射状态,建议你在前端单独写一个状态映射表,方便展示订单列表时翻译成中文。
5. 避坑排查清单:导航栏高度、授权弹窗与模拟器差异
这套源码在开发者工具里跑通只是第一步,真机一测总会冒出一堆模拟器上看不到的问题。我整理了几个商城项目里出现频率最高的,每一条都是按“现象-原因-解决”的思路写的,后面接手这套工程时可以对号入座。
5.1 顶部导航栏高度:同一套代码在安卓和 iOS 上为什么错位
现象:页面里用了自定义导航栏(也就是把navigationStyle设为custom),在开发者工具里看位置刚好,但预览到 iPhone 上标题偏下,安卓上又偏上。
原因:小程序右上角的胶囊按钮位置每台机器不一样。导航栏高度并不等于固定的 44px 或 48px,而是由“状态栏高度 + 胶囊高度 + 胶囊上下间距”组成的。iPhone X 系列的刘海屏状态栏是 44px,普通安卓是 24px,固定写死必然错位。
解决:用wx.getMenuButtonBoundingClientRect()动态测量胶囊位置,再算出导航栏高度:
const systemInfo = wx.getWindowInfo() const menuButton = wx.getMenuButtonBoundingClientRect() const navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height const navBarTop = menuButton.top const menuRight = menuButton.right这段计算出的navBarHeight就是自定义导航栏的占位高度。原理是:胶囊顶部到状态栏底部的距离 × 2 + 胶囊本身高度,等于导航栏总高度。navBarTop用于把自定义标题文字对齐到胶囊同一水平线,menuRight用于计算左侧可用宽度。这三个变量建议放在 globalData 里,避免每个页面都重新测量一次。
注意:
wx.getSystemInfoSync()在新版基础库已经标记弃用,建议直接用wx.getWindowInfo(),功能相同且不会再收到控制台弃用警告。
5.2 获取用户头像昵称:wx.getUserProfile 拿到的全是“微信用户”
现象:代码里用了wx.getUserProfile获取头像昵称,结果拿到的昵称固定是“微信用户”,头像是一个灰色默认图案。
原因:微信在基础库 2.27.1 版本后收紧了 user profile 能力,wx.getUserProfile不再返回真实头像和昵称,这是出于用户隐私的保护策略。老代码如果没做兼容,基本都会踩这一刀。
解决:新写法是让用户主动选择头像和输入昵称。头像用<button open-type="chooseAvatar">,昵称用<input type="nickname">,微信官方给的替代方案就是这两个原生能力。源码里如果是旧版写法,你直接换成下面这组:
<button open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">选择头像</button> <input type="nickname" bindinput="onNicknameInput" placeholder="请输入昵称" />onChooseAvatar(e) { this.setData({ avatarUrl: e.detail.avatarUrl }) }, onNicknameInput(e) { this.setData({ nickname: e.detail.value }) }chooseAvatar返回的是一个临时文件路径,需要先上传到服务器拿到永久 URL 再存用户表,不能直接把临时路径入库。这个步骤新人最容易漏,第二天刷新页面头像就裂了。
5.3 手机号授权:验证码拿不到用户的手机号
现象:把手机上获取手机号组件返回的 code 发给后端,后端报错“code 无效”或者直接提示无权限。
原因:这类问题通常是三种情况之一。基础库版本低于 2.21.2,老版本返回的是encryptedData而不是code,后端接收到后自然解析失败;个人主体小程序没开通手机号快速验证能力;后端拿去换手机号时拿的接口不对,把phonenumber.getPhoneNumber写成了code2Session。
解决:先确认项目project.config.json里的libVersion是 2.21.2 及以上。然后在控制台打印e.detail,看返回的是code还是encryptedData。如果是前者,再确认小程序主体类型——个人主体直接放弃这个方案,改手动输入手机号 + 短信验证码。后端用的接口也单独验证一次,微信文档里的路径是/wxa/business/getuserphonenumber,不要和登录换 session 的接口混在一起。
5.4 域名白名单:开发者工具能跑、真机白屏
现象:开发者工具模拟器一切正常,但点“预览”用手机扫码打开后,页面数据全部加载不出来,下拉刷新也没反应。
原因:开发者工具默认勾选了“不校验合法域名”,模拟器环境不拦截 HTTP 和非白名单域名请求。真机上微信强制走域名校验,所有不在后台白名单里的请求直接被拦截,页面就只剩一个空壳。
解决:开发调试阶段两个办法,一是勾选开发者工具右上角“详情-本地设置-不校验合法域名”,二是用“真机调试”模式扫码,这个模式不走真实域名校验但能真机跑。等要提交审核前,必须在 mp 后台的“开发管理-服务器域名”里把request合法域名配上,而且必须是 HTTPS。
注意:开发者工具勾选“不校验合法域名”只对模拟器生效,真机调试和预览是两个不同的环境,别以为模拟器能跑就万事大吉。我习惯是在发版前强制把工具的域名校验勾回去跑一遍,模拟器和真机各测一次。
6. 把 mock 接口切成真实接口:一个开关搞定联调切换
最后这个技巧值得单独说:源码工程里的接口路径都是/api/xxx这种相对路径,真实项目里你需要面对三种环境——本地 mock 数据、测试服接口、正式服接口。如果每次切换都去改每个页面的request调用,迟早改漏一个。
我的做法是在config目录下放一个环境配置:
// config/index.js module.exports = { isMock: false, baseUrl: 'https://api.example.com', mockBaseUrl: '/mock' }然后在utils/request.js里加一层判断:
const config = require('../config/index') const request = (options) => { if (config.isMock) { return new Promise((resolve) => { resolve(require(`../mock${options.url}.js`)) }) } return new Promise((resolve, reject) => { wx.request({ url: config.baseUrl + options.url, method: options.method || 'GET', data: options.data || {}, success: (res) => resolve(res.data), fail: (err) => reject(err) }) }) }mock 目录下按接口路径建文件,比如/mock/api/goods/list.js,导出一个和数据接口格式完全一致的对象。联调前把isMock改成false,所有请求自动切到真实服务。这个方案比逐个页面改代码省心太多,而且后端没就绪时前端照样能开发页面。
验证切到真实接口是否成功,我一般看三点:Network 面板里请求的 URL 域名是不是正式环境;登录流程能正常拿到 token;购物车下单走完整个状态流。有一次我调试时把isMock打开了忘记关,直接打了个测试包给客户演示,结果整个商城全是假数据,客户当场截图问我为什么商品价格永远不变。从那以后我每次发版前都强制走一遍:改isMock、清缓存、重新编译、确认 Network 面板里的第一个请求落在了真实域名上,再跑一遍下单流程。希望帮到你。
本文还有配套的精品资源,点击获取