
复盘了一下苍穹外卖这个项目的整体进度今天是Day6目标是把C端小程序端跑通核心下单链路。前五天基本把管理端那套CRUD、菜品分类、口味管理、套餐管理都做完了也从数据库设计一路写到了后台接口联调。今天开始切到微信小程序端这部分的坑跟管理端完全不同尤其涉及微信生态的登录态、请求封装、分页加载这些每一个都是实战中绕不开的坎。如果说前五天的重点是“管理后台能不能满足运营需求”那Day6的核心就是“用户能不能在小程序里顺畅地把外卖点完”。这个顺畅不是页面好看就行而是首次登录要不要授权、列表数据能不能滚动加载、图片上传服务器之后回显是不是正常、token过期了会不会直接白屏。这些问题看着不大但任何一个没处理好用户都会在第一步流失掉。今天这篇就把小程序端从零到一搭起来的完整过程和踩坑点记录下来。1. 项目进度梳理与Day6目标拆解1.1 苍穹外卖项目整体到Day5都做了什么先交代一下背景方便后面对照着看。苍穹外卖这个项目是典型的前后端分离架构管理端用的是SpringBoot MyBatis Plus数据库MySQL管理端页面用Vue3 Element Plus做了一套基础的增删改查页面。前五天实际上做的事情是建表设计、菜品管理、分类管理、口味管理、套餐管理、以及一套基础的登录框架。换句话说管理端的基本盘已经立住了店铺能上架商品、能管理分类、能设置套餐后台数据已经能支撑日常运营。但是外卖系统只做管理后端是完不成闭环的。用户端没有入口用户就看不到菜品、下不了单整个业务逻辑就断在半路。所以Day6开始重心转向用户端微信小程序这一步是把整个项目从“一个后台系统”变成“一个能跑的外卖业务”的关键节点。今天的任务说白了就是两件事第一把小程序的项目骨架搭起来第二把用户端浏览菜品的完整链路走通包括登录、首页数据展示、分类联动、菜品列表的加载与分页。1.2 今天具体要做哪些模块第一梯队是基础设施包含小程序的目录结构初始化、微信登录流程接入、全局请求封装。因为小程序端的所有页面和数据请求都要依赖这三个东西它们是后续一切功能的地基。很多新手上来就写页面写到一半发现每个页面都在重复写wx.request而且登录状态没法统一管理回头再补封装的时候会非常痛苦。第二梯队是首页主要包含顶部导航栏适配、搜索框、轮播图、分类区域、菜品列表。首页是用户进入小程序的第一个落脚点体验好坏直接影响留存。这块做得糙一点用户滑两下就走了连注册登录的意愿都没有。第三梯队是核心列表逻辑即分类切换菜品、滚动分页加载。外卖列表的特点是数据量不会像电商那样动不动上百条几十条菜品也要做分页不然一次性加载几十张菜品图片用户手机流量直接告急等待时间也会非常影响体验。第四梯队是在具体页面上会用到的辅助能力比如本地上传图片、用户离开小程序的状态监听。这些今天先做基础实现后续的订单、购物车模块会继续复用今天搭的这个底座。2. 小程序项目初始化与目录结构设计2.1 为什么小程序端要单独建一套项目而不是复用管理端结构很多从前后端分离过渡到小程序开发的同学第一反应是“我已经有管理端的目录了把页面搬过去改改就行”。这个思路放到小程序端是非常危险的。管理端的Vue项目跑在浏览器里有完整的DOM、路由、状态管理库小程序跑在微信的宿主环境里没有DOM概念页面渲染和逻辑层是分开的路由也没有beforeEach那种全局守卫很多浏览器端理所当然的东西在小程序里根本不存在。说得再直白一点Vue项目里的每一个组件、每一条路由、每一个状态管理的思路在小程序里都可能要走一遍“翻译”。与其硬移植不如直接按小程序的规范重新搭一套把后端接口能力对接好页面按小程序的组件模型来设计。Day6我把目录结构设计成下面这样sky-take-out-mini/ ├── assets/ │ └── images/ # 静态资源 ├── components/ │ ├── dish-card/ # 菜品卡片组件 │ └── search-bar/ # 搜索框组件 ├── request/ │ ├── http.js # 请求核心封装 │ └── api.js # 接口统一管理 ├── pages/ │ ├── index/ # 首页 │ ├── category/ # 分类页 │ ├── cart/ # 购物车 │ ├── order/ # 订单 │ └── profile/ # 个人中心 ├── utils/ │ ├── auth.js # 登录态管理 │ └── format.js # 格式化工具 ├── app.js ├── app.json └── app.wxss这个结构看起来简单但每层都是按职责分的。components只放可复用UIpages放页面级逻辑request单独抽一层utils存纯函数。最核心的一点是请求单独封装页面永远不直接调用wx.request后续加拦截器、加统一错误处理全都在这一个地方搞定。2.2 app.json的全局配置细节小程序能跑起来全局配置文件是最不能被忽视的。app.json里page窗口表现、tabBar导航栏、网络超时时间这三项是要重点设置的。我Day6最开始踩了一个小坑tabBar里配置的图标路径写的是相对路径结果一直白屏排查了十分钟才发现是路径前面少了个斜杠。这种低级错误在开发工具里不报错但真机预览时会出现整个页面无法加载的问题遇到这种诡异白屏的时候优先检查app.json里的路径。{ pages: [ pages/index/index, pages/category/category, pages/cart/cart, pages/order/order, pages/profile/profile ], window: { navigationBarBackgroundColor: #ffc300, navigationBarTextStyle: black, navigationBarTitleText: 苍穹外卖, backgroundColor: #f5f5f5 }, tabBar: { color: #999999, selectedColor: #ffc300, list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/category/category, text: 分类 }, { pagePath: pages/cart/cart, text: 购物车 }, { pagePath: pages/order/order, text: 订单 }, { pagePath: pages/profile/profile, text: 我的 } ] }, networkTimeout: { request: 10000, uploadFile: 15000 }, style: v2 }这里networkTimeout是真的会救命的。微信开发者工具里默认超时时间可能是60秒但真机上如果接口10秒内没响应用户早就退出去了。设置成10秒比较合理既能给慢网络留余地也不会无限等待。再配合后端接口的自身超时控制用户体验才不会走到“卡死”的境地。3. 微信登录与请求封装全流程3.1 wx.login() 登录流程不能只在页面里调一次就完了苍穹外卖小程序端的登录设计遵循微信生态的标准流程。首次进入小程序前端拿到wx.login()返回的code把这个code通过后端接口发给自己的服务器后端拿code去微信的接口换取openid和session_key然后生成自己的登录态token返回给前端。前端把token存到storage里后续所有请求带上这个token后端通过token识别用户身份。这个流程里有一个很隐蔽的坑wx.login()拿到的code有效期只有5分钟而且只能用一次。很多同学的写法是每次打开小程序都无脑调wx.login()拿新code然后发给后端换token。这在后端session机制下会导致之前的下发token全部失效用户在操作过程中会被莫名其妙地顶下线。正确的做法是首次登录或者token过期时才调wx.login()平时直接用存量token发请求。// utils/auth.js const TOKEN_KEY sky_token; function getToken() { return wx.getStorageSync(TOKEN_KEY); } function setToken(token) { wx.setStorageSync(TOKEN_KEY, token); } function removeToken() { wx.removeStorageSync(TOKEN_KEY); } function login() { return new Promise((resolve, reject) { wx.login({ success: (res) { if (res.code) { wx.request({ url: https://api.example.com/api/user/login, method: POST, data: { code: res.code }, success: (response) { const token response.data.token; setToken(token); resolve(token); }, fail: reject }); } else { reject(new Error(微信登录失败)); } }, fail: reject }); }); } function ensureLogin() { const token getToken(); if (token) { return Promise.resolve(token); } return login(); } module.exports { login, ensureLogin, getToken, setToken, removeToken };很多人会问为什么这里要包一层Promise而不是直接在页面里写wx.login因为登录状态会被几乎每一个页面依赖。首页要用户信息、加购物车要用户token、下单更要校验登录态。如果每个页面都自己调一次wx.login代码会分散到没法维护。封装成ensureLogin之后任何页面要用户凭证都走同一个入口统一处理、统一过期刷新后续维护成本会低非常多。3.2 全局请求封装统一拦截器才是正解wx.request在小程序里的地位等同于axios在Vue里的地位但它比axios原始得多连拦截器都没有。如果不在底层做统一封装每个页面写一遍请求、每个请求单独处理错误代码会瞬间失控。我的封装方案是基于Promise做一层薄封装把baseURL、header、token注入、错误拦截、loading管理全部收口到一个文件里。// request/http.js const { getToken, ensureLogin } require(../utils/auth); const BASE_URL https://api.example.com/api; function request(path, method, data, options {}) { return new Promise((resolve, reject) { const token getToken(); const header { Content-Type: application/json, Authorization: token ? Bearer ${token} : }; if (options.loading ! false) { wx.showLoading({ title: options.loadingText || 加载中..., mask: true }); } wx.request({ url: BASE_URL path, method: method || GET, data: data || {}, header, success: (res) { if (res.statusCode 200) { const body res.data; if (body.code 1) { resolve(body.data); } else if (body.code 401) { // token过期重新登录后重试 removeToken(); ensureLogin().then(() { request(path, method, data, options).then(resolve, reject); }); } else { wx.showToast({ title: body.msg || 请求失败, icon: none }); reject(body); } } else if (res.statusCode 401) { // 无权限重新登录 removeToken(); ensureLogin().then(() { request(path, method, data, options).then(resolve, reject); }); } else { wx.showToast({ title: 服务器异常, icon: none }); reject(res); } }, fail: (err) { wx.showToast({ title: 网络异常请检查网络, icon: none }); reject(err); }, complete: () { if (options.loading ! false) { wx.hideLoading(); } } }); }); } module.exports { get: (path, data, options) request(path, GET, data, options), post: (path, data, options) request(path, POST, data, options), put: (path, data, options) request(path, PUT, data, options), del: (path, data, options) request(path, DELETE, data, options) };这套封装有几个关键点值得说一下。第一个是loading管理页面正常情况下都会展示加载中但有某些场景比如上拉加载更多不希望每次都弹loading会通过options.loading:false关掉这种按场景可配置的设计在真实项目里非常实用。第二个是自动重试逻辑401的时候自动清token重新登录后再把原请求重放一遍用户无感完成登录态刷新。第三个是后端响应体的约定苍穹外卖后端约定code为1表示成功、其他code表示失败所以前端封装的这个判断逻辑跟后端是严格对应的。3.3 为什么接口要统一收敛到api.js管理除了请求封装接口路径的集中管理同样重要。我见过太多项目把接口URL直接写在页面里后面后端改了base路径前端要把每个页面翻一遍。api.js的好处是接口定义收敛到一处改动只动一个文件同时能清晰看到当前项目到底对接了多少后端能力后续排查问题时有全局视角。// request/api.js const { get, post, put, del } require(./http); module.exports { // 登录 login: (data) post(/user/login, data), // 首页 getBanners: () get(/home/banner), getCategories: () get(/category/list), getDishList: (params) get(/dish/list, params), // 上传图片 uploadImage: (filePath) { // 上传要走wx.uploadFile不能走wx.request单独处理 } };写到这里必须额外提醒一下上传文件的接口不能走上面这套request封装。wx.uploadFile和wx.request是两套API前者专门处理multipart/form-data可以携带文件流并同时附带额外formData参数。我习惯在api.js里把uploadImage单独导出一个函数因为这个操作跟普通JSON请求的差异太大硬塞进通用request里只会把封装搞复杂。4. 页面顶部导航栏高度适配4.1 为什么顶部导航栏的高度不能写死小程序的顶部导航栏由两部分组成状态栏和导航栏。状态栏就是手机顶部显示电量、时间的那一小条导航栏是承载页面标题和返回按钮的那一条。安卓和iOS的刘海屏、挖孔屏、胶囊按钮的位置都不尽相同如果写死一个高度可能出现标题被挖孔遮挡或者返回按钮和胶囊重叠。所以最好的方案是让小程序自己读取系统信息动态计算导航栏高度。当初我第一次做小程序时直接在wxss里写了navigationBarHeight: 44px结果在iPhone 13上正常换到小米手机上胶囊按钮直接把页面标题顶到看不见。后来才明白导航栏真实高度要这样算// utils/system.js function getNavigationBarHeight() { const systemInfo wx.getSystemInfoSync(); const menuButton wx.getMenuButtonBoundingClientRect(); // 导航栏高度 胶囊按钮高度 上下各留8px间距 const navigationBarHeight menuButton.height (menuButton.top - systemInfo.statusBarHeight) * 2; return { statusBarHeight: systemInfo.statusBarHeight, navigationBarHeight, // 菜单按钮位置信息 menuButton }; }这个计算逻辑的原理是微信官方说要保持导航栏内元素和胶囊按钮垂直居中所以拿胶囊按钮的top减去状态栏高度得到的是胶囊距状态栏底部的间距上下各留相同间距再加上胶囊本身的高度就是整个导航栏的精确高度。为了省事也可以distory后存到globalData里页面直接读取。4.2 自定义导航栏的完整方案Day6我选择了自定义导航栏而不是用系统默认的。原因是默认导航栏的样式灵活性很低背景颜色、文字大小都受限且无法在页面上叠加自定义搜索框或其他组件。自定义导航栏的思路是在app.json的window配置里把navigationStyle设为custom让整个顶部区域都交给页面自己布局。{ window: { navigationStyle: custom } }然后在页面里根据上面算出的高度铺一个定位容器里面放状态栏占位、标题、以及可扩展的其他内容。比如首页想在导航栏里直接放搜索框就把搜索框组件跟导航栏做在一起。需要注意Custom模式下页面内容会顶到屏幕最上方所有页面的第一个元素都要主动避开这个高度否则内容会被状态栏盖住。我统一封装了一个占位组件所有页面开头都放它后续新增页面也不会忘了适配。5. 首页数据加载与分类联动5.1 首页接口返回结构的设计进入首页第一个要处理的问题是数据接口怎么定义。大部分外卖小程序的首页其实由好几个业务块组成轮播图、公告、分类、菜品列表。最直观的做法是后端为一个页面聚合一个接口一次返回所有数据。这个小项目当时选择的是分接口拉数据各区块通过请求并发操作可以各自维护后续运营改动一个模块不会影响其他模块。这里多说一句接口设计没有绝对的标准页面复杂度低时聚合接口更省事业务复杂了拆开更灵活。苍穹外卖的首页我采用的是轮播图和分类列表分别走独立接口菜品列表和分类联动单独走分页接口。首页的onLoad会并行发起三个请求虽然请求数量多了但是每个接口返回的数据量更小失败也能隔离单点。5.2 分类联动菜品列表要处理排序和切换的竞态分类联动这块今天花了最多时间调优。用户点击左侧分类右侧菜品列表要整体换成该分类下的菜品。听起来简单但这个交互里有几个容易出问题的细节。第一个细节是点击分类时页面状态的处理如果用户手速快快速切换了三个分类可能出现先点的分类响应慢、后点的分类响应快结果页面最终显示的是先点的分类数据这是典型的网络竞态问题。处理竞态的办法也不复杂在onLoad里用一个loading状态标记当前请求请求返回后先判断当前选中分类是否还是发起请求时的那个分类如果不是就丢掉这次数据。页面代码加上一个id标记即可做到// pages/index/index.js Page({ data: { categories: [], currentCategoryId: 0, dishList: [], loading: false, page: 1, pageSize: 10, hasMore: true }, switchCategory(e) { const id e.currentTarget.dataset.id; this.setData({ currentCategoryId: id, dishList: [], page: 1, hasMore: true }); this.loadDishList(true); }, loadDishList(isRefresh false) { if (!this.data.hasMore || this.data.loading) return; this.setData({ loading: true }); const requestId this.data.currentCategoryId; api.getDishList({ categoryId: requestId, page: this.data.page, pageSize: this.data.pageSize }).then((res) { // 关键检查确保返回时当前分类没有变 if (requestId ! this.data.currentCategoryId) return; const list isRefresh ? res.list : this.data.dishList.concat(res.list); this.setData({ dishList: list, hasMore: res.list.length this.data.pageSize, page: this.data.page 1 }); }).finally(() { if (requestId this.data.currentCategoryId) { this.setData({ loading: false }); } }); } });这段代码里最重要的就是那个requestId判断。处理完竞态问题之后页面切换分类的稳定性会好很多。还有很多项目会在这里搭配一个骨架屏方案让用户在数据没返回时看到骨架而不是空白。不过骨架屏需要额外写一套组件Day6我暂时用loading动画顶着后续有时间再迭代。5.3 列表加载更多时wxss和wxml配合关于加载更多的UI交互我的习惯是底部放一个状态提示。一共有三种状态加载中转圈或文案提示、已加载全部显示没有更多了、加载失败提供重试。这三种状态的处理逻辑如果分散在每个页面后期改起来工程量不小。但这天我采用的是最简单的方案底部的“上拉加载更多”通过onReachBottom触发触底时回调会自然执行分页加载。有几个注意事项需要特别说明。onReachBottom默认触底距离是50px可以通过在app.json的window里配置onReachBottomDistance来调整但对这个项目来说默认值就够用。另外一个真正要小心的是如果页面里同时存在scroll-view和原生页面滚动onReachBottom只在页面级滚动时触发scroll-view内部的滚动到底不会触发这个钩子如果列表外层套了scroll-view分页加载就会失效。6. 本地上传图片功能实现6.1 上传图片前先压缩和校验后端也要配合Day6另一个任务是实现“本地上传图片”。这个功能在评论、店铺头像、菜品反馈这些场景下都会用到。小程序端的wx.chooseMedia支持从相册选择或直接拍照拿到临时文件路径之后先做校验再调用上传。需要注意的一点是wx.chooseMedia选出来的图片体积可能很大现在手机随便拍一张都三五MB。如果直接上传到后端图片处理服务器的压力、流量消耗都是问题。所以在客户端做一个压缩是非常必要的。小程序支持用canvas压缩图片也可以用wx.compressImage接口这个接口是官方提供的专门做压缩能力的API压缩质量参数可调。我采用的策略是超过500KB的图片先用compressImage压到500KB以内再上传。function compressAndUploadImage(filePath) { return new Promise((resolve, reject) gt; { wx.compressImage({ src: filePath, quality: 80, success: (res) gt; { // 压缩完成校验大小 const fs wx.getFileSystemManager(); fs.getFileInfo({ filePath: res.tempFilePath, success: (info) gt; { if (info.size gt; 1024 * 1024) { wx.showToast({ title: 图片不能大于1M, icon: none }); reject(new Error(图片太大)); } else { uploadFile(res.tempFilePath); } } }); }, fail: reject }); }); }还有一个细节是上传图片时后端返回的是图片的URL路径但小程序端在真机上访问这个URL需要网络权限配置。开发调试阶段把项目详情里“不校验合法域名”的勾选打开就能在开发者工具中正常访问本机后端。但真机预览时如果不做域名配置图片会加载不出来。这个真想提到生产环境必须把后端域名配到微信公众平台的后台“request合法域名”和“uploadFile合法域名”里图片访问域名也要配同时要求后端接口必须走HTTPS。6.2 wx.uploadFile和wx.request在请求头处理上的差异调用wx.uploadFile时的header处理和wx.request几乎完全不同这是很多同学踩坑的高发区。wx.request默认Content-Type是application/json加token在header里直接写Authorization即可。但wx.uploadFile是multipart/form-data格式如果在header里手动设置Content-Type会导致boundary缺失后端解析请求体直接失败。function uploadFile(filePath) { const token getToken(); return new Promise((resolve, reject) { wx.uploadFile({ url: BASE_URL /upload, filePath, name: file, // 这里只传Authorization不手动设置Content-Type header: { Authorization: token ? Bearer ${token} : }, formData: { // 额外的业务参数比如图片类型 type: dish }, success: (res) { const data JSON.parse(res.data); if (data.code 1) { resolve(data.data.url); } else { reject(new Error(data.msg)); } }, fail: reject }); }); }必须强调wx.uploadFile里header不用也不能手动写Content-Type。微信底层会自动加上带boundary的multipart声明一旦你手动设置上传接口大概率返回415。这个坑我印象深刻当时是服务端同事排查了半天最后发现是前端手动设置了Content-Type导致的纯前端一个多余的设置居然坑了整个联调流程。7. 用户离开小程序时机的监听7.1 onHide和onShow在业务上的实际区别外卖小程序跟其他工具类小程序有个很大的区别用户可能中途切出去回个消息或者接个电话再回来继续下单。这种情况下如果小程序的页面状态没有处理好可能用户切回来发现购物车没了、列表状态混乱了。Day6预留的这个离开与返回监听能力在后续做订单状态刷新、购物车数据恢复时会派上大用场。页面Page里自带onHide和onShow两个生命周期。onHide是页面被切换到后台比如用户按Home键退出了小程序时触发onShow是页面重新回到前台时触发。很多新手会把这俩跟app.js里的onHide/onShow搞混。app.js里的onHide是“小程序整体切后台”时触发页面的onHide则是“当前页面被隐藏”时触发包括但不限于小程序切后台、跳转其他页面、打开半屏的弹窗。判断用户是离开小程序还是只是页面跳转不能在页面钩子里单独判断要结合app.js的onHide看先后顺序。7.2 用app.js统一管理前后台切换状态更合理的方案是在app.js里维护一个全局的isAppBackground状态同时在页面onShow里结合这个状态判断是否需要刷新数据。比如用户从后台回小程序切到购物车页就应该重新请求一次购物车最新数据而不是展示切出前的旧数据。// app.js App({ globalData: { isAppBackground: false }, onHide() { this.globalData.isAppBackground true; }, onShow() { this.globalData.isAppBackground false; } });然后在页面的onShow里做判断onShow() { const app getApp(); if (app.globalData.isAppBackground) { // 用户刚刚从后台回到小程序刷新当前页数据 this.refreshData(); } }之所以要用这种方式是因为页面onShow触发时无法知道用户是从小程序外切回来的还是从另一个页面返回的。有了这个全局标记辅助判断才能区分两种情况。如果不做区分每切回一个页面都请求一次接口页面栈里的数据全都要刷新对用户体验反而是一种伤害。8. 常见问题与排查技巧实录8.1 真机预览接口请求失败开发者工具却正常这是几乎每个人都会遇到的经典问题。开发者工具里所有接口都通一上真机所有请求全部失败报net::ERR_CERT_COMMON_NAME_INVALID或网络错误。最常见的原因是开发者工具默认不校验合法域名真机则会强制校验。解决办法分两步第一步开发阶段可以在微信公众平台把“不校验合法域名”打开但只能在开发调试版生效第二步真正对接生产环境时务必把HTTPS域名配置到后台白名单里并确认SSL证书是正规机构签发的微信不认自签证书。还有一小概率是后端接口返回的header带特殊字段触发了微信的安全机制。之前遇到一次后端在header里返回了token刷新标记前端在开发者工具里能读到真机上被微信安全层拦截了表现也是请求失败。这类问题排查时直接打开后端日志看有没有接受到请求比看小程序端的报错信息有用得多。8.2 请求封装好了但仍然偶发401重试死循环按前面封装的逻辑请求遇到401会自动清token、重新登录、再重放原请求。但有一种极端情况就是登录接口本身返回的就是401。一旦这种情况出现整个请求链会陷入递归死循环每次调登录接口都401然后无限重试用户手机电量悄悄被耗尽接口还一直占用。要规避这个问题循环入口的登录接口必须排除在重试逻辑之外。我后来在request函数里加了一个参数专门标记当前重试的次数超过两次直接提示“登录状态异常请重新打开小程序”并清空本地所有状态。这个兜底逻辑非常重要尤其项目上线以后用户网络环境复杂、后端并发压力大偶发401的频率比想象中高没有兜底真的会出事。8.3 分页加载数据重复问题不在前端也不在后端分页数据重复是列表加载里常见问题。后端接口正常返回了page参数对应的数据前端拼接的时候却发现每页尾部会出现上一页的数据甚至在并发请求的场景下页面会出现数据错乱。这个问题的根源往往出在“重复触发加载”上。onReachBottom在你把页面滑到接近底部的时候触发一次如果用户手还在继续滑动可能再次触发onReachBottom导致同一页的数据被请求了两次。我采用的方案是加一个loading锁请求没完成时不再触发第二次加载。这个锁在代码里其实就是if (this.data.loading) return。但只加锁还不够有时候点击分类切换很快时上一次请求还没结束就切了新分类原先分类的请求回来会被丢弃而新分类的请求如果晚到又可能被当成旧请求丢弃导致页面数据空白。这里要用前面提到的requestId竞态判断同时保留一个页面级标记来控制当前数据归属两个机制配合使用才能让分页加载稳定。8.4 缓存导致图片上传成功后页面显示旧图小程序里的图片缓存策略是同一路径的图片短时间内不会重新请求即使在服务端图片内容已经发生了替换。这会导致用户上传完新图片页面回显的还是旧图造成“上传失败”的错觉。破局方案是给图片路径加上时间戳参数const url ${fileUrl}?t${Date.now()};这种加时间戳的方式在服务端不识别的时候会造成多余参数但一般后端不会拦截query参数图片服务也能正常返回。如果后端对路径校验严格就换成用缓存清理接口或者让后端在图片更新时生成新的随机文件名。苍穹外卖这里后端生成文件名时本身就带了时间戳所以这个坑反而没踩到但仍值得记住。8.5 顶部自定义导航栏遮挡页面内容自定义导航栏后页面第一个元素会直接顶到屏幕顶部被状态栏覆盖。有人会问为什么不用默认导航栏呢因为默认导航栏做不到背景渐变、做不到导航栏内嵌搜索框。所以还是回到自定义的方案上。处理方式很简单全页面在wxml里引入一个占位组件组件高度取statusBarHeight加navigationBarHeight之和。这个占位组件放在每个页面最顶部后续所有页面都统一遵守这个规范就不会出现内容被遮挡的问题。末尾再加一个使用建议iphone和安卓的状态栏高度不同用wx.getSystemInfoSync拿到statusBarHeight直接赋值是最稳的。不要用CSS的env(safe-area-inset-top)去硬适配微信小程序的CSS变量兼容性没那么理想真机经常拿到不准确的值。今天Day6的内容核心工作就是把小程序端的地基打牢。登录流程、请求封装、导航栏适配、分页加载、图片上传这些能力在后续做购物车、订单列表、个人中心时都会反复用到。按目前的进度明天可以着手开始做点餐流程的完整串联了购物车加菜、下单、结算这套主流程跑通之后整个苍穹外卖项目基本就到了可以拿出去演示的状态。