从零到一:我用 UniApp(Vue3) 开发了一套微信点餐小程序,踩坑与工程化经验全分享

105 阅读14分钟

本文以一个真实交付的「到店扫码点餐」微信小程序项目为主线,从目录设计、请求封装、全局状态、Mock 调试到对接后端,完整复盘一套 UniApp(Vue3) 前端的工程化实践。无论你是刚接触 uni-app 的新手,还是想参考一套可落地的点餐类项目结构,都值得花几分钟读完。

一、项目背景

最近完成了一个纯前端的点餐小程序项目,名字叫「阳光点餐」。它面向餐饮门店顾客,实现到店扫码点餐的完整流程:浏览门店与菜品 → 多规格选菜 → 本地购物车 → 优惠券自动最优抵扣 → 下单挂单 → 餐后收银台统一结账。

几个关键定位:

  • 纯前端交付:只交付小程序前端源码 + 一份《接口规范》,不包含任何后端代码;
  • 前端可独立运行:内置一套本地 Mock 数据,mock: true 时不需要后端,整个项目离线就能跑起来,方便甲方验收 UI 和交互;
  • 对接极简:后端就绪后,只需改 config/env.js 里的域名、appid 和一个开关,业务代码零改动。

这种「先交付可运行的前端,后端按规范实现」的模式,特别适合外包 / 插件市场交付和甲方团队并行开发的场景。

二、功能与技术栈

功能清单

模块能力
门店门店信息、营业状态、分类、菜品列表、多规格、套餐
购物车本地持久化、跨页不丢失、多规格区分、全选/删改
优惠券领取、未使用/已使用/已过期、下单自动匹配最优券
订单到店挂单、订单列表状态筛选、详情、取消、评价(多图上传)
用户手机号验证码登录、收货地址管理、收藏
点餐入口扫码桌号、手动输入桌号
商家后台(附带)营业统计、营业额趋势、菜品销量排行、结算对账(Web 端用)

技术栈

  • 框架:UniApp + Vue3 + <script setup> 组合式 API
  • 样式:SCSS + 全局 CSS 变量(Design Tokens)
  • 状态:Vue3 reactive + computed + watch 自建轻量 store(未引入 Pinia)
  • 网络:uni.request / uni.uploadFile 全局封装
  • 组件:easycom 自动注册,8 个公共业务组件
  • UI 库:uni-ui 基础组件

三、目录结构:把"对接层"和"业务层"彻底分开

一个点餐项目文件不少,目录划分的核心思路是:让"将来要改的地方"高度集中。

order-food/
├── api/          # 接口封装(对接层,所有网络请求都在这)
│   ├── base.js   # 请求/响应拦截器 + mock 分发
│   ├── shop.js   # 门店、菜品
│   ├── cart.js   # 购物车(云端同步)
│   ├── order.js  # 订单
│   ├── coupon.js # 优惠券
│   ├── user.js   # 用户、地址、评价、收藏
│   └── statistics.js # 商家端统计
├── components/   # 8 个公共组件(GoodsItem/CartFloat/CouponItem...)
├── config/
│   └── env.js    # ★ 唯一需要改的对接配置
├── mock/         # 本地模拟数据(开关控制)
├── pages/        # 11 个业务页面
├── store/        # 全局状态(cart 持久化 / user / shop)
├── utils/        # 金额分转元、时间格式化
└── App.vue       # 全局样式 + Design Tokens

为什么这样设计? 因为项目是"纯前端交付",甲方后端团队一定会问一句话:"我们后端好了之后,前端要改多少?"。答案在这套结构里被压缩到极致——只改 config/env.js 一个文件,并把 mock 开关关掉。其余业务代码与后端约定解耦,这是外包/交付类项目最核心的工程体验。

四、贯穿全项目的两个"硬规范"

这类项目最容易在对接时扯皮,所以在动手写第一行页面之前,先和后端约定死两条规则:

1. 统一返回结构 + 统一错误码

所有接口,无论成败,必须返回同一结构:

{
  "code": 0,        // 0=成功;非0=错误码
  "msg": "提示文案",
  "data": {},       // 业务数据,无数据返回 null
  "requestId": "随机唯一请求ID(排查日志用)"
}

错误码是前后端沟通的"共同语言":

错误码含义前端处理
0成功正常渲染
401Token 失效清缓存 + 跳登录页
403无权限Toast 提示
2001门店未营业弹窗 + 禁用下单
3001库存不足弹窗 + 刷新库存

2. 金额一律用"分"(整数)

后端所有价格单位是分,前端展示时 ÷100 转成"元"。这样彻底规避了浮点运算精度丢失的问题。工具函数放 utils/money.js:

// 后端返回 2990 → 前端展示 29.90 元
export function fenToYuan(fen) {
  const n = Number(fen)
  if (!isFinite(n)) return '0.00'
  return (n / 100).toFixed(2)
}

// 列表合计(单位:分)
export function sumFen(list, getPrice, getNum) {
  return list.reduce((acc, it) => acc + Number(getPrice(it)) * Number(getNum(it)), 0)
}

这两条规范在《前端开发文档》里用一章写死,后端照着实现即可,前端不用写任何"防御性分叉代码"。

五、核心:全局请求封装 + Mock 开关(api/base.js)

这是整套前端的"血管"。它统一做了几件事:

  1. 自动拼接 baseUrl;
  2. 自动从缓存读 token 塞进请求头;
  3. 统一 loading、超时(10s);
  4. 按错误码分流处理(401 跳登录、2001 提示门店未营业等);
  5. 命中 Mock 直接返回本地数据,不发真实请求。
export function request(url, method = 'GET', data = {}, options = {}) {
  const { loading = true } = options
  // Mock 模式:命中本地模拟数据则直接返回,不再请求真实后端
  if (env.mock) {
    const mockData = getMock(url, method, data)
    if (mockData !== undefined) {
      return new Promise((resolve) => setTimeout(() => resolve(mockData), 200))
    }
  }
  return new Promise((resolve, reject) => {
    if (loading) uni.showLoading({ title: '加载中', mask: true })
    uni.request({
      url: env.baseUrl.replace(/\/$/, '') + url,
      method,
      data,
      header: {
        [env.tokenKey]: env.tokenPrefix + getToken(),
        'content-type': 'application/json'
      },
      timeout: 10000,
      success: (res) => handleSuccess(res, resolve, reject),
      fail: () => {
        uni.hideLoading()
        uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' })
        reject(new Error('network error'))
      }
    })
  })
}

export function get(url, params = {}, options) {
  return request(url, 'GET', params, options)
}

export function post(url, data = {}, options) {
  return request(url, 'POST', data, options)
}

这里有个很关键的设计:拦截器在 code === 0 时直接 resolve(data),把业务数据吐给页面;页面拿到的就是纯粹的 data,而不是整个响应体。这让业务代码非常干净:

// api/shop.js —— 页面拿到的直接是门店数据
export function getShopInfo() {
  return get('/api/shop/info')
}

而响应拦截器对错误码的统一处理(见 handleSuccess),保证了"门店未营业""库存不足"这类业务错误,在任何页面都能给出统一提示,不用每个页面重复写。

Mock 是这套交付模式的灵魂。mock/index.js 根据 url + method + 入参 分发本地数据:

export function getMock(url, method, data = {}) {
  switch (url) {
    case '/api/shop/goodsList': {
      const cid = data.categoryId
      const list = cid && cid !== 0 ? goodsAll.filter((g) => g.categoryId === cid) : goodsAll
      return clone(list)
    }
    case '/api/shop/goodsDetail': {
      // 注意:URL 参数是字符串,goodsAll 里是数字,需转数字再严格比较
      const g = goodsAll.find((x) => x.goodsId === Number(data.goodsId)) || goodsAll[0]
      return clone(g)
    }
    // ... 其余接口同理
  }
}

甚至连"优惠券自动计算最优券"这种业务逻辑,Mock 里都实现了完整版(满减校验 + 有效期判断),这样前端可以把下单结算全流程都调通,真正实现"无后端可验收"。

六、状态管理:不引 Pinia,一个文件搞定购物车持久化

购物车是这个项目数据模型里最典型的场景。用 Vue3 的 reactive + watch 自建了一个轻量 store,并通过 watch 深监听自动写入 uniStorage,实现切换页面不丢失:

import { reactive, computed, watch } from 'vue'

const STORAGE_KEY = 'local_cart'

export const state = reactive({
  list: uni.getStorageSync(STORAGE_KEY) || []   // 启动即从缓存恢复
})

// 深监听,购物车一变就写缓存
watch(
  () => state.list,
  (v) => uni.setStorageSync(STORAGE_KEY, v),
  { deep: true }
)

function findIndex(goodsId, specId) {
  return state.list.findIndex((i) => i.goodsId === goodsId && i.specId === specId)
}

export function addItem(goods, spec, num = 1) {
  const specId = spec ? spec.specId : ''
  const idx = findIndex(goods.goodsId, specId)
  if (idx > -1) {
    state.list[idx].num += num            // 同商品同规格 → 累加数量
  } else {
    state.list.push({ ...goods, specId, specName: spec ? spec.specName : '', num })
  }
}

export const totalCount = computed(() => state.list.reduce((a, i) => a + i.num, 0))
export const totalPrice = computed(() => state.list.reduce((a, i) => a + i.price * i.num, 0))

关键细节:购物车去重键是 goodsId + specId 的组合——同一个奶茶,大杯和小杯是两条不同的购物车记录。这个组合键在后续"勾选结算"时也用 goodsId:specId 字符串传参,全项目保持了一致。

页面里直接响应式消费:

<script setup>
import { addItem, totalCount, totalPrice } from '@/store/cart.js'

function addToCart(g) {
  addItem(g, null, 1)
  uni.showToast({ title: '已加入', icon: 'none' })
}
</script>

<template>
  <!-- 底部悬浮购物车 -->
  <CartFloat :count="totalCount" :totalPrice="totalPrice" tabbar @tap="goConfirm" />
</template>

七、结算页实战:优惠券自动匹配最优券

结算页(confirm)是这个项目交互最重的页面。它只做一件事:帮顾客算出最划算的实付金额。

流程是:

  1. 进入页面自动请求"我的可用优惠券";
  2. 调用后端 /api/coupon/calc 自动匹配抵扣最多的券;
  3. 顾客也可以手动点开券面板切换/放弃优惠券;
  4. 实付 = 商品小计 - 券抵扣。
const payMoney = computed(() => Math.max(0, subTotal.value - reduce.value))

onMounted(async () => {
  try {
    couponList.value = await getCouponList(1)   // 1=未使用
  } catch (e) {}
  try {
    const r = await calcCoupon(subTotal.value)  // 后端返回最优券
    coupon.value = r.coupon
    reduce.value = r.reduce || 0
  } catch (e) {}
})

// 满减门槛判断(手动切换券时实时校验)
function canUse(c) {
  return c.fullMoney <= subTotal.value
}

payMoney 用 Math.max(0, ...) 兜底,防止券抵扣超过小计出现负数金额——这种边界虽然少见,但做支付相关 UI 时一定要防。

下单动作比较特别,因为是到店挂单、餐后统一结账的模型,所以 createOrder 只建单不支付:

const cartList = list.value.map((i) => ({ goodsId: i.goodsId, specId: i.specId, num: i.num }))

createOrder({
  deliveryType: 1,
  tableNo: shop.tableNo,
  cartList,
  couponId: coupon.value ? coupon.value.couponId : '',
  remark: remark.value,
  totalMoney: subTotal.value,
  payMoney: payMoney.value
}).then(() => {
  clearCart()                                  // 下单成功清空本地购物车
  uni.redirectTo({ url: '/pages/order/pay' })  // 跳到"提交成功"页
})

这里体现了业务模型的重要差异:到店点餐 ≠ 外卖,支付环节由收银台完成,所以 wx.requestPayment 不在顾客端实现。做业务项目,先想清楚"钱从哪收",再决定代码结构,能少走很多弯路。

八、扫码桌号:条件编译 + 正则兜底

到店场景的入口是扫码。uni.scanCode 扫出来的结果可能是二维码链接(可能带参数),也可能是不带协议的纯桌号,所以要做正则兜底,把结果里的数字抠出来:

function scanCode() {
  // #ifdef MP-WEIXIN || APP-PLUS
  uni.scanCode({
    success: (res) => {
      const m = (res.result || '').match(/\d{1,4}/)
      if (m) {
        shop.setTableNo(m[0])
        uni.showToast({ title: '已入座桌号' + m[0], icon: 'none' })
      }
    }
  })
  // #endif
}

同时首页 onLoad 支持从链接参数里直接带桌号进店(?tableNo=12),适合把带参的小程序码贴到餐桌上。桌号信息放在全局 store/shop.js,保证首页、结算页、订单页数据一致。

九、Design Tokens:一套 CSS 变量,全站视觉统一

这个项目没有引 UI 框架的主题系统,而是自己在 App.vue 里定义了一套设计令牌(CSS 变量),组件内统一 var(--xxx) 引用:

page {
  --primary: #FF7A1A;     /* 品牌橙 */
  --primary-dark: #F2600A;
  --price: #FF4D2D;       /* 价格红 */
  --bg: #FFF7EF;          /* 暖色页面底 */
  --card: #FFFFFF;
  --text-main: #2B2B2B;
  --text-sub: #666666;
  --text-tip: #999999;
  --border: #F0E6DC;
  --success: #2BBF7A;
  --disable: #CCCCCC;

  --r-card: 16rpx;        /* 圆角 */
  --r-btn: 40rpx;
  --r-tag: 8rpx;

  --pad-page: 24rpx;
  --gap: 16rpx;
}

好处很明显:

  • 改主题只动一处:想换品牌色,改 --primary 一个变量,全站同步;
  • 组件零魔法数字:所有页面用 var(--gap)、var(--r-card),间距圆角高度统一;
  • 配合 easycom:组件开箱即用,视觉天然一致。

再配合 .btn-primary、.price、.safe-bottom 这几个全局工具类,页面样式代码大幅瘦身。

十、对接真实后端:到底要改什么?

这是甲方最关心的问题,也是这篇文章想让你学会的"交付思维"。

前端侧,只改 config/env.js:

export default {
  baseUrl: 'https://your-api-domain.com',      // ① 换成备案过的 HTTPS 域名
  uploadUrl: 'https://your-api-domain.com/api/upload',
  tokenKey: 'Authorization',
  tokenPrefix: 'Bearer ',
  mpAppId: 'wxxxxxxxxxxxxxxxxx',               // ② 换真实 appid
  mock: false                                   // ③ 关掉 Mock 开关
}

后端侧,照着《接口规范》第 7 章实现 30 多个接口即可,前端不感知。上线的硬性要求也要提前说清楚:

  • 后端域名必须 ICP 备案 + HTTPS(443 端口),小程序强制要求;
  • 小程序后台「服务器域名」要加 request / uploadFile / downloadFile 白名单;
  • 不支持本地 IP、localhost、内网地址线上使用。

十一、踩坑与经验总结

最后分享几个这个项目里真实踩过、值得记住的点:

  1. URL 参数类型坑:uni.navigateTo 传参拿到的是字符串,而 mock 数据里 goodsId 是数字,不 Number() 强转会匹配失败返回错误数据。跨页面传参后一定要校验类型。
  2. toISOString() 时区坑:判断优惠券有效期时,如果用了 new Date().toISOString() 取"今天",会比北京时间慢 8 小时,晚间会误判券已过期。正确做法是用本地日期拼接 YYYY-MM-DD 字符串比较。
  3. appid 泄露风险:开发用真实 appid,但发布到插件市场 / 交付源码前,一定要把 manifest.json 和 project.config.json 里的 appid 替换成占位符,避免泄露。建议用构建脚本或 git 分支处理,别在源码里硬编码两套。
  4. 购物车键要用 goodsId + specId:同一商品不同规格必须分开记账,这是点餐类项目最容易漏的点。
  5. Mock 优先让"完整业务"能跑通:不要只 mock 列表数据,把"优惠券最优匹配""下单建单"这种核心逻辑也 mock 出来,前端才能真正离线验收全流程,这对交付谈判非常有利。

十二、写在最后

一套点餐小程序前端,难点从来不在某个页面,而在如何把"对接后端"这件事变得可预测、可验收、可维护。这套项目的核心经验可以浓缩成三句话:

  • 规范先行:返回结构、错误码、金额单位,开工前就写死,避免对接扯皮;
  • Mock 兜底:让前端在无后端时也能完整跑通,降低并行开发的阻塞;
  • 改造成本聚焦:所有对接配置收敛到一个文件,后端就绪后一行配置切换。

如果你也在做 uni-app 业务项目,或准备交付一套可复用的点餐类小程序,希望这篇文章的目录设计和几个核心思路能给你参考。

十三、项目开源地址

本项目完整源码已发布到 DCloud 插件市场,可直接下载运行、二次开发:

📦 阳光点餐 · UniApp 点餐小程序前端源码 插件地址:ext.dcloud.net.cn/plugin?id=2…

下载后按 README 说明:用 HBuilderX 打开工程,填入真实 appid 即可在微信开发者工具中运行;对接后端只需修改 config/env.js 一个文件。也欢迎在插件市场评论区交流使用心得。

如果你对文中的某个模块(比如 Mock 完整实现、优惠券计算、订单状态机)感兴趣,欢迎评论区留言,我可以单独拆一篇展开讲。

  1. URL 参数类型坑:uni.navigateTo 传参拿到的是字符串,而 mock 数据里 goodsId 是数字,不 Number() 强转会匹配失败返回错误数据。跨页面传参后一定要校验类型。
  2. toISOString() 时区坑:判断优惠券有效期时,如果用了 new Date().toISOString() 取"今天",会比北京时间慢 8 小时,晚间会误判券已过期。正确做法是用本地日期拼接 YYYY-MM-DD 字符串比较。
  3. appid 泄露风险:开发用真实 appid,但发布到插件市场 / 交付源码前,一定要把 manifest.json 和 project.config.json 里的 appid 替换成占位符,避免泄露。建议用构建脚本或 git 分支处理,别在源码里硬编码两套。
  4. 购物车键要用 goodsId + specId:同一商品不同规格必须分开记账,这是点餐类项目最容易漏的点。
  5. Mock 优先让"完整业务"能跑通:不要只 mock 列表数据,把"优惠券最优匹配""下单建单"这种核心逻辑也 mock 出来,前端才能真正离线验收全流程,这对交付谈判非常有利。

十二、写在最后

一套点餐小程序前端,难点从来不在某个页面,而在如何把"对接后端"这件事变得可预测、可验收、可维护。这套项目的核心经验可以浓缩成三句话:

  • 规范先行:返回结构、错误码、金额单位,开工前就写死,避免对接扯皮;
  • Mock 兜底:让前端在无后端时也能完整跑通,降低并行开发的阻塞;
  • 改造成本聚焦:所有对接配置收敛到一个文件,后端就绪后一行配置切换。

如果你也在做 uni-app 业务项目,或准备交付一套可复用的点餐类小程序,希望这篇文章的目录设计和几个核心思路能给你参考。

如果你对文中的某个模块(比如 Mock 完整实现、优惠券计算、订单状态机)感兴趣,欢迎评论区留言,我可以单独拆一篇展开讲。