
1. 项目概述与前期准备1.1 为什么我选了外卖点餐系统当练手项目做微信小程序开发最怕的就是“学了语法但没做过完整项目”。我见过太多人把组件文档刷了三遍真到要独立从零写一个东西时还是不知道该把文件放哪、怎么组织逻辑。所以我一直建议新手用外卖点餐系统作为第一个完整项目——它不是最酷炫的但它是麻雀虽小五脏俱全的代表有列表、有联动、有复杂状态管理购物车还要处理后端通信和支付流程。你把它啃下来再去写电商、社交、工具类小程序思路基本都是通的。外卖点餐系统的核心链路很清晰用户浏览店铺和商品——加购商品——提交订单——支付——商家接单。这个链路把所有关键环节都串起来了而每个环节都有独立的技术难点。比如商品分类和列表的联动、购物车的增删改查、订单状态在不同页面间的同步这些不是翻文档能会的必须亲手做一遍才能理解里头的坑在哪。这篇博文会从项目结构设计、核心功能实现、后端对接、到上线审核的完整流程全部走一遍代码部分会给出关键片段的完整实现上下文相关的细节我会用文字说明。项目本身我建议你用自己的方式搭一遍光看不写永远理解不了为什么购物车数据要用全局变量而不能只用局部变量。1.2 技术选型原生小程序还是框架这里先回答一个很多人纠结的问题用原生还是 uni-app/Taro我的建议是练手项目务必用原生。原因有三第一原生的调试体验最直接报错信息最清晰你能借此理解微信小程序的底层机制第二原生框架的组件和 API 是其他框架封装的基础你先把原生搞明白了再看 uni-app 的跨端封装会豁然开朗第三外卖点餐系统的复杂度不高原生足以覆盖没必要引入额外依赖增加学习成本。但这并不是说框架不好。如果后续你想同时发布到支付宝小程序、抖音小程序那 uni-app 和 Taro 值得考虑。只不过在从 0 到 1 这个阶段请把原生吃透。本项目我采用的是原生小程序 JavaScript没有引入 TypeScript。原因一是很多新手对 TS 还不太熟二是点餐系统的类型复杂度不高JS 足够。后端部分为了省去服务器运维的麻烦我用了微信小程序云开发云函数 云数据库你不需要自己买服务器和域名也能跑通全流程。若你自己有服务器用 PHP/Java/Node 写接口也是一样的思路前端部分不受影响。2. 整体设计从需求到页面2.1 功能模块拆解外卖点餐系统的用户端核心功能就五个模块首页店铺信息、点餐页分类菜品的联动、购物车悬浮底部栏、订单页订单列表与详情、我的个人信息。这是用户能感知到的功能但如果只盯着这些页面写代码你会发现订单状态没人管理、商品库存变化没人检测、支付回调不知道在哪处理。所以我做项目的第一步通常不是打开编辑器而是画功能图和数据流。把后台管理的需求也考虑进来商家需要在后台维护商品、分类、接单状态后台管理可以用同一个云开发控制台搞定也可以做一个简单的商家端页面。这个项目我把重心放在用户端商家端用云控制台手动改数据库模拟。从用户视角出发一个完整对外卖的流程是这样的进入小程序的点餐首页看到商家的基础信息店名、公告、起送价、配送费进入点餐页左侧是商品分类右侧是菜品列表顶栏是商家信息选择菜品规格比如辣度、加料、数量加入购物车查看购物车调整数量确认下单确认订单信息收货地址、备注、配送时间提交订单进行微信支付查看订单列表和订单详情关注订单状态这七个步骤对应到代码里就是五个页面加三个逻辑层购物车状态管理、订单状态管理、用户信息管理。页面不复杂复杂的是状态如何在页面间共享。2.2 数据表设计和字段规划在动手写代码前先设计好云数据库的集合结构避免做到一半发现字段不够用。users 集合用户表字段名类型说明_openidstring微信用户的唯一标识云开发自动生成nicknamestring昵称avatarUrlstring头像phonestring手机号如果用户授权addressListarray收货地址列表每个元素包含 name、phone、address、detail、isDefaultcategories 集合分类表字段名类型说明namestring分类名称比如「热销」「主食」「小吃」sortnumber排序号越小越靠前products 集合商品表字段名类型说明categoryIdstring所属分类的 IDnamestring商品名称imagesarray图片路径列表originalPricenumber原价单位分pricenumber现价单位分salesnumber月售量descriptionstring商品描述specsarray规格列表如 [{ name: 标准, priceAdd: 0 }, { name: 大份, priceAdd: 300 }]isSoldOutboolean是否售罄orders 集合订单表字段名类型说明orderNostring订单号uidstring用户 openiditemsarray商品列表快照[{ productId, name, specName, price, quantity, image }]totalPricenumber商品总价分deliveryFeenumber配送费分packageFeenumber打包费分actualPricenumber实付金额分statusnumber订单状态0待支付、1已支付待接单、2已接单制作中、3配送中、4已完成、5已取消addressInfoobject收货人信息快照remarkstring用户备注createTimedate下单时间payTimedate支付时间finishTimedate完成时间要注意的是订单里的items和addressInfo必须用快照方式存储也就是把下单那一刻的商品名称、价格、规格和地址信息全部复制一份放进订单。不要只存 productId 再关联查询因为商品可能在下单后改价或下架用户看到的历史订单应该保持当时的信息。这是做交易系统的一个基本原则历史数据不可变。2.3 页面架构设计项目的页面结构如下pages/ ├── index/ // 首页店铺信息 公告 ├── shop/ // 点餐页分类 商品 购物车 ├── cart/ // 购物车确认页 ├── order-confirm/ // 订单确认页 ├── order-list/ // 订单列表页 ├── order-detail/ // 订单详情页 ├── mine/ // 个人中心页 ├── address-edit/ // 地址编辑页 └── address-list/ // 地址列表页底部 TabBar 挂三个页面首页index、订单列表order-list、我的mine。点餐页shop不放进 TabBar因为用户应该从首页点击进入店铺点餐体验上更接近真实外卖 App。订单确认页和地址页也不放在 TabBar 里这些是流程页面用wx.navigateTo跳转即可。我见过很多新手把所有页面都放进 TabBar 目录这会导致两个问题一是用户可以通过底部菜单随意跳转违反业务流程二是 TabBar 有页面数量限制最多 5 个且切换 TabBar 时是无法携带复杂参数的。所以核心原则是TabBar 里的页面是“容器”流程页面一律用普通页面。3. 核心页面实现点餐页与购物车3.1 分类与商品列表的联动点餐页是整个小程序的灵魂。左边的分类栏右边的商品列表点击左侧分类右侧滑动到对应区域右侧滑动时左侧的选中分类也要跟着变。这就是经典的双列表联动。实现思路是这样左侧分类用scroll-view通过scroll-into-view控制高亮项滚动到可视区右侧商品列表是一个纵向滚动的scroll-view每个分类区块加一个id如cate-分类id监听右侧滚动通过bindscroll事件获取当前滚动条位置判断当前处于哪个分类区块关键代码逻辑如下。右侧商品区渲染时为每个分类区块绑定idscroll-view scroll-y classproduct-list scroll-top{{scrollTop}} bindscrollonProductScroll block wx:for{{categories}} wx:key_id view idcate-{{item._id}} classcate-section view classcate-title{{item.name}}/view view wx:for{{groupedProducts[item._id]}} wx:key_id classproduct-item !-- 商品卡片内容 -- /view /view /block /scroll-view在onProductScroll中用boundingClientRect获取每个分类区块的top值判断当前视口顶部落在了哪个分区范围内onProductScroll() { const query wx.createSelectorQuery(); query.selectAll(.cate-section).boundingClientRect(); query.select(.product-list).boundingClientRect(); query.exec((res) { const sections res[0]; const listTop res[1].top; let currentIndex 0; for (let i 0; i sections.length; i) { if (sections[i].top - listTop 0) { currentIndex i; } else { break; } } if (currentIndex ! this.data.currentCategoryIndex) { this.setData({ currentCategoryIndex: currentIndex }); } }); }左侧分类点击右侧跳转则使用scroll-into-viewscroll-into-viewcate-{{activeCategoryId}}在数据变化后视图会自动滚动到目标区块。踩坑提醒scroll-into-view的值不能是纯数字开头的字符串所以我在每个区块 ID 前加了cate-前缀同时这个前缀也避免了分类 ID 和页面其他元素 ID 冲突。还有一点scroll-into-view只会滚动到对应元素的位置不会触发bindscroll事件所以左侧高亮项的同步需要你在点击时手动setData更新。3.2 购物车状态管理购物车是全局共享的数据因为点餐页要展示订单确认页也要读取。所以我把它放进了全局变量app.globalData.cartList并在需要使用的页面用getApp()读取。但这里有个大坑直接修改globalData不会自动更新页面视图因为小程序的setData是页面级别的。所以购物车的管理不能只是改个变量还要在所有使用购物车的页面同步触发setData。我建议封装一个购物车类放在utils/cart.js里class Cart { constructor() { this.list []; } init(cartList) { this.list cartList || []; } // 加入购物车 add(product) { const index this.list.findIndex( (item) item.productId product.productId item.specName product.specName ); if (index -1) { this.list[index].quantity product.quantity; } else { this.list.push({ ...product, quantity: product.quantity }); } this.save(); } // 减少数量 reduce(productId, specName) { const index this.list.findIndex( (item) item.productId productId item.specName specName ); if (index -1) { this.list[index].quantity - 1; if (this.list[index].quantity 0) { this.list.splice(index, 1); } this.save(); } } // 移除某项 remove(productId, specName) { this.list this.list.filter( (item) !(item.productId productId item.specName specName) ); this.save(); } // 清空购物车 clear() { this.list []; this.save(); } // 计算总价、总数量 getSummary() { let totalPrice 0; let totalCount 0; this.list.forEach((item) { totalPrice item.price * item.quantity; totalCount item.quantity; }); return { totalPrice, totalCount }; } // 保存到本地缓存 save() { wx.setStorageSync(cartList, this.list); } }关于价格单位我要特别提醒在小程序端所有价格计算一律用“分”不要用“元”。用浮点数算钱会碰到经典的0.1 0.2 ! 0.3问题虽然可以通过toFixed(2)临时修复但一旦涉及税费计算、优惠分摊、退款浮点误差会被无限放大。用分做整数运算就没这个问题显示时再转成元(price / 100).toFixed(2)。这个习惯我从做支付项目养成后再也没为金额显示错乱而困扰过。购物车数据持久化用wx.setStorageSync。为什么不做云同步因为购物车本身是临时性数据用户并不需要在换设备后保留它而且本地缓存性能更好。等到订单确认后购物车数据会被清空。这是符合产品逻辑的。3.3 商品规格选择与库存校验外卖商品离不开规格选择比如辣度不辣/微辣/中辣/特辣、加料加蛋 2元、加肠 3元。规格选择的弹层其实是一个自定义组件包含两个维度的选项组和一个数量加减器。规格选择的核心逻辑是当用户选择了所有必选规格项后才能计算最终价格和加入购物车。这里要处理好两个数据specs是选项组定义selectedSpecs是当前已选值。计算价格时在商品基础价格上加上所有已选项的priceAdd// 每个选项组如 [{ name: 辣度, required: true, options: [{ label: 不辣, priceAdd: 0 }, { label: 中辣, priceAdd: 0 }] }] computePrice() { let basePrice this.data.product.price; let addPrice 0; this.data.selectedSpecs.forEach((key) { const item this.data.product.specs.find((s) s.name key); // 每个维度只能选一项 const option item.options.find((o) o.label this.data.selectedValues[key]); if (option) addPrice option.priceAdd; }); this.setData({ currentPrice: basePrice addPrice }); }库存校验放在加入购物车时做而不是下单时才做。用户加购时就检查stock是否充足如果库存为 0 直接置灰按钮用户根本无法点击。这个检查在云函数里也要再做一次防止有人绕过前端校验下单。前后端双重校验不是吹毛求疵而是真实业务必须的。4. 订单链路的实现4.1 从购物车到订单确认订单确认页要做三件事展示购物车商品清单、选择收货地址、填写备注和配送时间。这里最容易翻车的是页面间数据传递。小程序页面跳转传参只能传字符串你无法把一个对象数组直接塞进url。所以在order-confirm页面我选择从全局缓存读取购物车数据而不是通过路由参数传递onLoad() { const cartList wx.getStorageSync(cartList) || []; if (!cartList.length) { wx.toast 后返回上一页; return; } this.setData({ items: cartList, ...new Cart(cartList).getSummary() }); this.loadAddress(); }页面把商品清单和价格明细商品小计、配送费、打包费、预计总价展示出来。配送费和打包费的规则可以写死在配置里比如满 30 免配送费不足 30 收 5 元打包费按订单总价的比例或者固定金额。这个项目我采用的是满 30 免配送费 每件商品 1 元打包费的规则。下单时有一个重要的校验逻辑检查用户的配送地址是否在配送范围内。真实外卖系统会用地图 API 做范围判断这个项目里我用云函数做了简化——判断用户填写的地址字符串里是否包含商家的配送区域关键词。比如商家配送范围是“XX区核心商圈”那么地址中必须包含这些关键词之一。这个逻辑演示了“前端校验不够后端校验兜底”的完整思想。4.2 订单状态机的流转订单状态我用一个数字枚举表示状态值含义可执行操作0待支付用户取消订单、倒计时超时自动关闭1已支付待接单商家接单2已接单制作中商家制作完成并开始配送3配送中用户确认收货4已完成用户评价、申请售后5已取消无这个状态机在云函数里实现。所有状态变更都通过云函数updateOrderStatus完成不允许小程序端直接连接数据库修改订单状态。为什么因为直接操作数据库就无法做权限校验用户就能把自己订单的状态从“待支付”改成“已完成”甚至把支付金额改成 0。云函数里必须校验操作者身份和操作合法性// 更新订单状态 exports.main async (event, context) { const { orderId, action } event; const wxContext cloud.getWXContext(); const uid wxContext.OPENID; const order await db.collection(orders).doc(orderId).get(); if (!order.data || order.data.uid ! uid) { return { success: false, message: 订单不存在 }; } // 根据动作执行对应的状态流转 const statusMap { cancel: { from: [0, 1], to: 5 }, confirm: { from: [1], to: 2 }, startDeliver: { from: [2], to: 3 }, complete: { from: [3], to: 4 } }; const rule statusMap[action]; if (!rule || !rule.from.includes(order.data.status)) { return { success: false, message: 当前状态不允许此操作 }; } await db.collection(orders).doc(orderId).update({ data: { status: rule.to } }); return { success: true }; };4.3 微信支付对接支付是外卖点餐系统的另一个难点。微信小程序端流程是前端调用wx.requestPayment但支付所需的参数必须由后端生成。具体来说小程序端把订单号发送给云函数云函数调用微信支付的统一下单接口拿到prepay_id云函数用prepay_id生成支付参数timeStamp、nonceStr、package、signType、paySign返回给前端前端调用wx.requestPayment拉起收银台支付成功后微信服务器会异步通知你的服务器这里用云函数接收支付回调更新订单状态为已支付这里需要说明的是云开发环境里可以使用cloud.cloudPay.unifiedOrder这个内嵌的支付 API省去了自己封装支付签名和证书处理的步骤。如果你用的是自己的服务器流程类似只是需要自己实现统一下单和回调验签。进入开发者工具时支付功能在测试环境是无法完整调试的。我当时的做法是在云函数里加一个测试开关// 云函数 createOrder 中 const isTest process.env.NODE_ENV ! production; if (isTest !event.mockPay) { // 走真实支付 } else { // 直接返回支付成功方便联调 }要留意的是测试开关只能用于开发阶段上线前务必移除否则别人就能绕过支付下单。我个人建议在云函数的部署配置里用环境变量控制这个开关而不是写死在代码里。一旦代码写死上线后你又要改代码重新部署很容易漏改。5. 数据交互与云开发实践5.1 云函数与数据库权限云开发数据库的默认权限是“仅创建者可读写”但点餐系统的商品、分类这类数据对所有用户都是可读的因此你要在云开发控制台修改集合权限为“所有用户可读仅创建者可写”。而订单、用户信息这些敏感数据永远不要让小程序端直接读写数据库必须通过云函数中getWXContext().OPENID做身份识别后再操作。这一点很多人容易忽略。小程序端的数据库权限设置虽然可以做一定的限制但本质上所有小程序端代码都可以被反编译查看直接在客户端操作数据库等于把数据库暴露给了攻击者。安全底线是正式项目里所有写操作和敏感读操作都通过云函数。5.2 请求封装为了代码复用封装一个基于wx.cloud.callFunction的请求模块统一处理错误提示和 loading 状态// utils/request.js function callFunction(name, data {}) { wx.showLoading({ title: 加载中, mask: true }); return wx.cloud .callFunction({ name, data }) .then((res) { const result res.result; if (!result.success) { wx.showToast({ title: result.message || 操作失败, icon: none }); throw new Error(result.message); } return result.data; }) .catch((err) { console.error(云函数调用失败, err); throw err; }) .finally(() { wx.hideLoading(); }); } module.exports { callFunction };这里的mask: true能防止用户在请求过程中重复点击但要注意如果某个页面同时发起多个请求比如点餐页加载分类和商品是两个接口hideLoading会提前关闭其他请求的 loading。所以我在实际项目中对需要并行的请求会单独使用不带 loading 的调用方法避免体验上闪烁。5.3 下拉刷新与分页加载外卖点餐页和订单列表页都需要下拉刷新和上拉加载。订单列表的分页逻辑遵循一个固定模式用skip和limit参数控制数据量每次加载后判断是否还有更多数据async loadOrders(reset false) { if (this.data.loading) return; if (reset) { this.setData({ page: 1, orders: [], hasMore: true }); } if (!this.data.hasMore) return; this.setData({ loading: true }); const pageSize 10; const data await callFunction(getOrderList, { page: this.data.page, pageSize }); this.setData({ orders: reset ? data.list : this.data.orders.concat(data.list), hasMore: data.list.length pageSize, page: this.data.page 1 }); this.setData({ loading: false }); }在订单列表页的onReachBottom生命周期里调用loadOrders()在onPullDownRefresh里调用loadOrders(true)并停止下拉动画。这个模式适用于所有列表页做一次就会了。5.4 真机调试与常见网络问题我在开发这个项目时遇到的最头痛问题是模拟器一切正常真机一调试就请求不到后端。排查路径是这样的先看小程序开发者工具里有没有报错信息再看云函数日志有没有调用记录。都没有问题的话检查是不是真机的基础库版本太旧不支持某个 API也可以用vConsole在真机上打印详细错误信息。我最终发现是我的云函数部署在了“测试环境”而真机调试连的是“正式环境”两边环境不一致导致请求不到数据。在云开发控制台把云函数同步部署到对应环境后问题消失。如果你用的是自建后端还要注意在小程序管理后台配置request 合法域名同时开发阶段可以在开发者工具里勾选“不校验合法域名”。但真机调试这个选项不生效所以域名配置一定要提前做。6. 常见问题与踩坑记录6.1 页面与组件层面的坑setData的 key 不能直接拼接变量名比如this.setData({ [item.${id}.quantity]: newVal })这种写法在小程序里是无效的。你只能整体更新对象或数组或者用中间变量const key items[${index}].quantity; this.setData({ [key]: newVal });这是我在编写购物车加减数量时踩过的坑动态 key 的写法在微信小程序的基础库版本中表现不一致新版支持但旧版会静默失败。scroll-into-view在 iOS 上失效这个问题的表现是在 iOS 真机上点击左侧分类右侧商品区不滚动但相同代码在开发者工具和 Android 真机上一切正常。原因在于 iOS 上有时候scroll-view的滚动位置计算有误尤其是当列表数据是异步加载时。解决方案是在数据渲染完成后再设置scroll-into-view的值并且在设置之前先重置为强制触发变更setActiveCategory(id) { this.setData({ scrollIntoView: , activeCategoryId: id }, () { this.setData({ scrollIntoView: cate-${id} }); }); }text组件的user-select属性如果外卖订单详情页的订单号需要让用户复制用于联系商家记得给text加user-selecttrue。不加这个属性在 iOS 上长按是无法选中文本的。我当时被这个问题卡了半天以为是小程序裁剪了文本复制能力。6.2 数据层面与业务逻辑的坑订单号生成不能只靠时间戳用Date.now()生成订单号在大流量情况下几乎肯定会重复。我采用的是“云函数内生成订单号”的方式const orderNo ORD Date.now().toString().slice(-10) Math.random().toString(36).slice(-6).toUpperCase();同时云函数内保证每个用户的订单号唯一。仍然不够严谨但站在演示项目的角度已经足够了。生产环境一般是后端生成全局唯一的订单号这里不再展开。优惠金额的计算顺序如果项目要加优惠功能满减、折扣、会员价计算优惠时必须遵守一个顺序先商品级优惠如折扣、会员价、再订单级优惠满减、最后叠加配送费和打包费。分摊优惠到每件商品的逻辑也是类似避免出现“订单实付为负数”这种尴尬。用户取消订单后购物车是否恢复这是外卖系统中一个容易被遗忘的点用户提交订单进入订单确认页时购物车会被清空。但如果用户在订单确认页点击了“提交订单”失败比如库存不足此时购物车已经清空了用户要重新加购吗我当时的处理是订单确认页不清空购物车只有订单创建成功后由云函数通知前端清空购物车。这样即使下单失败用户的购物车数据还在体验更友好。6.3 审核与体验细节小程序提审时外卖点餐类目属于餐饮服务需要服务类目里包含“餐饮服务”或“外卖点单”并且可能需要提供《食品经营许可证》。个人主体的小程序申请这类类目会有困难所以如果只是练手或演示建议用“工具-信息查询”类目提审或者干脆保留在测试版本不发布。提审前还要保证所有流程不会报错尤其是支付环节可以正常拉起收银台、取消支付后可以恢复订单状态。我注意到有开发者反馈他们的体验版录入账密后再次打开可以记忆输入。这类体验问题不是硬性要求但会影响审核的通过率和用户体验。小细节右上角三个点的胶囊菜单没法隐藏这是微信的机制不是你能控制的。苹果手机上微信小程序不能滑动滚动的问题多半是因为页面没有触发页面滚动高度刷新可以尝试在onShow里调用wx.pageScrollTo({ scrollTop: 1 })强制刷新滚动位置。7. 项目总结与扩展方向整个外卖点餐系统做下来我最大的感受是小程序开发的技术难度并不是最高的难的是模块之间的状态同步和边界情况的处理。尤其是购物车到订单再到支付的链路它们跨越了多个页面、多个异步接口任何一个环节的疏漏都会让用户体验打折扣。给后面想动手做这个项目的朋友两个建议。第一个建议是从一开始就保持代码分层。页面文件只负责渲染和事件绑定业务逻辑放进工具类或独立的 service 文件数据请求统一走封装的云函数。这样项目长了之后你才能快速定位 bug。我见过太多写着写着所有逻辑都堆在 Page 里的小程序改一个功能需要翻几百行的setData那种体验我是深有体会。第二个建议是别急着上来就做支付。先把点餐和购物车流程跑通把订单状态机的迁移逻辑写好用模拟支付把整个流程验证一遍再接入真实支付。这个顺序能帮你把调试难度拆成两块每块都更可控。我甚至看到很多人把购物车功能做好就搁置了因为没有预留订单状态机的抽象后续接支付时被迫重构。这个项目的扩展方向也很多。你可以把商家端做出来让商家在小程序里上架商品、接单、打印小票可以接入地图和定位实现配送范围判断和骑手轨迹可以加会员系统、优惠券和积分体系甚至可以做一套推荐算法根据用户的点单历史推荐菜品。每一条路都能让你学到新的技术点而且它们都建立在本文这套基础架构之上。最后分享一个我保留至今的调试习惯每次写小程序我都会在本地放一个debug.md文件记录自己遇到过的每个问题和对应的排查过程。遇到类似问题时先翻自己的笔记比搜索引擎高效得多。这个项目踩过的坑我已经替你踩过了希望这篇文章能让你少走一些弯路。