简介面向小程序开发学习者与移动端爱好者这份源码以“东航订票”为业务场景完整呈现一款航班预订小程序的工程实现可用作课程设计、毕业设计或日常练手的参考项目。包内共157个文件涵盖wxml页面结构、wxss页面样式、js业务逻辑、json配置及less样式源文件并配套大量png图片与少量jpg、gif素材整体压缩包仅2.66MB轻便易下载。从内容预览看项目中包含加载动画、装饰图片、个人证件照等视觉素材说明对界面细节亦有处理。目前已有120人学习下载适合希望了解小程序目录规范、订票功能拆分、前端交互与配置写法的开发者。通过阅读源码可快速掌握小程序从页面搭建到逻辑联调的基本路径为独立开发同类项目打下基础。1. 东航订机票小程序源码先搞清楚这个 zip 包的边界这个“东航订机票.zip”不是传统网页项目而是一份微信小程序原生源码包。解压后你会看到一堆 jpg 和 gifhuabian、xfj、xfz、xxl、jpp 这些命名基本是按界面模块来的downIcon 和 upIcon 是排序箭头reviseIcon 是修改按钮。项目结构很典型pages 目录放页面utils 放请求封装image 放静态资源根目录有 app.js、app.json 和 .gitignore。适合刚入行移动开发的人拿来做机票预订流程的起步模板也适合老手快速搭一个带城市选择、航班列表、订单确认的 demo。要注意的是这类源码把很多数据写死在 page 里直接跑能通但要接真实服务得先拆数据层。2. 航班查询页城市选择、日期选择与查询参数组装机票查询页是整个源码里逻辑最密的一页。定位、选日期、选乘机人、发起查询每一步都有坑。下面按常见实现一层层拆。2.1 城市选择器的数据结构和联动逻辑机票查询页的门面就是两个机场选择器。多数小程序源码用的是原生picker组件而不是自己写半屏弹层。原生 picker 的优点是省事缺点是样式不能完全自定义。先给出一份城市数据模型// utils/cities.js const cities [ { code: SHA, name: 上海, airport: 虹桥, hot: true, letter: S }, { code: PEK, name: 北京, airport: 首都, hot: true, letter: B }, { code: CAN, name: 广州, airport: 白云, hot: true, letter: G } ] function filterCities(keyword) { if (!keyword) return cities return cities.filter(item item.name.includes(keyword) || item.airport.includes(keyword)) } module.exports { cities, filterCities }这里把code设计成机场三字码而不是城市名因为后端接口认的是三字码。很多 demo 源码直接拿中文名去请求遇到上海虹桥和上海浦东时会分不清。hot字段用于在弹层里显示“热门城市”分组letter字段是给右侧拼音索引用的。如果去掉这个字段做排序会麻烦很多。联动校验是常见遗漏点。如果不做用户可以选择同一个城市作为出发和到达生成查询请求时后端毫无意外会返回错误。所以要在bindChange里校验Page({ data: { cities: cities, from: null, to: null }, onFromChange(e) { const idx Number(e.detail.value) const from this.data.cities[idx] const to this.data.to if (to from.code to.code) { wx.showToast({ title: 出发和到达不能相同, icon: none }) return } this.setData({ from }) } })这里e.detail.value是 picker 选中项的下标不是城市对象要先转成数字再从数组取对象否则会拿到undefined。wx.showToast的icon: none是为了避免默认的 success 图标误导用户。注意在onToChange里也需要同样反向校验不要只写一半。原生 picker 有几个参数在源码里经常被写错列一个表属性类型作用常见坑点rangeArray数据源必须是数组对象数组要配range-keyrange-keyString对象数组时显示的字段用name而不是code否则显示三字码valueNumber当前选中项下标不传默认 0重置时容易掩盖真实选中项bindchangeEventHandler选择变化时的回调只在用户操作时触发代码 setData 不会触发如果后续要改成自绘城市弹层思路是用一个全屏蒙层加scroll-view右侧固定索引点击索引项时计算偏移量滚动到对应字母分组。这样做交互会更接近携程等票务应用但需要自己处理scroll-into-view的 id 冲突还要适配 iPhone 底部安全区。对于当前源码来说先用原生 picker 把流程跑通更合理后续再根据埋点数据决定是否重做。2.2 日期选择为什么不用原生 picker 而要自绘日历头部机票列表页的日期切换通常是横向滚动的 7 天栏而不是弹出一个巨大日历。原生picker的modedate虽然能选日期但要标注“低价”或“已售罄”就非常难所以源码里一般是在scroll-view里渲染一排日期卡片。核心代码是生成一个周日期数组// utils/date.js function buildWeekDates(baseDate) { const arr [] for (let i 0; i 7; i) { const d new Date(baseDate) d.setDate(d.getDate() i) arr.push({ label: ${d.getMonth() 1}/${d.getDate()}, date: ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)}, price: 0, disabled: i 0 }) } return arr } module.exports { buildWeekDates }label用于展示date是发给后端的参数。这里用padStart补齐月和日保证日期字符串是yyyy-MM-dd格式。很多后端接口对日期格式敏感如果漏了补零2025-7-1和2025-07-01可能被当成不同参数轻则查不到数据重则 500。disabled这里写的是i 0表示当天不能选因为航司的当日航班在查询接口中往往有截止时间。日期栏的 WXML 结构大概是scroll-view scroll-xtrue classdate-bar view wx:for{{weekDates}} wx:keydate classdate-item {{selectedDate item.date ? active : }} {{item.disabled ? disabled : }} bindtaponDateTap>onDateTap(e) { const date e.currentTarget.dataset.date if (this.data.selectedDate date) return this.setData({ selectedDate: date }) this.getFlights(date) }2.3 查询参数的组装与防抖查询参数最好收敛到一个函数里不要在页面里到处拼对象。机票查询常见参数有出发三字码、到达三字码、日期、成人数量、儿童数量、舱位function buildQuery(from, to, date, passengers) { if (!from || !to) return null if (from.code to.code) return null if (!date) return null return { depCode: from.code, arrCode: to.code, date: date, adultCount: passengers.adult, childCount: passengers.child, cabin: Y } }cabin: Y表示经济舱默认。如果后续要支持头等舱和商务舱在这个函数里加一个cabinType参数就行。乘客数量一般用stepper组件点击或-时e.detail.value是数字但如果你直接放进data再setData要注意类型是否被input转成字符串。可以加一句Number(e.detail.value)强行转换。搜索按钮的防抖处理let searchTimer null function handleSearch() { if (searchTimer) clearTimeout(searchTimer) searchTimer setTimeout(() { wx.navigateTo({ url: /pages/flight/list?depCode${from.code}arrCode${to.code}date${date} }) }, 300) }300 毫秒的延时可以过滤掉连续点击又不会让用户觉得卡顿。这里没有直接发请求是因为航班列表页通常是独立页面查询参数通过 URL 传过去再在onLoad里读取并请求航班接口。URL 中三字码都是字母不需要编码但为了稳妥可以对date做encodeURIComponent防止日期字符串里的横杠被某些容器转义。3. 航班列表渲染与筛选价格排序、余票状态和 loading 态航班列表页是用户停留时间最长的页面也是判断这个源码值不值得改的关键点。3.1 列表项的数据模型与 WXML 绑定航班列表页拿到接口数据后第一件事不是直接渲染而是把字段名标准化。后端可能返回airlineName也可能返回airline如果源码是前端 demo通常把数据写死在 js 里// pages/flight/list.js const flights [ { flightNo: MU5101, airline: 东方航空, depTime: 08:00, arrTime: 10:30, depAirport: 虹桥T2, arrAirport: 首都T2, price: 1280, discount: 0.6, remaining: 5 } ]remaining是余票数discount是折扣。需要谨慎处理的是价格单位后端接口经常返回“分”前端展示时除以 100下单时再传回“分”。如果两边单位不一致会出现展示价格和扣款价格不一样的问题。zip 里的源码如果写死了整数价格说明它只是 UI 演示接真实接口时要先确认单位。列表 WXML 的循环渲染view classflight-card wx:for{{displayFlights}} wx:keyflightNo view classtime-row text{{item.depTime}}/text text classarrow→/text text{{item.arrTime}}/text /view view classairport-row text{{item.depAirport}}/text text{{item.arrAirport}}/text /view view classprice-row text classprice¥{{item.price}}/text text classremaining wx:if{{item.remaining 0}}余票{{item.remaining}}/text text classsold-out wx:else售罄/text /view /viewwx:key不要用index因为在排序和筛选后列表位置会变化用 index 做 key 会导致小程序的 diff 算法错误最明显的表现是图片闪烁和输入框状态错乱。flightNo在这个场景里是唯一的所以用它做 key。wx:else是依附在wx:if上的中间不能有其他节点否则不生效。3.2 价格排序和条件筛选的边界排序按钮在源码里对应 upIcon 和 downIcon 两张图片点击后切换排序方向。排序逻辑function sortByPrice(list, order) { const clone [...list] clone.sort((a, b) { if (order asc) return a.price - b.price return b.price - a.price }) return clone }这里使用[...list]复制是因为Array.prototype.sort会原地修改数组。直接对this.data.flights排序会改变原始数据后续切换筛选条件时数据已经乱了。order用字符串asc/desc比用布尔值更可读也方便从页面 URL 参数中带上。筛选条件允许多选时使用 filter 组合更合适const source this.data.flights const filtered source.filter(item { const timeOk !this.data.timeRange || (item.depTime this.data.timeRange[0] item.depTime this.data.timeRange[1]) const airlineOk !this.data.airline || item.airline this.data.airline return timeOk airlineOk })!this.data.timeRange表示“没有选择这个筛选项时返回全部”。如果筛选项很多可以把这个判断抽成配置数组不要写一长串 if。注意item.depTime如果是字符串比较的是字典序08:00和8:00表现不同。后端返回时间字段时最好统一补零。表格里整理一下数组方法的行为差异方法是否修改原数组返回值在这个场景中的用途sort是原数组引用要先[...list]复制再排序filter否新数组组合筛选条件map否新数组给列表项加selected标记3.3 loading.gif 的正确打开方式zip 里的loading.gif通常被放在首屏发请求时显示回来时隐藏。很多人会在success回调里关 loading这会导致请求失败时 loading 一直转所以正确做法是放在complete里this.setData({ loading: true }) wx.request({ url: https://example.com/api/flights, data: query, success: res { this.setData({ displayFlights: res.data.data.flights }) }, fail: () { wx.showToast({ title: 网络异常, icon: none }) }, complete: () { this.setData({ loading: false }) } })WXML 中可以这样控制image wx:if{{loading}} src/image/loading.gif classloading-icon / view wx:else classflight-list.../viewloading初始值为true这样页面跳转后有一张 gif 顶着等数据回来再替换成列表。如果图片放在image目录下路径要写成/image/loading.gif不要用相对路径../../image/loading.gif后者在分包或自定义 tabBar 时容易失效。另外gif 在部分 Android 机型上会掉帧如果体验不 OK可以换成wx.showLoading的 loading 动画它的显示层级更高会盖在导航栏下面。还有一个常见需求是下拉刷新。在app.json的页面配置里打开enablePullDownRefresh: true然后页面里实现onPullDownRefresh。注意在请求完成后要手动调wx.stopPullDownRefresh()否则刷新动画会一直转。刷新期间和 loading 动画同时出现会乱一般做法是下拉刷新时只显示原生动画首次进入页面时才显示loading.gif。可以加一个isFirstLoad标志区分。3.4 上拉加载更多与空状态处理航班列表会做分页否则一次返回几十上百条数据渲染性能会很差。分页需要维护page和hasMore两个字段onReachBottom是页面滚动到底部时自动触发的不需要自己监听 scrollonReachBottom() { if (!this.data.hasMore || this.data.loading) return const page this.data.page 1 this.fetchFlights(page) }fetchFlights成功后新数据要用concat追加到旧的displayFlights后面而不是直接替换this.setData({ displayFlights: this.data.displayFlights.concat(res.data.data.list), page: page, hasMore: res.data.data.list.length 0 })hasMore为false时要显示“没有更多了”否则会一直触底请求。空数据也要单独处理不能只显示一个空白页面。常见做法是加一个view classempty暂无航班/view并且设置empty状态避免和 loading 同时出现。4. 登录与下单流程code 换 token、订单参数与支付预留位订票小程序真正卡人的不是展示页而是登录和下单这一段。源码往往只做了模拟数据接真实接口时全要重写。4.1 从 wx.login 到 token 的关键细节有的源码在进入首页时就弹wx.getUserProfile这是很多老项目的坏习惯。机票预订场景只有在下单前才需要登录所以应当把登录动作放在点击“立即预订”按钮时。登录链路分三步小程序端wx.login拿 code后端用 code 向微信接口换 openid 和 session_key再把 openid 映射为自己系统的用户并返回 token。// utils/auth.js function login() { return new Promise((resolve, reject) { wx.login({ success: async res { if (!res.code) return reject(new Error(login fail)) try { const { token } await request.post(/auth/login, { code: res.code }) wx.setStorageSync(token, token) resolve(token) } catch (err) { reject(err) } }, fail: reject }) }) }注意res.code有效期只有 5 分钟且只能使用一次拿到后必须立即发给后端。后端换取 openid 时需要的 appid 和 secret 只能放在服务器环境变量里绝对不要写进小程序代码。zip 源码里如果看到appid: your appid这种占位符直接替换成自己的secret不要动。登录态过期处理是另一个高频问题。接口返回 401 时很多源码只弹一个“请重新登录”的 toast但正确的做法是在请求拦截器里统一判断如果 401 就清掉旧 token重新调wx.login并把失败请求放进队列重放。demo 阶段可以先只做一步保证 token 失效时能跳回登录流程即可。4.2 订单确认页的参数收集与校验订单确认页要收集乘机人姓名、证件号、手机号、优惠券 id选乘机人时还会复用城市选择器的思路弹一个底部 sheet。先写一个证件校验函数function validateIdCard(id) { const reg /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X)$/ return reg.test(String(id).toUpperCase()) }这个正则只判断格式不判断校验位。真实项目里还要算前 17 位的加权因子不然伪造号码也能通过。如果源码里没有校验逻辑建议至少把空格和中间的·去干净再做匹配。手机号校验用/^1[3-9]\d{9}$/不要写得太宽避免后端收到一堆脏数据。订单参数在提交前统一组织字段类型说明flightNostring航班号用于锁定舱位segDatestring乘机日期yyyy-MM-ddpassengersarray乘客数组元素是{name, idCard}contactNamestring联系人contactPhonestring手机号couponIdstring优惠券 id可空remarkstring订单备注可空提交时给按钮加一个submitting状态防止重复点击const order buildOrder(this.data) if (!order) return this.setData({ submitting: true }) request.post(/order/create, order) .then(res { wx.navigateTo({ url: /pages/order/detail?id${res.data.orderId} }) }) .catch(() { wx.showToast({ title: 创建失败, icon: none }) }) .finally(() { this.setData({ submitting: false }) }).finally在微信开发者工具的基础库中已经支持不用像老代码那样在 success 和 fail 里重复写setData。如果项目运行的微信基础库版本较旧可以改用complete回调。按钮disabled属性也要绑定submitting不然用户双击还是能触发。4.3 为什么不直接放一个完整的 wx.requestPayment很多订单支付的源码只做到创建订单支付是空着的。原因很现实wx.requestPayment需要后端先调用微信支付下单接口生成prepay_id再对timeStamp、nonceStr、package、signType、paySign做签名其中paySign的生成需要商户 API 密钥。没有真实商户号前端就算写了这个 API 也会报requestPayment:fail。所以源码里通常只是预留了支付按钮的位置。如果之后要接支付基本流程是wx.requestPayment({ timeStamp: payData.timeStamp, nonceStr: payData.nonceStr, package: payData.package, signType: MD5, paySign: payData.paySign, success: () { wx.redirectTo({ url: /pages/result/success?orderId${orderId} }) }, fail: err { if (err.errMsg.includes(cancel)) return wx.showToast({ title: 支付失败, icon: none }) } })payData全部来自/order/create返回结果不要在前端自行拼接。signType不一定都是 MD5如果后端配置了 RSA 或 HMAC-SHA256要跟着改。err.errMsg里包含cancel时是用户主动取消不要当成支付失败弹 toast否则用户会在支付面板里反复看到“支付失败”弹窗。另外wx.redirectTo会关闭当前支付确认页保证用户不能返回重复支付。5. zip 转 demo 的最后一公里导入工具、图片处理和备案备注资源包拿到手真正跑起来只需要几步但每一步都有对应的报错处理。5.1 从 zip 导入微信开发者工具先把 zip 解压到纯英文目录项目文件夹名不要带空格和中文。打开微信开发者工具选择“小程序”点“导入”目录选中到包含app.json的那一层。如果报错app.json: File not found多半是选错了目录选中了外层文件夹。如果没有 AppID可以选择测试号但wx.requestPayment、wx.getUserProfile等接口会受限。zip 源码里的project.config.json如果带了 appid那也是别人的要改成自己的再上传。5.2 图片资源去重与 CDN 化zip 里的 jpg 资源体积通常不小小程序主包有 2MB 限制所以最好把静态图片转成 CDN URL。downIcon.jpg、upIcon.jpg这类箭头图标可以换成 iconfont 或 SVG不要用位图。gif 只保留一张 loading 图其他动效能用 CSS 实现的就不要放图片。.gitignore里至少要包含node_modules/和project.private.config.json后者是本机配置提交到仓库会和队友互相覆盖。5.3 小程序备案备注信息怎么填备案是上线前绕不开的一步。这个源码的备注可以参考本小程序用于展示航班查询与机票预订流程页面包含航班信息查询、价格展示、订单信息填写不涉及虚拟商品支付与用户生成内容。注意不要写“测试”“demo”这类词审核人员会认为不是正式服务。如果图片素材用的是东航相关 logo备案提交前要确认是否有授权否则换成自己拍摄或可商用的素材再上线。本文还有配套的精品资源点击获取