微信小程序商城源码拆解:从页面骨架到支付链路避坑
简介微信小程序商店的完整实现代码面向微信小程序开发者尤其适合需要快速搭建应用商店或电商类小程序、研究商店整体架构的人群。压缩包共57个文件体积仅131KB代码轻量且结构完整核心文件包括wxml页面结构、wxss样式、js业务逻辑、JSON配置与项目说明另有png图标资源便于直接替换使用。已有4000余人浏览学习。开发者可通过页面布局与工具函数学习小程序码解析、商品分类、搜索下载等典型功能的组织与调用方式也可以抽取其中代码嵌入自己的项目中减少从零开发的时间成本。整个项目目录划分清晰按pages、utils、template等模块归类方便对照阅读与二次开发。作为涵盖前端展示、后端服务与用户体验的完整参考适合用于搭建自己的小程序商店或作为课程设计素材。1. 微信小程序的商店代码完整商城工程拿到手先别急着改 UI微信小程序商店完整代码这类资源网上一抓一大把不少人拿到手先改 UI、换图片改完才发现接口、数据模型、页面传值全是断的。这套代码的价值在于它把「首页-分类-商品详情-购物车-下单-支付-订单列表」这条链路完整串了起来从全局配置、请求封装到支付回调都留出了可替换的口子。它适合三类人刚接触小程序想抄一份完整作业的前端新人给线下门店做商城的小团队以及想跑通微信支付流程但不想从零搭骨架的二开党。先看骨架再跑链路最后改皮是这套资源最不容易翻车的上手顺序。2. 拆开骨架看结构目录分层、数据模型与请求封装的三个关键点新手打开完整代码工程常见动作是一页一页翻 pages 目录翻完感觉“挺全的”但真到要改需求时不知道从哪下手。我建议大家先拆三层全局层app.js、app.json、app.wxss、工具层utils 下的 request、auth、format 等公共模块、页面层pages 下每个页面各自的 js、json、wxml、wxss。把这三层边界划清楚后面改接口、改样式、加页面才不会互相踩。这套代码里最应该先读的三个文件就是 app.json、utils/request.js 和 app.js它们决定了整个工程的路由、网络和数据底座。2.1 页面注册与 TabBar 配置app.json 决定的启动路由微信小程序编译时第一个找的就是根目录的 app.json它不只是样式配置还承担了页面注册和底部导航的定义。很多人以为某个页面文件名带 index 就会被当成默认首页实际上 pages 数组里排第一的才是冷启动的落地页顺序错了打开小程序直接进到购物车页或者订单页体验会很奇怪。{ pages: [ pages/index/index, pages/category/category, pages/cart/cart, pages/user/user, pages/goods_detail/goods_detail, pages/order_confirm/order_confirm, pages/order_list/order_list, pages/pay_result/pay_result ], window: { navigationBarBackgroundColor: #ffffff, navigationBarTitleText: 小店, navigationBarTextStyle: black, backgroundColor: #f6f6f6 }, tabBar: { color: #9b9b9b, selectedColor: #e64340, list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/category/category, text: 分类 }, { pagePath: pages/cart/cart, text: 购物车 }, { pagePath: pages/user/user, text: 我的 } ] } }逻辑说明pages 数组是这个工程的完整路由表所有能访问的页面都必须在这里注册。tabBar 里挂的页面必须同时出现在 pages 数组里而且 tabBar 页面不能用 wx.navigateTo 去跳只能通过 switchTab这是个高频报错点。navigationBarTextStyle 只支持 black 和 white 两个枚举值写成其他样式会被真机忽略。参数说明navigationBarBackgroundColor 只收十六进制色值不支持 rgb() 写法。很多人只改了 wxss 里的页面背景色忘了改 window.backgroundColor真机上拉页面露出底部的白边多半就是这里没对齐。tabBar 里的 pagePath 要和 pages 数组的路径完全一致少一个点、多个斜杠都会直接导致编译报错。2.2 globalData 与本地缓存的边界哪些数据该放哪里这套代码里数据存放有三种位置globalData 存启动时需要全局共享的信息wx.setStorageSync 存需要跨页面持久化的业务数据页面 data 存当前页面的渲染状态。新手经常把购物车塞进 globalData这是隐患小程序进程可能随时被系统回收globalData 不持久下一次冷启动数据就没了。买过的东西全清空用户会直接弃用。// app.js App({ globalData: { shopInfo: null, token: , baseURL: https://api.example-store.com }, onLaunch() { // 启动时先恢复登录态和购物车 const token wx.getStorageSync(token) const cart wx.getStorageSync(cart) if (token) { this.globalData.token token } if (!cart) { // 首次打开给购物车一个空数组避免后续页面直接 map 报错 wx.setStorageSync(cart, []) } // 拉取店铺信息用于全局展示店铺名称和联系方式 this.loadShopInfo() } })逻辑说明onLaunch 阶段同步读取本地缓存避免后续页面 onLoad 时拿不到数据。购物车初始化为空数组这一步很关键很多“白屏 xxx is not a function”的报错就是前面某次写入把购物车格式写坏后面页面当数组遍历时报出来的。token 是登录后的身份凭证后端接口对它做校验失效时请求层应该统一拦截这个链路稍后在请求封装里展开。参数说明wx.getStorageSync 是同步 API适合启动阶段的小体量读取数据量大了以后要考虑异步版 wx.getStorage 或者直接走服务端接口。baseURL 放在 globalData 里不是长久之计更干净的做法是单独建一个 config.js把域名、版本号、开关集中管理后面换环境只改一个文件。2.3 核心数据模型设计商品、购物车项、订单的结构约定这套商店代码里数据模型直接决定页面遍历的写法。购物车里每一项应该是一个扁平对象goodsId、skuId、name、price、image、count、stock。后端返回的商品对象可能嵌套很深进入购物车之前一定要先做一次扁平化映射否则页面取价格还要一层一层点属性写起来痛苦改起来更是灾难。数据对象关键字段用途说明商品id, title, cover, price, stock, skuList页面卡片展示与详情页规格选择购物车项goodsId, skuId, name, price, image, count, stock购物车列表渲染和结算计算订单orderId, status, totalAmount, goodsList, addressId订单列表展示和支付回调购物车项的 count 表示商品数量stock 是下单瞬间的库存快照结算前要用它做一次前端拦截。price 建议用整数分存储展示时再除以 100 并保留两位小数避免浮点运算出现 0.1 0.2 之类的精度玄学问题。如果后端返回的是元单位浮点数那就在请求层统一做一次转换不要在页面里到处乘除。逻辑说明这张表不是随便列的每个字段都能在 wxml 里的数据绑定中找到对应位置。比如订单列表页只需要 orderId、status、totalAmount 三个字段用户中心页依赖 status 渲染待付款、待发货、待收货的角标。字段少了页面缺数据字段多了接口传输有冗余这套代码里的字段约定基本是按常见商城 H5 那套模型迁移过来的二开时最好保持结构不变。2.4 请求层封装把 wx.request 包装成可拦载、可替换的 Promise 模块整套代码里最值得复用的就是 utils/request.js。它做的事情不算复杂但把 wx.request 包成 Promise 绕开了回调地狱同时统一处理登录态携带、业务码判断、401 跳转和错误提示。拿到这套代码后第一件要改的就是这个文件的 baseURL以及确认后端接口的返回结构是否是约定的 code data message。// utils/request.js const config require(../config) function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: config.BASE_URL path, method, data, header: { Content-Type: application/json, // token 同步带上后端从这里解析用户身份 Authorization: wx.getStorageSync(token) || }, success(res) { // 约定statusCode 200 且 code0 表示业务成功 if (res.statusCode 200 res.data.code 0) { resolve(res.data.data) } else if (res.statusCode 401) { // 登录态失效清缓存、跳登录页 wx.removeStorageSync(token) wx.navigateTo({ url: /pages/login/login }) reject(new Error(login expired)) } else { wx.showToast({ title: res.data.message || 请求失败, icon: none }) reject(new Error(res.data.message)) } }, fail(err) { // 网络层失败断网、域名未配置、证书问题等 wx.showToast({ title: 网络异常请稍后重试, icon: none }) reject(err) } }) }) } module.exports { request }逻辑说明把 wx.request 包成 Promise 是为了配合 async/await 串起复杂流程典型场景是先获取收货地址再计算运费最后提交订单。Authorization 字段从本地缓存里取不用每次调接口临时传 token。fail 回调里最常见的报错是 request:fail url not in domain list说明请求域名没在小程序后台配置或者开发工具里开了域名校验。参数说明method 默认值写在函数签名里页面里不传就自动用 GET。code0 是这套代码约定的业务成功标记如果你们后端用的是别的字段名比如 successtrue只需要改这一处判断即可。401 统一做跳转避免每个页面自己写一遍 token 过期逻辑。如果项目里还有上传文件的需求可以在同目录加一个 upload.js复用同样的 header 和错误提示逻辑。3. 把核心链路跑通商品列表、购物车联动与下单支付的页面代码看完全局再进页面这套代码里信息量最大的是三个页面首页/分类页的商品列表、购物车页的数量与金额联动、下单确认页的地址与支付串联。把这三段代码吃透基本上整个商店的主流程你就拿下了。改起来最顺手的顺序是先跑通列表展示再调购物车逻辑最后连支付。3.1 首页与商品列表分页加载onReachBottom 触底续载的写法首页看起来只是淘几个商品卡片实际上隐藏了分页加载、下拉刷新、加载状态保护三套逻辑。很多二开版本这里最容易出现的问题是一口气拉全量商品或者触底之后重复请求所以看代码时重点盯着 page、pageSize、loading、finished 这四个标记位。// pages/index/index.js const { request } require(../../utils/request) Page({ data: { banners: [], goodsList: [], page: 1, pageSize: 8, loading: false, finished: false }, onLoad() { this.loadBanners() this.loadGoods(true) }, async loadGoods(reset false) { // reset 为 true 表示下拉刷新从头开始拉 if (this.data.loading || this.data.finished) return this.setData({ loading: true }) const page reset ? 1 : this.data.page try { const res await request(/goods/list?page${page}pageSize${this.data.pageSize}) const list res.list || [] this.setData({ goodsList: reset ? list : this.data.goodsList.concat(list), page: page 1, loading: false, finished: list.length this.data.pageSize }) } catch (e) { this.setData({ loading: false }) } }, onReachBottom() { // 触底时追加下一页 this.loadGoods() }, onPullDownRefresh() { this.loadGoods(true).then(() wx.stopPullDownRefresh()) } })逻辑说明loadGoods 接收一个 reset 参数下拉刷新时重置页码从 1 开始触底加载时用当前页码继续往后取。loading 和 finished 是两道闸前者防止快速滚动时多次触发请求后者判断列表拉完就停止后续请求。concat 拼接数组而不是整体覆盖这样触底加载不会丢掉前面已有的数据。参数说明pageSize 设置成 8 只是示例具体要根据后端接口的分页上限来调设太大会导致首屏渲染变慢。finished 的判断依据是 list 长度小于 pageSize意味着最后一页没拉满反推已经没有更多数据。如果后端的分页字段是 pageNum 而不是 page记得在请求层做参数名转换页面代码保持不变。3.2 购物车数量变更setData 按路径更新与金额重算购物车页面看着简单实际是整套代码里最容易出错的地方。数量加减如果整页 setData在商品多的时候会明显卡顿金额计算如果依赖页面里某个过期的 data 字段就会出现数量改了金额不动、库存超了还在加的黑匣子问题。这套代码的处理方式是“只改数组里的那一项再单独重算金额”。// pages/cart/cart.js Page({ data: { items: [], selectedIds: {}, totalPrice: 0.00, selectedCount: 0 }, onShow() { // 每次从缓存里读取保证购物车角标和列表同步 this.setData({ items: wx.getStorageSync(cart) || [] }) this.calcTotal() }, changeCount(e) { const { id, delta } e.currentTarget.dataset const items this.data.items const idx items.findIndex(item item.id id) if (idx -1) return const nextCount items[idx].count delta const stock items[idx].stock || 99 if (nextCount 1) return if (nextCount stock) { wx.showToast({ title: 库存不够啦, icon: none }) return } // 只更新数组里这一项的 count避免整页重绘 this.setData({ [items[${idx}].count]: nextCount }) wx.setStorageSync(cart, this.data.items) this.calcTotal() }, calcTotal() { const items this.data.items let total 0 let count 0 items.forEach(item { if (this.data.selectedIds[item.id]) { total item.price * item.count count item.count } }) this.setData({ totalPrice: total.toFixed(2), selectedCount: count }) } })逻辑说明changeCount 通过 dataset 拿到商品 id 和增量 delta先检查数量和库存边界再通过计算属性路径items[${idx}].count精准更新目标项。setData 支持数组下标的字符串路径这是官方文档里明确写过的性能优化点。wx.setStorageSync 紧跟其后把最新数组写回缓存这样即使页面被系统回收数据也不会丢。参数说明delta 在 wxml 里通过>// pages/order_confirm/order_confirm.js const { request } require(../../utils/request) Page({ data: { address: null, goodsList: [], totalAmount: 0, postage: 0, submitting: false }, async submitOrder() { if (this.data.submitting) return if (!this.data.address) { wx.showToast({ title: 请先选择收货地址, icon: none }) return } const goodsListSnapshot this.data.goodsList.map(g ({ goodsId: g.goodsId, skuId: g.skuId, count: g.count })) this.setData({ submitting: true }) try { const res await request(/order/create, POST, { addressId: this.data.address.id, goodsList: goodsListSnapshot, remark: this.data.remark || }) // 后端返回支付参数直接透传给 wx.requestPayment const pay res.payParams wx.requestPayment({ timeStamp: pay.timeStamp, nonceStr: pay.nonceStr, package: pay.package, signType: MD5, paySign: pay.paySign, success: () { // 支付成功后清空购物车跳转支付结果页 wx.removeStorageSync(cart) wx.redirectTo({ url: /pages/pay_result/pay_result?orderId${res.orderId} }) }, fail: (err) { // 用户取消或签名错误都会进这里具体看 err.errMsg console.error(pay fail, err) wx.showToast({ title: 支付未完成, icon: none }) this.setData({ submitting: false }) } }) } catch (e) { this.setData({ submitting: false }) } } })逻辑说明submitting 这个布尔量是防止重复下单的开关网络慢的时候用户连续点按钮没有这层拦截会生成多个重复订单。goodsListSnapshot 在提交那一刻固化商品快照后续即使购物车被清空或商品下架订单里的数据依然完整。payParams 是后端根据 openid、商品金额、订单号生成的签名参数小程序端只是透传不要在本地组装这些字段。参数说明wx.requestPayment 的 package 字段必须先经过 encodeURIComponent 编码很多后端 SDK 已经处理过这一步但如果你们后端是 Java 或 PHP 原生拼的前端拿到后要检查是否多转码了一次否则真机上会报 package 参数错误。fail 回调里如果 errMsg 包含 cancel说明用户主动取消支付此时不应该提示网络错误。signType 通常和商户平台配置一致不一定是 MD5也有 HMAC-SHA256必须跟后端对齐。4. 避坑与排查五个高频问题从现象到根因到解决代码能跑是一回事真机跑得稳是另一回事。这套商店代码我在模拟项目 X 里完整跑过一轮也帮 A 同学排查过不少二开引入的问题。下面这五个坑几乎把所有拿到源码后半夜不睡觉的开发者都捞过一遍每一条都按现象、原因、解决来给。4.1 商品图片裂了后台返回的图片 URL 没做协议与域名处理现象开发工具里商品图显示正常真机预览时一部分图片裂开控制台报 downloadFile:fail url not in domain list还有一部分是 http 协议的图点开直接是空白占位。原因图片 URL 有两种问题。后端返回的图片地址可能是 //img.example-store.com/a.jpg 这种协议相对地址真机渲染时拿不到合法的 https 协议头更常见的是图片域名没加到小程序的 downloadFile 合法域名列表里。开发工具默认可以关闭域名校验真机是强制校验的两者表现不一致这也是“开发工具好好的真机全裂了”的经典翻车现场。解决在请求层或者数据映射层保留一个统一的图片地址处理函数给相对路径自动补 https 协议同时把图片域名在小程序后台的「开发管理-服务器域名-downloadFile 合法域名」里配好。这一条最容易被忽略大部分二开者都只配了 request 合法域名忘了图片和视频走的是 downloadFile。4.2 购物车数量改了金额不刷新setData 路径和数据缓存不同步现象购物车里点加减号数量数字变了总价纹丝不动退出页面重新进来金额突然又对了。原因这是两个问题叠在一起。数量字段通过计算属性路径更新了但 calcTotal 读取的可能是 this.data 里的旧缓存数组两者不同步另一处是 onShow 里先 setData 了新数据紧接着 calcTotal 计算完又整体 setData 了一次结果顺序错乱后一次把前面的计算结果覆盖了。解决把 calcTotal 里的读取源统一改为 this.data.items 的同一份引用数量变更后立刻执行重算并从 storage 里重新读出最新值来兜底。要点是“改一条路径就重算一次”不要等到某个生命周期回调里再统一算。我后来写的购物车组件calcTotal 永远只依赖一个统一的 state 对象不存在两份数据打架的问题。4.3 真机支付没反应支付参数签名或异步通知地址不对现象开发工具里点支付弹窗正常拉起真机上一遍又一遍点了没反应console 里出现 errMsg: requestPayment:fail invalid sign。原因微信支付签名细节非常多常见是后端生成 paySign 时参与签名字段顺序不对或者 timeStamp 类型传成了字符串导致真机验签不通过。开发工具对部分参数容忍度较高真机签不过就静默失败。另一个容易被忽略的是支付结果异步通知地址后端没配或没公网可达支付成功但订单状态一直不改。解决先抓一份正常支付请求的参数做前后端字段逐项比对重点看 timeStamp 的值类型、nonceStr 是否同一个字符串、package 是否编码。真机调试时打开 vConsole把 fail 回调里的 errMsg 完整打出来不要只看 toast。后端异步通知地址必须填公网 HTTPS 接口本地联调只能依赖 devtools 里的模拟支付功能。4.4 分享卡片打开是空白页onLoad 的 options 参数丢失或格式错误现象从会话里点别人转发的卡片小程序打开后停留在启动页或者进入商品详情页但数据是空的一片白。原因分享卡片路径是 pages/goods_detail/goods_detail?goodsId123 这种带 query 的地址。问题要么是分享时路径拼错要么是把参数写进了 onShareAppMessage 的 path 里但没编码蹭到中文字符或空格之后真机直接解析失败。页面端如果只在 onLoad 里读了一次 options冷启动时参数延迟也会造成空数据。解决分享的 path 参数统一用 encodeURIComponent 包装后再拼接页面 onLoad 里对 options 里的字段做一次 decodeURIComponent。如果是分享带商品分类或活动来源的建议在 onLoad 和 onShow 里做一次参数合并防止冷启动和热启动场景表现不一致。我自己写分享逻辑时还会在页面里加一个参数为空时的兜底跳转避免白屏没有任何反馈。4.5 安卓端样式错位rpx 与 px 混用导致布局崩掉现象同一套代码 iOS 手机上显示完美安卓真机上商品卡片参差不齐有的边框粗一圈有的文字被截断。原因rpx 是微信小程序的动态单位按屏幕宽度自适应px 是固定像素在安卓设备上物理分辨率差异大混用时会导致盒子模型撑破。更多情况是模板里写了 inline style 用了 px或者 wxss 里写的 border 宽度、字体大小没有统一走 rpx。iOS 和安卓对小数像素的处理机制不同rpx 换算后出现 0.5 像素时安卓会四舍五入iOS 会保留最终看起来就是各种对不齐。解决全项目搜索 wxss 里单独的 px 单位除了 1px 的 border 和某些需要固定物理像素的场景其余一律改成 rpx。注意 iPhone X 底部安全区这类特殊情况要用 env(safe-area-inset-bottom) 配合 constant 兼容而不是靠固定的 px 回避。改完样式后在安卓低端机和 iOS 各过一遍核心页面别只在 iPhone 上调完就提交代码。5. 上线前最后一个技巧用 Mock 数据完整跑通链路再切真实接口拿到代码后最稳的开发顺序是先用这套代码自带的 Mock 数据把页面链路全部跑一遍然后再切真实接口。切接口前我会在 config 里加一个 USE_MOCK 开关让它决定 request 层走本地模拟还是真实网络。这一步的价值在于不用等后端出接口前端先把页面交互、支付弹窗、订单回跳全部验证一遍把问题暴露在前端自己身上。// config.js module.exports { BASE_URL: https://api.example-store.com, USE_MOCK: true }// utils/request.js 开头增加一段拦截 const config require(../config) const mock require(./mock) function request(path, method, data) { if (config.USE_MOCK mock[path]) { // Mock 模式下不发真实网络请求直接返回本地数据 return Promise.resolve(mock[path](method, data)) } return realRequest(path, method, data) }Mock 数据文件里按接口路径组织比如 /goods/list 返回写死的商品数组/order/create 返回一个假的 payParams。订单创建这种接口Mock 模式不需要真的调起支付只要保证 payParams 里每个字段都有值页面渲染不报错即可。开发工具里把「模拟支付」开关打开真机验证支付时再关闭 Mock 切回真实签名。验证清单我一般固定四步第一步 Mock 模式跑通商品列表、购物车、下单、支付结果页全链路第二步切真实接口、关 Mock验证商品图片加载和分页第三步真机调试支付回调第四步清掉所有缓存从分享卡片冷启动一次。从那以后我每接一个商城小程序都会先把这套 Mock 开关做好再开始改业务。支付和分享这两个环节等后端接口齐了再联调时间成本完全不一样。希望帮到你。本文还有配套的精品资源点击获取