简介面向希望研究微信小程序商店实现方式的开发者与学习者这套完整代码以微信小程序技术栈搭建商店类应用可用于理解从界面展示、业务逻辑到配置组织的整体结构适合具备基础 WXML、WXSS 与 JavaScript 知识、希望参考项目落地的读者。压缩包共 57 个文件、约 131KB主要由 png 界面素材与图标、js 业务脚本、json 页面及全局配置、wxml 页面结构、wxss 样式文件和 md 说明文档组成并含少量系统缓存文件。已有 4042 人学习说明其作为入门参考具有一定关注度。代码涉及商店首页、分类与搜索入口、登录页面等模块可帮助读者对照理解页面路由、组件组织、样式布局和数据请求方式也能为后续补充后端接口、下载记录与用户行为分析提供可扩展骨架。对于想快速把握小程序商店项目目录与开发流程的读者这份轻量代码包便于导入、阅读和二次改造。1. 一套能直接跑起来的小程序商店先看它替你省了哪几步上个月有个开社区生鲜店的朋友来找我说想把货搬到线上问「微信小程序商店完整代码」这种东西到底值不值得拿。我说先别急着从零写去找一套能从头跑到尾的商店代码——首页、分类、详情、购物车、确认订单、订单列表这条链路能跑通骨架立起来之后再决定改哪儿。这份资源就是这样一个底子原生小程序写的商城项目页面齐全数据先用本地 mock 兜住外层套了一层 request 封装页面注册、tabBar、分包、自定义组件、工具函数都摆好了。适合两类人刚摸小程序想照着抄结构的和接了单子需要一个商店底座往上叠业务的。不适合指望开箱就能收款上线的支付和登录态得自己补服务端那一截。2. 目录结构与技术选型原生框架、分包、组件化怎么落地拿到一份商店代码我的习惯是先翻三个文件app.json、app.js、project.config.json。这三个决定了项目能不能跑起来、页面怎么组织、以及在开发者工具里能不能编译。很多人导入项目之后一片白屏八成就是这三处没对上跟业务代码一点关系都没有。2.1 从 app.json 读骨架页面注册、tabBar 与分包app.json 是小程序的骨架表页面路径、tabBar、窗口样式、分包规则全在这个文件里。这份商店项目的配置大致长这样我在原来的基础上补了几行注释{ pages: [ pages/index/index, pages/category/index, pages/cart/index, pages/user/index, pages/goods/detail, pages/order/confirm, pages/order/list ], subPackages: [ { root: packageUser, pages: [ address/list, address/edit, order/detail ] } ], tabBar: { color: #999999, selectedColor: #ff5000, backgroundColor: #ffffff, list: [ { pagePath: pages/index/index, text: 首页, iconPath: static/tab/home.png, selectedIconPath: static/tab/home-on.png }, { pagePath: pages/category/index, text: 分类, iconPath: static/tab/cate.png, selectedIconPath: static/tab/cate-on.png }, { pagePath: pages/cart/index, text: 购物车, iconPath: static/tab/cart.png, selectedIconPath: static/tab/cart-on.png }, { pagePath: pages/user/index, text: 我的, iconPath: static/tab/user.png, selectedIconPath: static/tab/user-on.png } ] }, preloadRule: { pages/user/index: { network: all, packages: [packageUser] } }, window: { navigationBarTitleText: 小店, navigationBarBackgroundColor: #ffffff, navigationBarTextStyle: black, backgroundColor: #f5f5f5 }, style: v2, sitemapLocation: sitemap.json }几个参数值得单独说一下。pages数组的第一项就是启动页顺序不能随便调想改首页就把目标路径挪到第一位别去改代码里的跳转。subPackages里的root是分包目录名分包内的页面路径在跳转时要写成/packageUser/address/list这种带 root 前缀的完整形式只写address/list是找不到的。preloadRule配的是预加载用户进「我的」页时顺手把分包下载下来点地址管理就不会卡一下network: all表示不限网络环境如果你流量紧张可以改成wifi只在 Wi-Fi 下预载。tabBar这里有个容易被忽略的点list最少 2 项、最多 5 项而且 tabBar 页面必须写在主包的pages里放进分包会直接编译报错。图标路径是相对项目根目录的文件格式只认 png、jpg 这些位图单张建议压在 40KB 以内超了在某些机型上会加载不出来。2.2 目录分层pages、components、services、utils 各管什么页面能注册只是第一步真正决定这份代码好不好改的是目录怎么分。这个商店项目的分层是比较典型的做法我按自己的理解重新梳了一遍├── app.js # 全局登录态、购物车角标、全局配置 ├── app.json # 页面注册、tabBar、分包 ├── app.wxss # 全局样式变量与通用类 ├── pages/ │ ├── index/ # 首页轮播 分类入口 商品列表 │ ├── category/ # 分类左侧类目右侧商品 │ ├── cart/ # 购物车勾选、加减、合计 │ ├── user/ # 个人中心订单入口、地址入口 │ ├── goods/detail.js # 商品详情SKU 选择、加购 │ └── order/confirm.js # 确认订单、提交 ├── packageUser/ # 分包地址管理、订单详情等低频页 ├── components/ │ ├── goods-card/ # 商品卡片首页和分类页共用 │ ├── stepper/ # 数量步进器 │ └── empty/ # 空状态占位 ├── services/ │ ├── goods.js # 商品域接口 │ ├── cart.js # 购物车域接口 │ └── order.js # 订单域接口 ├── utils/ │ ├── request.js # 请求封装 │ ├── auth.js # 登录态读写 │ └── format.js # 金额、时间格式化 ├── config/ # 环境地址、常量枚举 └── static/ # 图片、tabBar 图标services这一层是我最看重的。很多新手写商店接口调用直接散在十几个页面的onLoad里改一个字段名要全局搜一遍。按业务域拆成 goods、cart、order 三个文件之后页面只负责拿数据渲染接口地址和参数结构都收在 services 里后端换域名或者字段改名只动一处。utils和config同理前者放无状态的纯函数后者放环境地址和枚举常量别把这些东西硬编码进页面。2.3 选型取舍为什么这套代码压在原生框架上现在做小程序商店摆在前面的选择无非几种原生小程序、跨端框架编译到小程序、或者干脆用 WebView 套 H5。这份代码选的是原生我理解这个取舍方案上手成本包体积性能改造成本原生小程序低会 JS 就能写最小最好组件直出低改哪看哪跨端框架中要装构建链偏大带运行时中等多一层桥接中先编译再看WebView 套 H5高要处理通信小页面在外差白屏和卡顿明显高两端都要改商店这类项目的特点很明确页面不算多交互以列表、表单、图片为主对首屏速度和滑动流畅度敏感。原生框架在这一点上优势最直接商品卡片就是一个自定义组件setData推什么就渲染什么出了问题看堆栈也清楚。跨端框架的好处是能一套代码出多端但代价是构建链、运行时和一堆平台差异补丁对一个只想改改皮肤的商店来说属于用大炮打蚊子。当然原生也有它的短板状态管理基本靠自己多页面共享购物车数据得靠app.globalData加本地缓存手动同步不像框架那边有成熟的状态库。这份代码的做法是「以 Storage 为单一数据源」任何页面改了购物车就写回 Storage进页面时重新读简单粗暴但不容易出错。我在中小规模的商店项目里一般也这么干等业务真复杂到扛不住再上状态管理别一上来就把架子搭得过于宏大。3. 核心链路拆解商品列表、详情、购物车到下单的数据流架构讲完落到代码上。商店这条主链路其实就四段列表拿数据、详情选规格、购物车算钱、下单落订单。每一段都有几个容易翻车的细节我按顺序拆。3.1 首页列表分页、下拉刷新与骨架状态列表页最常见的写法是「一个 onLoad 拉一次onReachBottom 再拉一次」但这里有几个状态必须自己管好不然会出现重复请求、列表重复渲染、到底了还在转圈这些问题。// pages/index/index.js const { request } require(../../utils/request) Page({ data: { list: [], page: 1, size: 10, status: idle, // idle | loading | more | noMore hasMore: true }, onLoad() { this.loadMore() }, // 触底加载下一页 onReachBottom() { this.loadMore() }, // 下拉刷新清空重来不要在原数组上追 async onPullDownRefresh() { this.setData({ list: [], page: 1, hasMore: true, status: idle }) await this.loadMore() wx.stopPullDownRefresh() }, async loadMore() { // 双保险正在请求 或 已经没有更多直接返回 if (this.data.status loading || !this.data.hasMore) return this.setData({ status: loading }) try { const res await request({ url: /goods/list, data: { page: this.data.page, size: this.data.size } }) const nextList this.data.list.concat(res.list || []) this.setData({ list: nextList, page: this.data.page 1, hasMore: nextList.length res.total, status: nextList.length res.total ? more : noMore }) } catch (e) { // 失败要把状态退回去否则用户再也触发不了加载 this.setData({ status: idle }) } } })status这个字段是整个列表的开关loading时拦住一切重复请求noMore时不再触发触底事件页面上根据它切换「加载中 / 上拉加载更多 / 没有更多了」三种文案。page是页码从 1 开始每次成功后自增size是每页条数后端如果限制最大值别超过那个数。hasMore的判断我用的是累加后的总长度和res.total比较而不是直接看当前页有没有返回满因为后端在最后一页可能刚好返回满条数用长度判断才不会漏掉最后一页。下拉刷新那段里setData先清空再请求是有意为之。如果直接在旧数组上concat用户下拉之后会看到旧数据闪一下再变成新数据体验很怪。清空重来虽然会短暂白屏但配合骨架屏反而不违和。提示列表页的onReachBottom只在这个页面没有滚动容器嵌套时才会触发。如果你的页面上半部分是固定区域、下半部分才是scroll-view触底事件要挂在scroll-view的bindscrolltolower上别傻等页面的生命周期。3.2 详情页与 SKU 选择点一次「加入购物车」要判断多少件事详情页看着只是展示真正麻烦的是规格选择。一个商品有颜色、尺寸两维规格组合起来就是若干个 SKU每个 SKU 有自己的价格和库存。用户可能只选了颜色没选尺寸也可能选中的组合压根没货。这套代码的处理方式是把规格组合预先生成好每次点击后重新匹配而不是在原来的结果上加加减减。// pages/goods/detail.js Page({ data: { goods: {}, skuList: [], // [{ id, specs: { color: 红, size: M }, price: 5900, stock: 12 }] selected: {}, // { color: 红, size: M } currentSku: null, canBuy: false }, onSelectSku(e) { const { key, value } e.currentTarget.dataset // 再次点击同一个值视为取消选择 const next { ...this.data.selected } next[key] next[key] value ? : value // 每次都按当前选择全量重新匹配绝不复用上一次的 currentSku const matched this.data.skuList.find(sku Object.keys(next).every(k !next[k] || sku.specs[k] next[k]) ) this.setData({ selected: next, currentSku: matched || null, canBuy: !!(matched matched.stock 0) }) }, onAddCart() { const { currentSku, goods } this.data if (!currentSku) { return wx.showToast({ title: 请先选择规格, icon: none }) } if (currentSku.stock 0) { return wx.showToast({ title: 该规格暂时缺货, icon: none }) } const cart wx.getStorageSync(cart) || [] // 同 SKU 合并数量不同 SKU 追加一行 const existed cart.find(item item.skuId currentSku.id) if (existed) { existed.count 1 } else { cart.push({ skuId: currentSku.id, goodsId: goods.id, title: goods.title, cover: goods.cover, specsText: Object.values(currentSku.specs).join( ), price: currentSku.price, // 单位分 count: 1, stock: currentSku.stock, checked: true }) } wx.setStorageSync(cart, cart) this.updateCartBadge(cart.length) wx.showToast({ title: 已加入购物车, icon: success }) }, updateCartBadge(num) { // tabBar 角标购物车为空时移除 if (num 0) { wx.setTabBarBadge({ index: 2, text: String(num) }) } else { wx.removeTabBarBadge({ index: 2 }) } } })onSelectSku里的dataset拿到的是>// pages/cart/index.js Page({ data: { list: [], checkedCount: 0, totalPrice: 0.00, allChecked: false }, onShow() { // 每次进页面都从 Storage 重新读不信任内存里的旧数据 const list wx.getStorageSync(cart) || [] this.setData({ list }) this.calc(list) }, // 所有变更最后都走这一个函数重新算不做增量累加 calc(list) { const picked list.filter(item item.checked) const totalFen picked.reduce((sum, item) sum item.price * item.count, 0) const count picked.reduce((n, item) n item.count, 0) this.setData({ checkedCount: count, totalPrice: (totalFen / 100).toFixed(2), allChecked: list.length 0 picked.length list.length }) }, onToggle(e) { const index e.currentTarget.dataset.index const checked !this.data.list[index].checked // 用路径写法只更新一个字段避免整个数组重新 setData this.setData({ [list[${index}].checked]: checked }) this.persist() }, onCountChange(e) { const { index, value } e.detail // 步进器组件抛出来的 const item this.data.list[index] const next Math.max(1, Math.min(value, item.stock)) if (next item.count) return this.setData({ [list[${index}].count]: next }) this.persist() }, onToggleAll() { const next !this.data.allChecked const list this.data.list.map(item ({ ...item, checked: next })) this.setData({ list }) this.calc(list) wx.setStorageSync(cart, list) }, // 落盘 重算两个动作绑在一起避免漏掉某一个 persist() { const list this.data.list wx.setStorageSync(cart, list) this.calc(list) } })核心思路是list是唯一数据源任何操作改完list就调一次calc把件数和金额整份重算。十几条购物车数据全量重算的开销可以忽略换来的是「永远不会有对不上的合计」很值。反过来如果你在每次加减时用this.data.totalPrice item.price这种增量方式只要有一个分支漏了更新金额就会越滚越偏而且很难复现。setData用路径写法list[${index}].checked只更新受影响的那一项比整体重新赋值性能好尤其在长列表上。注意路径里的索引是字符串拼接index来自dataset时是数字模板字符串会自动转不用额外Number()但如果你在别的地方直接传参就要小心类型。数量上限我用item.stock卡住这里是把库存快照存在购物车条目里。真实项目里库存是动态的进结算页时还要再向后端校验一次不能只信本地这份快照。3.4 结算与订单从确认页到订单状态流转最后一段是提交订单。这一步要校验的东西最多地址有没有、商品是不是还有货、金额对不对。这份代码在数据层先用本地存储把流程跑通等接后端时把落库那行换成接口调用就行。// pages/order/confirm.js const { request } require(../../utils/request) Page({ data: { address: null, items: [], totalFen: 0, submitting: false }, onLoad() { const address wx.getStorageSync(defaultAddress) || null const cart wx.getStorageSync(cart) || [] const items cart.filter(item item.checked) const totalFen items.reduce((s, i) s i.price * i.count, 0) this.setData({ address, items, totalFen }) }, async onSubmit() { const { address, items, totalFen, submitting } this.data if (submitting) return // 防连点 if (!address) return wx.showToast({ title: 请先选择收货地址, icon: none }) if (!items.length) return wx.showToast({ title: 没有可结算的商品, icon: none }) this.setData({ submitting: true }) try { // 接后端后这一行改成真实下单接口返回 orderNo 和支付参数 const order await request({ url: /order/create, method: POST, data: { addressId: address.id, items: items.map(i ({ skuId: i.skuId, count: i.count })), amount: totalFen } }) // 下单成功后清掉已结算的购物车条目 const rest (wx.getStorageSync(cart) || []).filter(i !i.checked) wx.setStorageSync(cart, rest) wx.redirectTo({ url: /pages/order/list?highlight${order.orderNo} }) } catch (e) { wx.showToast({ title: e.msg || 下单失败请重试, icon: none }) } finally { this.setData({ submitting: false }) } } })submitting这个布尔量是必须的。用户手快连点两下如果不拦就会生成两笔订单退款和客服处理起来很麻烦。用redirectTo而不是navigateTo也是有意为之订单提交完之后用户按返回键不应该再回到确认页重新提交一次redirectTo会把当前页替换掉返回就直接回购物车。金额amount是前端算好传给后端的但后端一定要自己按 SKU 价格再算一遍做校验前端传的数字只能当参考值。这是常识但我在真实项目里见过好几回直接信前端金额的实现属于典型的给自己埋雷。订单状态我用几个字符串常量表示UNPAID、PAID、SHIPPED、FINISHED、CANCELLED。状态流转放在服务端控制前端只负责根据状态渲染不同的按钮组别在前端写「点一下就改状态」的逻辑否则订单列表刷新一次就会露馅。4. 公共能力封装request、登录态、组件与样式适配主链路跑通之后剩下的活基本都落在公共层。这部分代码写得好不好直接决定后面加功能的效率。我见过太多项目页面写得还行但请求裸调wx.request调了三十处改个超时时间要全局替换。4.1 request 封装统一域名、错误码与 loading// utils/request.js const BASE_URL https://api.example.com // 换成自己的接口域名 function request(options {}) { const { url, method GET, data {}, header {}, loading true, timeout 10000 } options if (loading) wx.showLoading({ title: 加载中, mask: true }) return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method, data, timeout, header: { content-type: application/json, X-Token: wx.getStorageSync(token) || , // 登录态统一透传 ...header }, success(res) { if (res.statusCode ! 200) { return reject(new Error(HTTP ${res.statusCode})) } const body res.data || {} if (body.code ! 0) { wx.showToast({ title: body.msg || 请求失败, icon: none }) // 401 统一跳登录页面里就不用每处都写 if (body.code 401) { wx.reLaunch({ url: /pages/user/index }) } return reject(body) } resolve(body.data) }, fail(err) { wx.showToast({ title: 网络开小差了, icon: none }) reject(err) }, complete() { if (loading) wx.hideLoading() } }) }) } module.exports { request }BASE_URL单独提出来放顶部接不同环境时只改这一行或者干脆挪到config里按环境读取。loading参数默认为 true但列表下拉刷新、轮播轮询这类场景要显式传 false否则全局 loading 会一闪一闪很脏。timeout给 10 秒弱网下别设太短短了用户看到的全是「网络开小差了」。错误处理分了三层HTTP 状态码不是 200 直接 reject业务码code ! 0弹 toast 并 reject同时把 401 抽出来统一跳登录页网络层fail兜底提示。业务层调用方只需要try/catch不用再关心这些分支。这种把「业务码」和「HTTP 码」分开处理的约定前后端一定要在接口文档里写死不然联调阶段能来回扯半天。4.2 登录态wx.login 拿到 code 之后该做什么登录是小程序商店绕不开的一块。流程本身不复杂调wx.login拿临时 code把 code 发给自己的服务端服务端去换openid和session_key生成自己的 token 返给前端前端存起来后续请求带上。// utils/auth.js const { request } require(./request) function login() { return new Promise((resolve, reject) { wx.login({ success: async ({ code }) { if (!code) return reject(new Error(未获取到 code)) try { // 服务端拿 code 换 openid并签发自己的 token const res await request({ url: /auth/login, method: POST, data: { code }, loading: false }) wx.setStorageSync(token, res.token) wx.setStorageSync(openid, res.openid) resolve(res) } catch (e) { reject(e) } }, fail: reject }) }) } function ensureLogin() { const token wx.getStorageSync(token) if (token) return Promise.resolve(token) // 并发调用时可能触发多次登录这里用单例锁住 if (!ensureLogin._pending) { ensureLogin._pending login().finally(() { ensureLogin._pending null }) } return ensureLogin._pending } module.exports { login, ensureLogin }ensureLogin里的单例锁是个小技巧。页面onLoad里经常好几个请求同时发出去如果每个都先判断有没有 token再各自调一次登录就会出现三次wx.login并发的情况服务端拿到的 code 可能互相覆盖。加个_pending把首次登录挂住其他调用方等同一个 Promise问题就没了。用户的昵称头像这类信息现在不建议一上来就弹授权框。常规做法是等用户真正点到「我的订单」「收货地址」这些需要身份的地方再引导授权体验上顺很多。4.3 自定义组件商品卡片与数量步进器商品卡片在首页、分类页、搜索结果页都会用不抽组件就得复制三份 WXML改一次样式改三处。这份代码抽了两个组件卡片和步进器。// components/goods-card/index.js Component({ properties: { goods: { type: Object, value: {} }, showTag: { type: Boolean, value: true } }, methods: { onTap() { // 组件不自己跳页面把点击抛给父页面方便在不同场景复用 this.triggerEvent(tap, { id: this.data.goods.id }) } } })!-- components/goods-card/index.wxml -- view classcard bindtaponTap image classcard__cover src{{goods.cover}} modeaspectFill lazy-load / view classcard__body view classcard__title{{goods.title}}/view view classcard__price text classcard__price--symbol¥/text text classcard__price--num{{goods.priceText}}/text /view /view view wx:if{{showTag goods.tag}} classcard__tag{{goods.tag}}/view /view组件里只声明properties和事件不碰路由也不碰全局状态这是我自己写组件的硬规矩。父页面这样用goods-card wx:for{{list}} wx:keyid goods{{item}} bind:taponGoodsTap /triggerEvent的第一个参数是事件名父页面用bind:tap接第二个参数是detail父页面在e.detail.id里取。注意事件名别用click这种和原生冲突的词用tap或者自定义的cardtap更稳。properties里的type一定要写别只给value类型不对时小程序会在控制台给出警告早点改比上线后再查省事。goods.priceText是提前格式化好的字符串不是原始的分。组件只负责展示不负责计算这也是我想强调的一点把格式化放在 services 或页面层做组件拿到的永远是能直接渲染的数据逻辑就不会散。4.4 样式与适配rpx、安全区与主题变量小程序的样式适配基本靠 rpx750rpx 等于屏幕宽度设计稿按 750 宽出图的话数字可以直接抄。但有两个地方容易翻车一是和 px 混用字体用 px、间距用 rpx在不同机型上比例会飘二是底部安全区iPhone 那种带横条的机型底部固定按钮会被挡住。/* app.wxss */ page { --brand: #ff5000; --text-main: #333333; --text-sub: #999999; --bg-page: #f5f5f5; background: var(--bg-page); font-size: 28rpx; color: var(--text-main); } /* 底部固定操作栏给安全区留位置 */ .footer-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 16rpx 24rpx; padding-bottom: calc(16rpx constant(safe-area-inset-bottom)); padding-bottom: calc(16rpx env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06); }主题色用 CSS 变量挂在page上改色只动一行。做二次开发换品牌色的时候这个设计能省不少事总比全局搜索#ff5000强。env(safe-area-inset-bottom)那两行是写了两遍的constant()是给老版本的兜底新版本认env()两个一起写不会打架。字号我统一用 rpx虽然有人主张字体用 px 更稳但对商店这种以图文卡片为主的界面rpx 的一致性更好也省得为不同尺寸单独写媒体查询。5. 从导入到真机运行步骤与常见问题排查代码看懂了接下来是把它跑起来。这一步看着简单实际上新手卡住的时间往往比读代码还长。5.1 四步跑通导入、配置、域名、预览第一步打开开发者工具选「导入项目」目录一定要选到app.json所在的那一层。很多人选到了外层文件夹工具会提示找不到app.json就是这个原因。第二步填 AppID。没有正式 AppID 就选测试号够本地调试用。如果项目里project.config.json写死了别人的 AppID导入时会被覆盖或者提示无权限改成本地测试号即可。第三步处理接口域名。开发阶段可以在「详情 → 本地设置」里勾上「不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书」这样本地 mock 或者 http 接口都能跑。但真机预览时这个开关不生效接口必须是 https 且在小程序后台配置过。第四步编译预览。点「编译」看首页能不能出来点「预览」扫码在真机上看一遍。真机这一步别省模拟器和真机的差异比想象中大尤其是图片加载和底部安全区。5.2 排查清单五个最容易翻车的点现象原因解决导入后一片空白控制台报找不到 app.json导入时选的目录层级不对选到了外层重新导入目录选到 app.json 同级tabBar 图标不显示或编译报 iconPath 不存在路径写错、用了 jpg 之外的格式、单张超过 40KB核对相对路径统一用 png压缩到 40KB 以内请求报「不在以下 request 合法域名列表中」域名没配或者用了 http开发阶段勾选不校验域名上线前在小程序后台配好 https 域名真机上列表空白模拟器正常模拟器放行了 http 和未备案域名真机更严格检查接口协议是否 https域名是否已配置购物车勾选只影响第一项或视图不更新直接改了数组元素没走 setData或者整体赋值覆盖了其他项用this.setData({ [list[ i ].checked]: v })路径写法再补两个不在表里但很常见的。一个是分包页面跳不过去报「页面不存在」多半是路径没带 root 前缀写成/packageUser/address/list就好了另一个是wx:key用了index列表增删之后勾选态错位把wx:key换成数据里的唯一 id 就能解决。这两个坑我自己都踩过尤其是wx:key用索引在购物车这种会删除元素的列表上必翻车。6. 二次开发把一套通用商店改成自己的业务代码跑起来之后真正的活才开始。这份商店是个通用骨架落到具体业务上改动通常集中在四个地方我按动手顺序说一下。改造点涉及文件注意点换接口地址config/index.js、utils/request.js只改配置别在页面里改换品牌色和字体app.wxss里的 CSS 变量变量名不动只换色值增删页面app.json的 pages、tabBartabBar 页必须在主包接真实支付services/order.js、确认页支付参数必须由服务端下发换数据源是最先要做的。把services里各个函数的 mock 返回换成真实请求页面代码一行都不用动这是分层带来的好处。我给个 goods 服务的改法// services/goods.js const { request } require(../utils/request) // 列表把 dist 字段转成页面能直接渲染的格式页面不做任何计算 async function getGoodsList({ page 1, size 10 } {}) { const res await request({ url: /goods/list, data: { page, size } }) return { total: res.total, list: (res.list || []).map(item ({ ...item, priceText: (item.price / 100).toFixed(2) })) } } async function getGoodsDetail(id) { const res await request({ url: /goods/detail, data: { id } }) return { ...res, skuList: (res.skuList || []).map(sku ({ ...sku, priceText: (sku.price / 100).toFixed(2) })) } } module.exports { getGoodsList, getGoodsDetail }所有格式化都在这层做完页面拿到的priceText直接上屏。等哪天要加「划线价」「会员价」只需要在这里多算一个字段页面模板跟着加一行不用满项目找哪里在用分。接支付这块得多说一句。小程序的支付需要自己的商户资质代码里能做的只是「拿到服务端下发的支付参数后调起支付面板」签名、下单这些必须在服务端完成前端拿着金额自己去拼参数是绝对不行的。我一般会把支付单独封一个pay(orderNo)的函数内部走服务端下单拿参数、调wx.requestPayment、再回调服务端确认三步里任何一步失败都要留好重试入口。还有一个技巧值得记一下改主题色的时候先在app.wxss里把所有颜色相关的值都提成变量然后再改。如果原代码里颜色是硬编码散在各处的就先用查找功能统计一遍出现次数集中在一次改完别今天改首页明天改详情改到一半忘了哪些改过最后页面颜色对不上排查起来极度难受。从那以后我每次拿到一套别人写的商店代码都会先把app.json、services、app.wxss这三个文件过一遍再动手改业务先搞清楚骨架和契约再谈功能。这套习惯帮我省掉了太多「改了这里崩了那里」的时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?