前阵子帮朋友的小餐馆做了一个微信小程序外卖点餐系统,从注册账号、搭项目骨架到核心点餐流程跑通,整个过程踩了不少坑。这篇文章就是那次实践的项目笔记,把从0到1实现一个外卖点餐系统小程序的完整思路和关键代码整理出来——包括项目该怎么做技术选型、登录和token怎么处理、商品和购物车数据怎么设计,还有真机调试和上线审核阶段容易踩的那些雷。想做微信小程序项目练手、或者给实体店做点餐系统的同学,可以直接照着这套思路来。
1. 动手之前,先搞懂外卖小程序到底要做什么
1.1 一份外卖作业的完整功能清单
我在开始写代码之前,先花了半天时间把外卖点餐的用户流程走了一遍。用户进店之后要干什么?看菜品、选菜、加购物车、下单、付钱、等外卖。如果把这个流程拆成可以实现的模块,大概是这样:
- 首页:店铺信息、公告、热销推荐,承担"门面"的角色;
- 菜单分类:左侧分类、右侧菜品列表,这是点餐效率的核心;
- 购物车:加购、减购、清空、实时计算金额;
- 结算下单:确认订单信息、填写收货地址、提交订单;
- 订单管理:待支付、制作中、配送中、已完成等状态查看;
- 个人中心:用户信息、地址管理、订单记录、客服与售后入口。
这还只是用户端。站在商家角度,还需要一个后台来维护菜品、接收订单、修改订单状态。但做从0到1的项目,一定要做减法:先做用户端这六个模块,商家端用一个简单的后台或者数据库直接改数据来过渡。我见过很多人一开始就想做商家管理后台、骑手端、大数据分析,最后连点餐主流程都没走通——这是项目失败最常见的原因。
1.2 技术选型:原生微信小程序、uni-app 还是云开发
先说结论:我建议,如果只是学小程序、做单端项目,直接用原生微信小程序开发,不要一上来就上跨端框架。当时我做一个简单的对比:
| 方案 | 适用场景 | 我在意的点 |
|---|---|---|
| 原生微信小程序 | 只做微信端、想深入理解小程序机制 | 调试直接、无编译层、文档最好查 |
| uni-app / Taro | 需要同时出微信、支付宝、H5、App | 多端复用,但多一层框架转换,遇到问题要会区分"框架bug"还是"小程序bug" |
第二个关键选择是后端。对于外卖点餐来说,小程序前端只是半个系统,没有后端就没有菜品数据、没有订单存储。两种主流方案:云开发和自建后端。新手和做产品原型,推荐用云开发,云函数写接口、云数据库存数据,免运维,域名都不用买。如果你想锻炼全栈能力,或者项目后面要接商家端、要自由扩展,就可以用 Node.js 或者 PHP 自建后端。我当时选了原生小程序加 Node.js 自建后端,原因很简单:这个项目的目标不是最快上线,而是把前后端交互链路完整吃透。如果你最终选了云开发,也不影响这篇文章后续对登录、商品、购物车、订单这些数据链路的理解,只是把"后端接口"换成了"云函数"。
1.3 项目边界:先跑通核心闭环,再考虑支付和配送
外卖点餐绕不开两件事:支付和配送。但是这两个功能在MVP阶段都不要做。微信支付要求小程序主体必须是企业、个体工商户等非个人主体,个人开发者无法开通;就算有企业资质,支付接入还需要商户号、证书、回调地址,这已经是一个独立工程。配送也一样,接第三方配送平台要商务合作和费用,自配送又要做骑手端。所以第一个版本,我把闭环定义为:用户浏览菜单 → 加入购物车 → 提交订单 → 订单写入数据库 → 后台看到订单。用户订单状态先停留在"待支付/已提交",后续再对接支付和配送。
2. 项目初始化:注册、工具链与目录分层的正确姿势
2.1 注册小程序账号与开发者工具
打开微信公众平台注册小程序。注册时有两点要注意:一是主体类型,个人和企业的权限差异很大,个人主体无法开通微信支付,也无法上架部分类目;二是邮箱不能重复注册。注册好后,在"开发-开发设置"里拿AppID。后续在微信开发者工具里新建项目时选"小程序",填入AppID。工具建议下载稳定版,别追beta版,我遇到过beta版自带一堆插件兼容问题。还有一个经常被问的问题:"基础库版本从哪设置"——开发者工具右上角"详情-本地设置-调试基础库"可以切换调试基础库;后台的"设置-基本设置"里可以设置最低基础库版本。平时调试用新版没问题,但上线前最好把最低版本按官方建议设置好,避免用户基础库过低导致API不生效。
2.2 目录分层:别把代码全堆在 pages 里
很多新手项目,所有页面放在pages、所有方法写在各页面的index.js里,两三百行还能看,五百行就开始痛苦了。外卖小程序页面多、交互多,我的做法是预先分层,目录结构大概是这样的:
miniprogram/ ├── app.js // 全局逻辑、登录态初始化 ├── app.json // 全局配置:页面路径、tabBar、窗口样式 ├── app.wxss // 全局样式变量 ├── pages/ │ ├── menu/ // 点餐页:左侧分类、右侧商品 │ ├── cart/ // 购物车页(或点餐页内侧滑面板) │ ├── order/ // 订单列表 │ └── mine/ // 个人中心 ├── components/ │ ├── goods-card/ // 商品卡片组件 │ └── number-box/ // 加购减购数量组件 ├── api/ │ └── request.js // 统一请求封装 ├── utils/ │ └── format.js // 价格格式化、时间格式化等 └── assets/ └── images/这样分层的核心好处是:页面只负责页面逻辑和交互,数据请求在api层,可复用的UI封装成组件。特别是goods-card和number-box,菜单页、购物车、订单详情都可能用到,抽成组件后一处修改、处处生效。注意components里每个组件四个文件(js/json/wxml/wxss),需要在组件的json里声明"component": true,页面使用前再在页面的json里用usingComponents引用。
2.3 app.json 全局配置:tabBar、窗口与第一屏
小程序启动后读的第一个文件就是app.json,页面路径、窗口样式、tabBar都在这里配置。我配了三个tab:点餐、订单、我的。配置代码大致是这样的:
{ "pages": [ "pages/menu/index", "pages/order/index", "pages/mine/index" ], "window": { "navigationBarTitleText": "xx外卖", "navigationBarBackgroundColor": "#ff6b35", "navigationBarTextStyle": "white", "backgroundColor": "#f7f7f7" }, "tabBar": { "color": "#999", "selectedColor": "#ff6b35", "list": [ { "pagePath": "pages/menu/index", "text": "点餐" }, { "pagePath": "pages/order/index", "text": "订单" }, { "pagePath": "pages/mine/index", "text": "我的" } ] } }注意两个细节:一是pages数组第一项是启动首页,小程序页面路径都必须在这里注册;二是tabBar的pagePath必须在pages数组中,并且tabBar页面建议不用自定义导航栏,否则会出现胶囊和导航栏重叠计算的问题。navigationBarTextStyle只支持black/white两种值,背景色要跟导航栏文字颜色搭配好,否则状态栏会糊成一片。
3. 登录链路:code 换 token 的完整闭环
3.1 登录不是一个"获取用户名密码"的过程
很多第一次做小程序的人会习惯性地想:登录嘛,做一个账号密码输入页。在小程序里,主流方案是微信授权登录。用户打开小程序,微信就能作为身份提供方,不需要用户输入用户名密码。核心API是wx.login。整个链路如下:
- 前端调用wx.login(),微信返回一个临时code;
- 前端把code发给自己的后端;
- 后端拿code + appid + appsecret,调用微信的接口(code2Session)换取 openid 和 session_key;
- 后端用 openid 在数据库里找到或创建用户,生成自己的登录凭证token;
- 后端把token返回给前端;
- 前端把token存到storage,之后所有请求都带上这个token。
这里有一个新手最容易踩的坑:code2Session 的调用必须有 appsecret,而 appsecret 一旦出现在前端代码里,就相当于把账号密码贴在了门口。所以这个接口只能在后端调用,前端永远只拿code。
3.2 openid、session_key、token 各管什么
openid是用户在当前小程序里的唯一ID,拿到它你就能把订单、购物车、地址跟用户关联起来;session_key是微信会话密钥,主要用来解密手机号等敏感数据,它不应该下发到前端。token则是你自己后端发给小程序的"通行证",里面可以带user_id和过期时间,也可以做成无状态的JWT。我的做法是:后端收到code后,如果查不到openid就创建一个用户,如果查到就直接取用户;然后生成token返回。前端不关心openid是谁,它只需要把token管好。
注意:session_key 是会过期的,如果你将来要解密手机号,不能缓存旧session_key,必须从新的 code 换取。另外,手机号快速填写的API现在也需要企业认证,个人主体用不了,所以第一版地址管理就让用户手动填写。这一点在项目规划时就要考虑到,否则做到后面才发现能力受限就得返工。
3.3 请求封装:token、状态码、loading 统一管起来
为了让所有页面不重复写网络请求的样板代码,我封装了一个request函数:
const BASE_URL = 'https://your-api.example.com' const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') wx.request({ url: `${BASE_URL}${url}`, method, data, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 401) { handleTokenExpired() return } if (res.data && res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res.data) } }, fail: () => { wx.showToast({ title: '网络异常', icon: 'none' }) reject(new Error('network error')) } }) }) } module.exports = { request }接口返回报文统一用{ code, data, msg },code为0表示成功。这样前端处理逻辑会非常清爽。handleTokenExpired 里做静默重新登录:先调用 wx.login 换新 code,再请求后端换新 token,然后重新执行失败的请求。外卖场景下用户可能使用很久,token过期时不要让用户重新走一遍登录,体验会好很多。
这里还要说一个实践细节:不要把 wx.showToast 放在每个页面都写一遍。封装层统一处理错误提示,页面只关心数据,这样能少写很多重复代码。对于需要loading的接口,也可以在request里加一个可选参数,自动管理showLoading和hideLoading,页面不用自己去配对调用。
4. 商品与购物车:点餐主流程的数据设计
4.1 菜单数据结构与两种加载策略
点餐主流程的数据主要是分类和商品。我的表结构大致是:
- category: id, name, sort
- product: id, category_id, name, price, image, stock, sales, status
前端页面的核心数据结构是一个数组:
menuData: [ { id: 1, name: '热销', products: [ { id: 101, name: '招牌卤肉饭', price: 22, image: '', stock: 50 } ]} ]两种加载策略:
- 一次性加载所有分类和商品,前端本地做筛选。适合菜品几十个以内的小店,用户切分类时秒开,无loading闪烁;
- 点击分类时按category_id请求,适合上百个菜品的餐厅,但每次切换都有网络等待,需要做loading状态。
外卖点餐MVP我建议用第一种。后续菜品多了再改成懒加载,改动的点集中,不会推翻整体设计。
4.2 购物车:本地状态管理 + setData 性能优化
购物车在小程序里怎么存?我的建议是:未提交订单之前,购物车完全维护在前端本地,用storage做持久化。这个设计有一个很实际的理由:用户可能加了几样菜,退出小程序再回来,购物车还在,体验会好很多。数据结构用对象而不是数组:
data: { cart: { 101: 2, // 商品id: 数量 102: 1 } }对象的好处是:按商品id更新数量时不需要遍历数组,改起来快。这里分享一个setData的性能细节。新手容易写成每次加购都把整个menuData重新setData一遍,菜品一多就会卡顿、掉帧。正确的做法是精确更新路径:
handleAdd(e) { const { id } = e.currentTarget.dataset const current = this.data.cart[id] || 0 this.setData({ [`cart.${id}`]: current + 1 }) }利用数据路径语法,只更新一个键值。设计number-box组件时,点击加号从外层的自定义事件往上抛,不要在子组件里直接修改全局数据,这样数据流是单向的,好排查问题。购物车底部栏的金额也可以通过计算属性在页面里统一算,不要在多个方法里各算一遍,否则很容易出现数字对不上的bug。
4.3 订单状态机与下单接口设计
下单时,前端把购物车清单传给后端,后端做三件事:校验库存、用服务端价格重新计算金额、生成订单。这里必须强调:永远不要信任前端传过来的金额。页面金额是给用户看的,真正的金额必须用服务端商品表里的价格来算,否则用户改一下请求数据就能低价下单。订单表核心字段:
- order_id(主键/订单号)
- user_id(下单用户)
- goods_list(冗余商品快照:商品名、单价、数量、图片)
- total_amount(服务端计算的总额)
- status(状态码)
- create_time / pay_time / finish_time
订单状态我习惯用数字,方便后端排序和计算:
| 状态值 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 待支付 | 用户取消、支付 |
| 1 | 已支付/制作中 | 商家接单或出餐 |
| 2 | 配送中 | 查看配送状态 |
| 3 | 已完成 | 评价、再来一单 |
| -1 | 已取消 | 无 |
前端只需要一个状态映射对象,显示时把数字翻译成文案。设计状态机时只定义合法的流转路径,例如0到1到2到3,非法跳转会直接报错,这样后端就不会出现状态混乱的问题。商品快照字段尤其重要,因为商家改价或下架菜品后,历史订单仍然要展示当时的价格和名称,快照就是把下单那一刻的商品信息固定下来。
5. 踩坑与上线:真机调试、适配与审核
5.1 真机调试请求不到后端:完整的排查链路
这个坑几乎人人都会遇到。后台接口在开发者工具里能通,预览到手机上就失败。我是按这个顺序排查的,一步步来:
- 看报错提示。开发者工具的Network面板如果显示fail,多半是域名校验或网络层问题;如果有statusCode,比如404/500,说明请求已经到了后端,问题在后端逻辑。
- 排查域名。正式环境小程序要求后端必须HTTPS,并且要在小程序后台配置request合法域名。本地调试时可以临时在开发者工具里勾选"不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书",但注意这只能解决工具里的问题,真机预览还需要在手机上打开调试模式。
- 排查地址。如果你用本地电脑后端调试,真机访问不到localhost。手机和电脑必须连同一个局域网,而且接口地址要写你电脑的内网IP,比如192.168.x.x:3000,不能用127.0.0.1。这一步卡了我一下午。
- 排查防火墙。电脑防火墙没放行对应端口,手机依然访问不到。Windows上把Node.js或对应端口设为允许入站即可。
- 排查跨域?小程序wx.request不存在浏览器里的跨域限制,如果报错"url not in domain list"那是域名校验问题,不是CORS问题。很多前端同学在这里被误导。
按这个顺序排查,基本十分钟定位。我当时遇到的问题就出在第三步,接口地址写成了localhost,换成局域网IP立刻就好了。
5.2 顶部导航栏与安全区:不同机型的适配
小程序默认导航栏是系统渲染的,有胶囊按钮,标题居中。但如果要自定义导航栏(比如点餐页想要沉浸式头图),就要自己计算导航栏高度。导航栏总高度等于状态栏高度加菜单按钮高度加上下间隙。可以通过wx.getWindowInfo()和wx.getMenuButtonBoundingClientRect()拿到:
const windowInfo = wx.getWindowInfo() const menuRect = wx.getMenuButtonBoundingClientRect() const statusBarHeight = windowInfo.statusBarHeight const navHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height这个navHeight就可以用来设置自定义导航栏的占位高度。底部安全区也一样,iPhoneX之后的机型有Home条,要让购物车结算按钮避开底部,在wxss里写:
.settle-bar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }这两行代码对点餐页的底部购物车尤其重要,否则结算按钮会被Home条挡住一半。真机调试时多拿几台不同机型的手机试一下,尤其是有横条的老款全面屏。
5.3 基础库版本与API兼容性
开发工具里跑得好好的API,到了用户手机上没反应,多半是基础库版本太低。基础库相当于小程序的运行时,不同版本支持的API不一样。我建议在后台设置最低基础库版本时,先看微信官方的版本占比数据,选一个能覆盖95%以上用户的最低版本。代码里对较新的API用wx.canIUse()做判断,例如:
if (wx.canIUse('getWindowInfo')) { const info = wx.getWindowInfo() } else { const info = wx.getSystemInfoSync() }注意wx.getSystemInfoSync在2022年底已经被标记为废弃,新项目直接用getWindowInfo,但为了兼容低版本还是要做能力判断。另外,不要在线上版本贸然把最低基础库版本调到最新,"能覆盖绝大多数用户"比"用上最新API"更稳妥。
5.4 审核提交最容易被拒的几个点
小程序提审时最怕的不是功能简陋,而是触犯平台规则。结合这次经历,几个高频风险点:
- 类目与内容不符。做外卖点餐,需要选择符合平台规范的类目,个人主体没有食品经营许可类资质的话,上"餐饮服务"类目会被拒。可以先选"工具-信息查询"这类允许的服务类目提交审核,但项目说明要写清楚是演示用途,否则会被驳回。
- 要有可用数据。审核人员打开小程序,至少要能看到真实的商品和分类,不能是空页。我提审前专门造了一批完整测试数据,包括菜品图、价格、分类、库存。
- 隐私协议。现在小程序收集用户信息(包括头像、昵称、手机号)必须要有隐私弹窗和《隐私政策》。不弹窗,审核不通过。这个要在开发初期就加上,不要拖到提审前一天再补。
- 不要诱导分享。外卖小程序里常见的"分享好友得红包"很容易被判为诱导分享,MVP阶段先别做。
- 页面可用性比功能多少重要。审核最怕遇到点了半天没反应的死链,宁可功能少而精,别放占位按钮。
最后分享一个实际感受。做这个外卖点餐小程序,最难的不是某个具体API,而是把用户流程、数据结构、状态流转统一想清楚。如果时间重来,我会更早地把订单状态机画出来,更早接真机调试,而不是在模拟器里自我感觉良好。后续想扩展的话,可以加商家后台、订阅消息通知、优惠券系统,甚至对接支付后把订单闭环真正跑完。希望这篇笔记能帮你少走一些弯路。