简介:面向希望研究微信小程序商店实现方式的开发者与学习者,这套完整代码以微信小程序技术栈搭建商店类应用,可用于理解从界面展示、业务逻辑到配置组织的整体结构,适合具备基础 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 class="card" bindtap="onTap"> <image class="card__cover" src="{{goods.cover}}" mode="aspectFill" lazy-load /> <view class="card__body"> <view class="card__title">{{goods.title}}</view> <view class="card__price"> <text class="card__price--symbol">¥</text> <text class="card__price--num">{{goods.priceText}}</text> </view> </view> <view wx:if="{{showTag && goods.tag}}" class="card__tag">{{goods.tag}}</view> </view>组件里只声明properties和事件,不碰路由也不碰全局状态,这是我自己写组件的硬规矩。父页面这样用:
<goods-card wx:for="{{list}}" wx:key="id" goods="{{item}}" bind:tap="onGoodsTap" />triggerEvent的第一个参数是事件名,父页面用bind:tap接,第二个参数是detail,父页面在e.detail.id里取。注意事件名别用click这种和原生冲突的词,用tap或者自定义的cardtap更稳。properties里的type一定要写,别只给value,类型不对时小程序会在控制台给出警告,早点改比上线后再查省事。
goods.priceText是提前格式化好的字符串,不是原始的分。组件只负责展示,不负责计算,这也是我想强调的一点:把格式化放在 services 或页面层做,组件拿到的永远是能直接渲染的数据,逻辑就不会散。
4.4 样式与适配:rpx、安全区与主题变量
小程序的样式适配基本靠 rpx,750rpx 等于屏幕宽度,设计稿按 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、tabBar | tabBar 页必须在主包 |
| 接真实支付 | 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这三个文件过一遍再动手改业务,先搞清楚骨架和契约,再谈功能。这套习惯帮我省掉了太多「改了这里崩了那里」的时间。希望帮到你。
本文还有配套的精品资源,点击获取