旅游客户端和电商客户端最大的差异,是「日期」是一个贯穿全站的强状态。电商用户进来就能看商品,旅游用户不选日期什么都看不了——因为价格和是否有房都取决于日期。
第一版把日期当成一个普通的筛选项,出了一串问题。
一是快速改日期导致数据错乱。用户连续点了三次日期,发了三个请求,第一个请求最慢最后返回,把最新的结果覆盖了——界面上显示的是第一次选的日期的价格,而日历上高亮的是第三次的日期。用户看到的价格和日期对不上。
二是日期状态散落在各页面。列表页有一份、详情页有一份、下单页又有一份,用户在详情页改了日期返回列表,列表还是旧日期。更糟的是下单时用的日期和用户最后看到的不一致。
三是日历上不展示价格,用户要逐个试。用户想找便宜的日子,只能选一天看一次价格,来回切十几次。把每日价格直接画在日历上,是这个业务里体验提升最大的一处。
四是时区把日期搞错了一天。客户端用本地时间构造日期对象,用户在东八区订东京(东九区)的酒店,跨零点时算出来的日期差一天。
所以四个设计:请求竞态用序号控制、日期提升为全局唯一状态、日历上展示每日价格与售罄、日期用纯日期字符串不用时间戳。
YYYY-MM-DD 是纯日历概念,本身不带时区,传递和比较都不会出错。一旦用了 Date 对象或时间戳,任何一层做了时区转换就会错开一天,而且这类 bug 只在特定时区用户身上出现,几乎无法复现。为什么自绘日历而不用现成组件。现成的日历组件几乎都不支持「在格子里显示价格」和「按日售罄置灰」,而这两个正是旅游业务的核心需求。改造现成组件的成本超过自绘,而且现成组件的跨端表现不一致(小程序和 App 的渲染差异)。代价是要自己处理跨月、闰年、区间高亮、无障碍这些细节,工作量不小。判断依据是「核心需求是否被现成方案覆盖」——如果只需要选单个日期,绝对应该用现成的。
一次拉三个月的价格数据有体积代价。90 天 × 每天一个价格和状态,压缩后不大,但如果一个酒店有十个房型就是 900 条。所以日历上只展示「当日最低价」,由服务端聚合后返回,客户端不做聚合。这既减少了数据量也避免了前端算错。
踩过的坑:请求竞态导致价格和日期对不上。用户快速改日期,三个请求并发,最慢的那个最后返回覆盖了界面。用户看到的是 A 日期的价格、日历高亮的是 C 日期,然后按这个价格下单,结果实付金额不同,投诉。修法是序号比对丢弃过期响应。这个坑的通用教训是:任何「用户输入驱动的异步查询」都必须处理竞态,搜索联想、筛选、分页都是同一类问题。
踩过的坑二:用 new Date('2025-10-01') 解析日期,在部分端上差一天。这个字符串被当成 UTC 零点解析,转成本地时间后在负时区就变成了 9 月 30 日。而不同端的 JS 引擎对无时区日期字符串的处理还不完全一致。修法是全程不把日期字符串转成 Date 对象——比较用字符串比较(YYYY-MM-DD 的字典序就是时间序),加减天数用专门的纯日期工具函数。这是日期处理里最经典的坑,能讲出来说明真踩过。
没做的部分:没做「灵活日期」搜索(比如「10 月任意三天,帮我找最便宜的」)。这需要服务端做区间扫描,客户端要展示多个候选区间,交互设计复杂度高,当时没做。
日历首次渲染耗时:在组件挂载前后打时间戳,量「数据到手 → 三个月格子渲染完成」的时间。约 120ms。要说明是多少个格子、是否含价格、测试机型——低端机上会明显更慢,只报好机型的数字不真实。
切换日期后的可感知延迟:量从「用户点击确认日期」到「列表首屏内容更新完成」。约 350ms(含一次网络请求)。要区分「命中缓存」和「实时查询」两种情况分别报。
「旧响应覆盖问题未再出现」怎么验证:构造场景测试——用工具让第一个请求延迟 3 秒返回、后续请求正常,然后快速连续切换日期三次,检查最终展示的数据是否对应最后一次选择。改造前每次都能复现,改造后未再出现。这类竞态问题用确定性场景验证,不要用比率。
不要报「预订转化率提升 X%」。那受价格、供给、活动影响,不是客户端优化能独立归因的。可以报的是「日历上展示价格后,用户切换日期的平均次数下降」——如果有埋点数据的话,这个指标和这次改动的因果关系相对清晰。
new Date('2025-10-01')——这个字符串被当成 UTC 零点解析,转成本地时间后在负时区变成了 9 月 30 日,而且不同端的 JS 引擎处理还不完全一致。正确做法是全程用 YYYY-MM-DD 字符串:比较直接用字符串比较(这个格式的字典序就是时间序),加减天数用专门的纯日期工具函数,绝不转成 Date 对象。另外「今天」要用服务端下发的目的地当前日期,客户端本地时间既可能被改也可能时区不对。
旅游业务的价格不在我们手上——一部分来自供应商实时接口,随时可变。这带来一个电商很少遇到的问题:用户看到的价格和他点下单时的价格可能不一样。
第一版为了列表页快,用了缓存价格,然后直接拿这个价格去下单。三类事故。
一是价格不一致的投诉。列表显示 500,下单扣了 550。用户认为平台虚假标价,这不只是体验问题,在部分市场是监管风险。
二是前端算金额算错了。总价、税费、优惠后金额都在前端算,用浮点数乘除,出现「三晚 199.99 显示成 599.96999」这种情况。而且前端算出来的金额和服务端算的不一致,以谁为准说不清。
三是多币种展示混乱。写死了货币符号在前面、小数点两位、逗号做千分位,结果在部分地区完全不符合当地习惯(有的用点做千分位、日元没有小数位)。
所以三个设计:价格分层(列表参考价、详情实时价、下单前二次确认)、前端绝不做金额运算、格式化统一走 Intl 按 locale 处理。
price * nights,然后出精度问题或者和服务端算的不一致。连「单价乘天数」都由服务端算好返回。二次确认会降低转化,这是明确的代价。多一次弹窗、多一次点击,一部分用户会在这里流失。但价格不一致的代价更大——客诉、退款、平台信誉、甚至监管问题。取舍方向在支付相关的场景必须是「宁可慢一点、宁可少成交,不可扣错钱」。缓解手段是把价格变动的发生率降低(提高详情页价格的实时性和缓存的准确度),而不是取消这道确认。
列表页用缓存价的偏差要控制在可解释的范围。如果缓存过期时间太长,价格偏差会很大,用户点进详情发现价格变了很多,即使有标注也会不满。所以缓存的过期时间要短(秒级到分钟级),宁可多查几次也别让偏差太大。
踩过的坑:前端算了汇率换算。为了展示用户本币价格,前端拿了一个汇率接口自己算。结果汇率缓存过期、和服务端下单时用的汇率不一致,展示价和实付价差了几块钱,用户投诉。修法是汇率换算完全由服务端做,前端只展示服务端返回的本币金额。这个坑的通用教训是:任何会影响实付金额的计算都不能放在前端,包括汇率、税费、优惠。
踩过的坑二:二次确认弹窗期间用户能返回,导致状态错乱。弹窗弹出后用户按了物理返回键,弹窗关了但下单流程的状态还在「等确认」,再点提交时逻辑走乱了。修法是弹窗期间拦截返回手势,或者返回时显式重置整个下单状态。移动端的任何中间态都要考虑「用户按返回键」这个动作。
没做的部分:没做价格变动的历史提示(比如「此价格 2 小时前是 480,现在涨了」)。这对用户决策有帮助,但需要价格历史数据且可能引发「为什么涨价」的咨询,产品上没定,没做。
「价格不一致客诉未再出现」怎么验证:依据是客服工单的分类统计,要能说出改造前的具体案例(「列表 500 实扣 550」)。同时要报「二次确认拦下价格变动的次数」——这个数字每天都有,它证明这道防线在真实工作,而不是摆设。报「防线拦下了多少次」比报「问题降为 0」可信得多。
金额精度:写单测覆盖各币种的展示(美元两位、日元零位、含千分位的大额),以及各 locale 下的格式化输出。这类正确性改造用「测试用例数量与覆盖的币种/地区」说明,比用百分比有说服力。
详情页实时查价的耗时:要报出来,因为这是二次确认之外用户能感知到的成本。并且要区分「命中缓存」和「回源供应商」两种情况。
不要报「价格准确率 100%」。价格在供应商手上随时可变,客户端不可能保证「准确」,只能保证「不一致时被拦住」。把能力描述准确,比吹一个绝对数字重要。
Intl.NumberFormat,不自己写格式化函数。差异比想象的多:货币符号在前还是在后、小数位数(美元 2 位、日元 0 位、部分中东货币 3 位)、千分位用逗号还是点还是空格、负数怎么表示。自己写一定会漏,而 Intl 是浏览器原生能力,规则最全且随系统更新。做法是服务端返回 { amount: 整数, currency: "JPY", scale: 0 },前端用 Intl.NumberFormat(locale, { style: 'currency', currency }) 格式化。注意 locale 和 currency 是两个独立维度——一个日本用户可能想看美元价格,此时 locale 是 ja-JP 而 currency 是 USD,格式化要按日本习惯展示美元金额。这个区分容易被搞混。
这是旅游客户端和电商客户端最大的差异:付款成功不等于订单成功。付完钱还要等供应商确认,可能几十秒,也可能失败。客户端必须把这个中间态处理好。
第一版照电商做,支付成功就跳「预订成功」页。后果很直接。
一是拒单时用户已经建立了预期。他看到「预订成功」,截图发给同行的朋友,第二天收到「很抱歉酒店无房」的通知。这时的投诉强度远高于一开始就告知「正在确认」。
二是等待期用户重复下单。第二版改成显示「确认中」但没有阻止操作,用户等了二十秒觉得卡住了,返回重新下了一单,结果订了两间房。
三是轮询在切后台后停了。用户在等待期切到别的应用看了一分钟回来,页面还卡在「确认中」转圈——因为定时器被系统挂起了。
四是应用被杀后这单就"消失"了。用户重启应用,订单列表里那单还是待确认,但客户端不知道要去查,用户以为丢了。
所以四个设计:如实展示中间态并给预期时长、等待期禁止重复下单、轮询要能在前后台切换与重启后恢复、超时不判失败。
如实展示中间态会降低「下单成功」的即时满足感,但整体是正收益。用户看到「确认中」确实不如「预订成功」爽,一部分用户会焦虑。但对比拒单时的投诉强度,如实告知的总成本低得多。缓解手段是把预期说清楚(「通常 1 分钟内」)、给进度反馈、以及对自营库存的订单走即时确认并打「立即确认」标签,让用户可以主动选择确定性更高的房源。
拦截返回键有争议,我们选了「提示而不强拦」。硬拦返回会让用户觉得被困住(尤其是他想去查点别的)。做法是返回时弹提示「订单仍在确认中,可在订单列表查看进度」,允许离开但保留本地记录和全局标记。这样既不困住用户,也防住了重复下单。
踩过的坑:切后台回来轮询停了,页面永远转圈。用户在等待期切出去看了两分钟,回来页面还在「确认中」,实际上订单早就确认成功了。原因是定时器被系统挂起后没有恢复机制。修法是监听前后台切换事件,回前台立刻查一次,并基于「发起时间」重算轮询状态。移动端的任何定时逻辑都必须考虑前后台切换,这是和 Web 开发最大的思维差异。
踩过的坑二:全局标记只在内存里,应用重启后失效导致重复下单。用户在等待期杀掉应用重开,「有进行中的预订」标记丢了,他又下了一单。修法是全局标记也基于本地持久化的待确认记录来判断,而不是一个内存变量。移动端凡是需要跨会话生效的状态,都必须落 storage。
没做的部分:没做等待期的「预计剩余时间」倒计时。理论上可以根据历史确认耗时预估,但预估不准反而更焦虑(说 30 秒结果 2 分钟),所以只给了一个模糊的「通常 1 分钟内」。
确认结果的可感知延迟:客户端埋点,量从「支付成功」到「界面展示最终状态」的时间,取中位数约 28 秒。要用中位数而不是平均值——少数超时转「处理中」的订单会把平均值拉到分钟级。并且要单独报「转处理中的比例或数量」,只报中位数会掩盖长尾。
「重复下单未再出现」怎么验证:构造场景手工演练——在等待期尝试所有可能的重复下单路径(返回列表再下单、重新搜索进详情下单、杀应用重启后下单),验证每条路径都被拦住。杀应用重启这条最容易漏,也正是我们踩过坑的地方。
三类中断场景验证:切后台——等待期切出去停留两分钟再回来,验证能立刻拿到结果;断网——等待期开飞行模式,恢复后验证能继续查询;杀应用——等待期强杀,重启后验证自动补查。这三个必须能说出演练步骤和预期结果,说「我做了兜底」但描述不出验证方式,会被认为没真做。
不要报「预订成功率」。拒单主要由供应商房态准确性决定,客户端只能减少损失不能提高成功率。可以报的是「因客户端原因导致的重复订单数」——这个是我们能影响的,而且用绝对数。
没有匹配的内容,换个关键词试试。
项目拆解 · 旅游预订端(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据