
做外卖类小程序总有一个绕不开的节点那就是从“能看页面”过渡到“能真正下单”。如果你跟的是苍穹外卖这套实战项目到了 Day6 这个阶段说明已经过了环境搭建和基础页面的坎开始碰微信小程序里真正硬核的东西请求封装、列表分页、图片上传还有一堆绕不开的页面细节。这篇内容就围绕 Day6 这一天的实操展开我会把跑通这套流程的经验和踩坑点都整理出来适合正在跟苍穹外卖的学员也适合所有第一次用原生微信小程序做电商类项目的开发者。做项目这件事有时候看起来功能都做完了但用起来总差点意思往往就是差在这些看不见的基础层里。这篇内容不会教你机械敲代码而是把为什么要这样设计、哪些地方容易翻车讲明白你照着走的时候能少走不少弯路。1. 项目结构与整体设计思路1.1 先厘清小程序端和后端的分工苍穹外卖这个项目本身是前后端分离的后端用 Spring Boot 提供接口管理后台一般用 Vue 或者 React 开发用户端跑在微信小程序里。Day6 之前的任务基本都在搭后端和后台管理页面到了 Day6 才真正把用户端小程序串起来。我刚拿到 Day6 需求的时候第一反应是赶紧写页面毕竟列表页、详情页的 UI 摆在那里视觉上很有成就感。但实际操作下来才发现Day6 真正的工作量不在页面而在数据链路。小程序端必须想清楚三件事接口怎么请求、数据怎么分页、用户操作怎么反馈。如果不先把这三件事定下来后面很可能返工。我遇到过不少同学在页面里直接写wx.request一个接口调七八处后端接口域名改个端口全局搜出来几十处替换。这种写法在小项目里看着没问题但苍穹外卖这种几十个接口的项目绝对不能这么干。所以 Day6 的第一步我建议不是写按钮而是先把项目的请求层设计出来。1.2 目录划分和 app.json 全局配置小程序的工程结构虽然官方给了规范但实际项目里每个人组织方式都不一样。我在这个项目里用的目录结构大概是这样的pages存放所有页面每个页面一个目录目录名和页面名保持一致components自定义组件比如菜品卡片、数量选择器utils工具函数包括 request 封装、格式化、常量api接口层按业务模块拆成dish.js、cart.js、order.jsstatic静态资源图片、图标等接口层单独拆出来是我个人非常推荐的做法。举个例子首页要调菜品接口在api/dish.js里写一个函数import { getRequest } from ../utils/request; export function getDishList(params) { return getRequest(/dish/list, params); }页面里只需要import { getDishList } from ../../api/dish然后调用函数就行。接口路径、参数结构都和页面逻辑分离后面如果后端改了接口名你只需要改api层。app.json 的全局配置同样不能轻视。你要在 app.json 里注册页面路径、配置window的导航栏标题和背景色、定义tabBar。外卖小程序用户端通常有“首页”“订单”“我的”几个底部 tab建议 Day6 第一天就把 tabBar 的图标和页面路径规划好不要等到页面写完了再补。否则改一次 tabBar页面之间跳转的路径全部要核对一遍特别容易漏。页面级的.json文件要注意一个小细节如果你想让某个页面用自定义导航栏比如首页顶部放搜索框和定位要在该页面的 .json 里配置navigationStyle: custom。这个配置只对当前页面生效不会影响其他页面。很多新手在这里踩坑是因为把它配到了 app.json 的 window 里结果发现全局导航栏都没了半天不知道怎么回事。2. 请求封装先把地基打好2.1 为什么要统一封装而不是裸调 wx.request微信小程序的wx.request是页面开发中最频繁使用的 API但裸用问题很多。第一每次都要写完整 URL接口域名一换页面全要改。第二每个页面各自处理success和fail代码重复度极高。第三一旦需要统一的 token 注入、错误提示、加载等待散落在各处的代码会让你改到崩溃。我自己带项目的时候定过一个规矩页面文件里不允许出现wx.request必须走封装后的方法。这条规矩看着有点绝对实际执行起来受益很大。请求一旦异常你只需要在一个文件里排查接口返回的数据结构不规范也只需要在一个地方兼容。封装的核心目标有四个统一域名配置、统一请求头、统一处理状态码、统一错误提示。这四个统一做完小程序的网络层才算站得住。2.2 封装实践Promise 化、header 注入与状态码判断用原生语法封装一个 Promise 风格的 request核心代码其实不长。我贴一段我在项目中使用的典型实现细节都标在注释里const BASE_URL https://api.example.com; // 根据环境切换 const request (url, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${url}, method, data, timeout: 10000, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success: (res) { // 第一层判断HTTP 状态码 if (res.statusCode 200) { const body res.data; // 第二层判断业务状态码 if (body.code 1) { resolve(body); } else if (body.code 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(body); } else { wx.showToast({ title: body.msg || 请求失败, icon: none }); reject(body); } } else { wx.showToast({ title: 网络异常, icon: none }); reject(res); } }, fail: (err) { wx.showToast({ title: 网络请求失败, icon: none }); reject(err); } }); }); }; export function getRequest(url, params) { return request(url, GET, params); } export function postRequest(url, data) { return request(url, POST, data); }这里最容易被忽略的是两层状态码的判断。很多后端接口在 HTTP 正常返回 200 的情况下业务 code 可能是 401 表示登录过期。如果只判断statusCode用户就会在一个登录已过期的页面里继续看到一堆报错。我在项目里最重要的一次改造就是加入了 401 自动清除登录态并跳转登录页的逻辑从那以后线上反馈少了一大半。2.3 封装之后的调用方式和边界情况封装好了页面里调用就变成了 Promise 的写法非常清爽import { getDishList } from ../../api/dish; getDishList({ categoryId: this.data.currentCategoryId }) .then((res) { this.setData({ dishList: res.data }); }) .catch((err) { console.error(获取菜品失败, err); });这里有两个边界情况值得说。第一个是并发请求的 loading 处理。如果在封装里统一打开wx.showLoading那么多个请求并发时先完成的请求关闭 loading后完成的还没结束loading 就提前消失了。我的处理方式是维护一个计数器每次请求创建时加一结束时减一归零才关闭 loading。当然如果你更习惯在页面级别手动控制 loading那封装里就不要统一加 loading 提示。第二个是请求超时。wx.request默认超时在低版本基础库里没设可以在封装里显式加timeout: 10000尤其是提交订单这类操作网络稍慢就容易让用户干等好几十秒没反应。设置超时后fail回调会收到错误你再根据errMsg判断是超时还是断网提示语可以更精准。3. 页面列表与“加载更多”的实现3.1 分页参数的三个关键变量外卖首页、订单列表这类长列表后端基本都不会一次性把数据全返回而是用分页。分页接口一般接受page和pageSize返回数据里通常还有一个total或者hasMore。我习惯在页面 data 里维护三个变量list当前已加载的数据数组、page下一页页码、hasMore是否还有更多数据。每次请求成功后把新返回的数组 concat 到 list 后面page加一。这里有一个新手常犯的错误页面下拉刷新时忘记把page重置为 1结果刷新后加载的是第二页数据。下拉刷新和筛选切换都属于“重置场景”。只要数据源发生变化page必须复位为 1list必须清空hasMore必须重置为 true。这三个变量是绑在一起的改一个就要检查另外两个不然列表数据一定乱。3.2 触底加载页面滚动和 scroll-view 的区别页面列表的加载更多触发时机有两种写法。第一种是普通页面滚动配合onReachBottom。你什么都不用配置只需要在页面.js里写onReachBottom() {}方法页面滚动到底部附近就会触发。触发距离默认值是 50px也可以在页面 .json 里用onReachBottomDistance调整。这种方式的优点是实现简单缺点是你必须保证页面确实存在滚动区域如果内容太少撑不满一屏onReachBottom是不会触发的。第二种是局部区域滚动用scroll-view组件。这时触底事件用bindscrolltolower但有一个关键前提scroll-view必须有明确的高度否则它的内容自适应滚动事件永远不会触发。这是个很隐蔽的坑。外层样式写成height: 100vh或者用 flex 分配页面剩余的高度scroll-view才能正常工作。两种方式不要混用。如果页面已经用了scroll-view做局部滚动又在页面上写onReachBottom两套逻辑会互相干扰要么重复加载要么完全不触发。我在项目里一般只用第一种结构简单只有商品分类切换那种“列表区域独立滚动”的页面才用scroll-view。3.3 加载锁、尾部状态和性能优化触底事件在用户快速滑动的场景下有可能在几百毫秒内连续触发两三次。如果不加锁同一个page的请求会被发出去多次列表里就出现重复数据。我自己加锁的方式很朴素在 data 里放一个loadingMoreonReachBottom() { if (this.data.loadingMore || !this.data.hasMore) return; this.setData({ loadingMore: true }); this.fetchDishList(); }请求完成后把loadingMore设回 false。这个方法简单有效足够覆盖绝大多数场景。尾部状态提示建议做成列表的最后一个组件。常见文案是“加载中…”和“没有更多了”。如何决定显示哪一种loadingMore为 true 时显示“加载中…”hasMore为 false 时显示“没有更多了”。从用户角度来说这是个很微妙的细节但做好了列表不会让人感觉“卡住”。性能方面有一点值得提列表数据量大了以后setData的开销会变大因为setData会把数据从逻辑层传到渲染层数据越大传输越慢。如果订单列表已经几百条每次 concat 之后 setData 整个列表滚动会变得卡顿。解决办法之一是wx:for时加上wx:key让组件能够复用另一个是如果仍然直接 setData 整个数组规模不大时也能接受。想深入研究的话可以了解一下官方扩展库里的长列表组件方案但 Day6 阶段不需要过早优化。4. 本地图片上传的完整链路4.1 选图接口chooseMedia 与临时路径在 Day6 里图片上传最常见的场景是用户评价时上传图片以及商家编辑菜品时上传菜品图。本地开发的第一步是让用户选图官方接口现在推荐用wx.chooseMedia它比旧的wx.chooseImage功能更完整支持拍照和相册两种方式count 参数可以限制数量。选图成功回调里拿到的不是最终可用的图片地址而是临时文件路径。这个临时路径的生命周期只存在于当前小程序进程重启之后就会失效。所以正确做法是拿到临时路径后立刻上传不要把它当作持久地址保存到后端。有一个开发中的常见困惑开发者工具里看到的临时路径形如http://tmp/xxx.jpg很多人误以为可以直接当正式图片 URL 使用结果一发布发现图片全裂了。4.2 上传方法wx.uploadFile 的请求差异和 JSON 解析图片上传使用的是wx.uploadFile它和wx.request不是一套 API。wx.uploadFile会把文件以 multipart/form-data 的方式提交你需要指定filePath和name。name是后端接收文件的字段名这个必须和后端同事约定好。如果后端用 Spring Boot 接收常见字段名是file或multipartFile拿不准就去看后端接口代码里的参数注解。上传时需要额外携带参数比如用户 id 或者 token就放在formData里。有些后端要求把 token 放在 header 的Authorization里wx.uploadFile也支持传 header。你自己封装一个 upload 方法的时候记得把 request 封装里那套 header 逻辑复用过来不然就容易出现“普通接口能调通上传接口一直 401”的怪现象。还有一个细节是wx.uploadFile返回的res.data是字符串。很多后端会返回 JSON但微信不会自动帮你解析必须JSON.parse。我第一次遇到的时候直接在 success 回调里读res.data.code拿到 undefined懵了十几分钟才反应过来。后来我会在解析外面加一层 try/catch避免后端万一返回非 JSON 导致整个页面报错。4.3 压缩、进度条和九图上传真实用户上传的图片体积通常不小尤其是现在手机默认拍照动辄几 MB。不经过处理直接上传一是上传时间特别长二是多图场景容易触发内存问题。wx.chooseMedia里虽然有sizeType参数可以选压缩模式但不同机型的压缩效果参差不齐。如果想要更可控的效果可以选图之后再用wx.compressImage压缩一遍。压缩参数里的quality取值 0 到 100外卖场景适合 70 左右肉眼基本看不出差别体积却能小不少。上传进度用onProgressUpdate监听。上传接口支持监听进度回调参数里有progress。简单的进度条显示在按钮上或者上传区域内就行。我见过不少项目完全忽略这个反馈用户的图片超过 2MB 时网络差的情况下会干等十几秒你说他能不怀疑小程序卡死了吗。多图上传还有一个顺序问题。如果用户一次选了 9 张图简单地在 for 循环里发起 9 个异步上传返回值顺序是不确定的最后展示的图片顺序可能和选择顺序不一致。我的处理办法是记录原始索引每个上传任务带上自己的 index所有任务完成后按索引排好序再一起提交给后端。这个细节在“评价晒图”这类功能里几乎一定会被用户注意到。5. 绕不开的页面细节单选、导航栏和生命周期5.1 单选和 SKU 选择的两种实现思路外卖小程序的点餐页面经常出现规格选择比如“辣度微辣、中辣、特辣”“份量标准、大份”。在原生小程序里最简单的方案是radio-group嵌套radio。这个方案代码量最小而且天然支持单选的互斥逻辑。但问题是原生的 radio 样式很难定制圆点、颜色、尺寸都有限制做出外卖 App 那种按钮组样式很费劲。我更推荐的做法是用view自己模拟选中态。data 里定义selectedIndexwxml 里用wx:for渲染选项点击时更新selectedIndex再根据selectedIndex index给当前项添加active样式类。视觉上完全可控交互逻辑也只有一行赋值适合 SKU 选择。唯一要注意的是多个规格组不能共用一个selectedIndex每个规格组要有自己独立的选中状态变量。5.2 顶部导航栏高度不要用固定像素自定义导航栏是外卖类小程序特别常见的需求因为顶部要放定位、搜索框、购物车入口原生导航栏展示不了这么丰富的内容。设置自定义导航栏之后你需要手动处理安全区域的占位。很多新手会直接写一个固定高度比如网上流传的 64px 或 88px。但状态栏高度在不同手机上是不同的iPhone 的刘海机型和安卓机型差距很大。正确做法是动态获取const windowInfo wx.getWindowInfo(); const statusBarHeight windowInfo.statusBarHeight; const menuButtonRect wx.getMenuButtonBoundingClientRect();用这两组数据就能算出导航栏的高度menuButtonRect.height加上(menuButtonRect.top - statusBarHeight) * 2然后再加状态栏高度这就是自定义导航栏应有的总高度。我把这段计算封装成了一个工具函数所有页面共用。还有一个相关细节是页面底部安全区iPhone 的 home 指示条会挡住内容需要给底部预留env(safe-area-inset-bottom)的距离否则布局会显得局促。5.3 生命周期监听用户离开和回到小程序“用户切走了”这件事在网页里很难感知但在小程序里有明确的生命周期。用户点手机 Home 键、切到其他 App、或者小程序从后台恢复都会触发对应事件。App 级别有onHide和onShow页面级别也有onHide和onShow。如果你想在用户离开小程序时保存草稿、记录离开时间、断开某些连接写在app.js的onHide里最合适。如果只是某个页面需要暂存输入内容那就写在该页面的onHide里。需要注意onHide里尽量别做太多事情切后台之后小程序随时可能被系统销毁所以用同步 API 写本地存储或者轻量埋点是更稳的。回到小程序时触发onShow这是刷新数据的好时机。比如购物车角标数量、订单状态这类数据在用户离开期间可能已经变化回到页面时应该重新拉取。我有个习惯列表页的请求尽量放在onShow而不是onLoad因为onLoad只在页面首次加载时触发一次从二级页面返回时不会再触发数据就可能是旧的。还有一个需要区分的是onUnload。它只在页面销毁时触发比如关闭页面或跳到另一个 tab。很多新手把切后台误认为页面卸载结果发现离开小程序后再回来onLoad没有被执行。搞清楚onShow、onHide、onUnload三者的区别很多生命周期相关的 bug 都能避免。6. 调试、体验版与常见问题排查6.1 把小程序发给别人试用本地开发完成后把代码发给别人试用最规范的方式是上传体验版。第一步在微信公众平台注册一个小程序账号拿到真实的 AppID替换开发者工具里的测试号。第二步在开发者工具的工具栏点“上传”输入版本号和备注。第三步到公众平台的“版本管理 - 开发版本”里找到刚上传的版本点击“选为体验版”。体验版不是谁都能打开只有添加到“体验成员”名单里的微信号才能扫码使用。你可以在公众平台的“成员管理 - 体验成员”里添加测试同学的微信号。在收集反馈时我会让测试同学按照“手机型号 微信版本 操作步骤 现象描述 截图”这个模板来反馈。没有这个模板你大概率只会收到“打不开”“很卡”这种没法定位的信息。上线阶段还有一个提醒小程序里调用接口必须使用已配置的合法域名开发时可以在开发者工具右上角勾选“不校验合法域名”但发布前需要在公众平台的“开发设置 - 服务器域名”里配置 request 和 uploadFile 的合法域名。域名必须是 HTTPS而且不能带路径。6.2 五个高频问题和定位方法我把 Day6 阶段常见的报错和坑整理成了一个表方便定位。现象可能原因排查方向请求返回成功但页面没数据setData 路径错误或 this 指向问题打印 res检查路径和箭头函数图片在开发者工具里正常真机不显示图片域名不是 HTTPS 或未配合法域名检查图片 URL 协议和公众平台域名配置触底加载完全不触发内容不足一屏或 scroll-view 没有高度确认滚动容器高度确认使用 onReachBottom 还是 bindscrolltoloweruploadFile 返回数据里 code 为 undefinedres.data 是字符串未 JSON.parse打印 res.data 查看真实结构按钮连续点击发起多次请求异步请求期间按钮未禁用加 submitting 状态绑定到按钮 disabled这五个问题里最常见、也最隐蔽的是第一个和第三个。setData 路径问题可以通过console.log很快定位滚动类的坑则需要在开发工具里反复确认页面高度。我的经验是遇到任何“事件没反应”的问题先在控制台打一条日志确认事件到底有没有触发再往下查。省得你对着代码猜半天结果发现是容器高度没撑开。6.3 调试工具和网络面板的使用习惯开发者工具里的 Network 面板比很多人想象的更重要。它能看到每次请求的 URL、请求方法、请求头、响应体、耗时。接口报错但页面没报错时第一时间开 Network 看返回的数据结构比在业务代码里逐个 console.log 高效得多。Console 面板也别浪费。我习惯在 request 封装的 success 和 fail 里加一个判断开发环境下console.log([request], url, params:, data)上线前把这种日志统一关掉。这样定位问题时请求参数一清二楚。还可以在 Network 面板里看请求详情中的响应内容微信官方有时候会把响应体用树形展示比看 JSON 字符串直观。不要小看这些使用习惯它们能帮你把定位问题的时间从半小时缩短到五分钟。真正到项目联调阶段接口联调 70% 的时间都在看 Network 面板和 Console 日志。7. 从 Day6 往后购物车与订单联动需要注意什么7.1 数据模型先想明白页面就是套模板Day6 做完下一步一般就是购物车和订单。这两个模块的代码量不大难点在数据关系。购物车里要保存菜品 id、数量、价格、规格信息还能实时改数量。如果在页面局部维护一堆零散变量到订单结算页就会开始混乱。我的建议是先把购物车的数据模型定下来比如用对象以菜品 id 为 key 存购物车项而不是用数组反复 find。时间允许的话把购物车数据持久化到本地存储用户退出页面再次进来时能恢复。注意放在 storage 里的购物车数据在提交订单成功后要记得清除否则用户会看到“下单成功但购物车还是满的”这种 bug。我在初期就踩过一次后来在支付成功回调里统一清除购物车本地数据才解决。7.2 提交订单的幂等和重复提交提交订单和上传图片不太一样它涉及服务端创建资源最怕用户手抖点了两下结果下了两单。解决重复提交的办法和列表加载锁差不多页面 data 里加一个submitting请求期间为 true按钮禁用。还有后端幂等设计通常会用唯一请求号小程序端生成一个 uuid 放在请求体里后端靠它去重。作为一个完整的实战项目做到订单接口时会遇到这个点提前了解它的原理联调时会顺手很多。7.3 这一天的实操给我留下的体会真要我总结这一天其实一句话就够了请求层写好了后面怎么都是顺的。Day6 之前我也觉得写页面更出成果但做完了才发现小程序的体验问题几乎都出在数据请求和交互反馈这些不起眼的地方。加载更多、图片上传、导航栏适配每一个单独拎出来都不复杂可它们组合在一起就是一个外卖小程序能不能被称为“产品”的分水岭。我自己在带项目的时候反复强调一件事练 Day6 一定不要只照着代码敲要主动把网络层封装的思路讲给自己听把分页触底的触发条件画出来搞清楚哪些 API 是同步、哪些是异步。这些基本功扎实了后面接订单、支付、评价的时候你就会发现自己已经能独立处理问题而不是每遇到一个报错都要去问一圈。拿今天这套代码多折腾几遍你会慢慢找到那种“知其然也知其所以然”的感觉这比把页面跑通本身更有价值。