列表页最初是「无限下拉,数据一直往数组里 push,全部渲染」。前一两百条没问题,滑到三四百条之后明显卡顿,小程序端还出现过白屏。三个原因叠加:
一是节点数无限增长。每个商品卡片十几个节点,300 条就是几千个,加上图片的原生组件层,渲染层压力很大。二是 setData 的数据量过大——小程序每次追加数据都要把整个列表数组跨线程传给渲染层,数组越大传输越慢,这是小程序特有的性能陷阱。三是图片没有治理,服务端返回的是原图 URL,一张商品图两三百 KB,一屏六张就是一两兆。
详情页的问题是接口太重:一个接口返回商品基本信息、多规格、详情图文、评价、推荐、库存、活动,服务端要聚合七八个数据源,P95 到了一秒多,前端只能一直转圈。而用户第一眼只需要看到主图、标题、价格。
所以三个方向:列表做回收渲染并优化 setData、图片按需尺寸、详情接口按首屏拆分并预取。
list = [...list, ...newItems] 会导致整个数组跨线程传输。正确做法是用路径更新只传新增部分,或者按页拆成多个子组件(每页一个组件,新增数据只更新新组件,老组件完全不动)。后者在 uni-app 里更好实现,也更彻底。屏幕宽度 / 每行个数 × DPR 算出实际需要的像素宽度请求。不要请求原图——一张 1200px 的图显示在 180px 的卡片上,浪费的是用户流量和解码时间。为什么不用现成的长列表组件。试过社区的方案,问题是商品卡片的高度不完全一致(标题一行和两行、有无促销标签),不定高的虚拟列表复杂度高且容易出滚动跳动。我们的选择是从设计上消除不定高——标题固定两行(不足补空、超出省略),标签区固定高度。这样就能用最简单的等高回收方案。用设计约束换实现简单,是前端很常见也很值得讲的取舍。
预取的成本与收益。预取会产生额外请求和流量,如果用户压根不点进去就是浪费。所以做了限制:只预取视口内且停留过的、只在网络状况良好时(弱网下预取会挤占正常请求)、只预取首屏字段(数据量小)。这样浪费的流量很有限,而命中时的体验提升很明显。要能说出「怎么控制副作用」,只说「我做了预取」是不够的。
踩过的坑:小程序端图片过多导致渲染层崩溃。列表滑很久之后小程序整个白屏,日志里没有 JS 报错。定位过程:先怀疑内存,看到内存持续增长;再发现是图片组件的原生层实例数超限——即使 JS 层的节点被回收了,如果图片组件没有真正从树上移除,原生层的实例还在。修法是回收时确保组件真正卸载(不是 v-show 隐藏),并且限制同时存在的图片数量。这个坑体现了小程序双线程架构的排查难点:JS 层看不到问题,要看原生层。
没做的部分:没做列表数据的本地持久化。用户从详情页返回列表时,如果页面被回收了要重新拉数据、重新滑到原位置。理想做法是缓存列表数据和滚动位置,但涉及数据新鲜度的判断(缓存的价格可能过期),当时没有做完整方案。
内存:Android 上 adb shell dumpsys meminfo 取 PSS,固定操作路径(匀速滑动 300 条商品,每 50 条记一次)。改造前持续增长直到被系统回收,改造后稳定在 300MB 以内。「稳定不增长」这个性质比绝对值更重要,因为绝对值和机型、系统、其他应用都有关。要报出测试机型。
详情页首屏可见时间:在页面 onLoad 和首屏内容渲染完成处打时间戳。改造前约 1.3s(等重接口),改造后 480ms 上下(首屏轻接口),预取命中时更快(几乎无网络等待)。要分别报「预取命中」和「未命中」两种情况——只报命中时的数字是选择性呈现,会被问穿。
图片流量:可以补充这个指标。用抓包工具统计滑动固定条数商品的总下载量,改造前后对比。这个数字对国际业务尤其有说服力,因为很多市场用户对流量敏感。
setData 的数据要经过序列化、跨线程传输、反序列化三步。传输的是你传给它的整个字段,所以 setData({list: 整个数组}) 会把几百条数据全部序列化一遍,数据量大时耗时明显。增量的做法是用数据路径:setData({'list[300]': newItem}),只传这一项。追加多条时可以循环构造多个路径的对象一次传。在 uni-app 里更实用的方案是按页拆子组件——每 20 条一个子组件,新增数据只是新增一个子组件实例,已有组件的数据完全不动,框架不会重传它们。
这个模块是从真实事故里长出来的。最初的实现很朴素:点下单按钮调接口,成功了跳支付,支付 SDK 回调成功就跳成功页。三个问题在国际业务的网络环境下集中爆发。
一是重复下单。用户在网络慢的时候点了按钮没反应,又点一次、再点一次,产生了三笔订单。或者请求超时后前端自动重试,服务端实际上都处理了。
二是最难缠的一个:请求几十秒才返回,前端已经判定失败。用户在弱网下提交订单,前端设了 10 秒超时,超时后显示「下单失败,请重试」并把按钮恢复。但那个请求实际上在第 30 秒成功了。用户看到失败又点一次,于是两笔订单。这个问题的本质是:客户端的超时不等于服务端的失败,超时只意味着「我不知道结果」。
三是支付结果不可信。支付 SDK 的回调可能丢(用户在支付页面切后台、断网、手动返回),也可能回调说成功但服务端还没收到渠道通知。以客户端回调为准会出现「客户端显示成功但订单还是待支付」,或者反过来。
所以整个设计围绕一个原则:客户端永远不是状态的裁定者,服务端订单状态是唯一真相。
「未知态不允许直接重试」的体验代价。用户在弱网下下单超时,我们不让他直接重试而是先查询,查询本身在弱网下也可能慢,所以用户要多等几秒。但这个代价远小于产生重复订单的代价——重复订单要走退款、要客服介入、用户会不信任平台。这个权衡的方向必须明确:在支付链路上,宁可慢一点,不可错一次。
为什么不用客户端本地的订单号做幂等。看起来客户端生成一个 UUID 也能做幂等。但两个问题:一是客户端生成的 ID 服务端无法预先校验(不知道这个 ID 是否对应一个合法的结算上下文),给了攻击面;二是结算上下文(商品、价格、优惠)需要在服务端锁定,token 正好可以承载这个绑定——服务端在下发 token 时就把这次结算的商品和价格记下来,下单时校验一致,防止客户端篡改。所以 token 不只是幂等键,还是结算上下文的句柄。
踩过的坑:轮询期间用户切后台,回来后轮询已经停了。小程序和 App 在后台会挂起定时器,用户切回来时轮询已经中断,页面卡在「确认中」。修法是监听应用前后台切换,回到前台时重新触发一次查询,并且用「记录发起时间 + 回前台后算已过时间」的方式判断还该不该继续轮询。移动端的任何定时逻辑都要考虑前后台切换,这是和 Web 最大的差异之一。
踩过的坑二:支付金额展示与实付不一致。结算页显示的价格是进入页面时算的,用户停留了十分钟,期间活动结束了价格变了,提交时服务端按新价算,用户实付更多。修法是结算上下文有明确的有效期,超时后强制刷新并提示用户价格已变化,需要用户重新确认。不能静默按新价扣款,那是投诉和监管风险。
没做的部分:没做支付渠道的智能排序(按用户历史和地区推荐最可能成功的渠道)。这对转化率有影响,但需要数据支撑,当时是固定顺序展示。
重复下单:用脚本或者抓包工具模拟弱网加重复点击(限速到很低,然后快速连点下单按钮 5 次),检查服务端产生了几笔订单。改造前每轮能复现数笔重复,改造后未再复现。这类可靠性问题用「可复现的测试」量化,比用线上比率更实在——线上重复下单的比率分母不好界定(多少次下单里有多少重复),而且量小的时候统计不显著。
三类中断场景怎么验证:逐个手工演练。断网——调起支付后立刻开飞行模式,恢复后看是否正确确认;切后台——支付过程中切到桌面停留几分钟再回来;中途退出——支付页面直接杀掉应用,重启后看是否自动检测到待确认支付。这三个场景必须能说出具体的演练步骤和预期结果,说「我做了兜底」但描述不出验证方式,会被认为没真做。
不要报「支付成功率提升 X%」。支付成功率主要由渠道、用户余额、风控决定,前端能影响的部分很小。把这个指标算成自己的成果一问就穿。可以报的是「支付结果确认的准确性」——改造前有多少笔订单出现过「客户端和服务端状态不一致」的客服工单,改造后这类工单消失。
跨端项目做到中期一定会遇到两个问题。
一是条件编译泛滥。刚开始只有几处平台差异,写个条件编译很方便。半年后代码里有几百处条件编译,散落在各个业务文件里,同一个能力(比如支付)在五个地方各写了一遍平台分支。新增一个平台要改几百处,而且一定会漏。更糟的是没人敢删——不知道某个分支是为了解决哪个端的什么问题。
二是主包体积撞上限。小程序对主包有明确的大小限制,撞上之后压根发不了版。这不是性能问题而是硬阻塞,而且往往是在要发版的时候才发现,非常被动。
所以两个方向:把平台差异从业务代码里收口到抽象层、把包体积做成可持续管理的(分包 + 门禁)而不是每次撞上限了才临时救火。
抽象层的成本。多了一层间接,调试时要多跳一层看实现;而且抽象接口一旦定了,改动会影响所有调用方。所以抽象层只对「多端有差异且被多处使用」的能力做——只在一个地方用一次的平台差异,直接写条件编译更清楚,硬抽象反而是过度设计。这个边界要说清楚,否则会显得为了架构而架构。
抽象层不可能覆盖 100% 差异。有些差异是根本性的——比如某个端压根没有某个能力,或者交互流程完全不同(App 端可以在应用内完成支付,H5 端必须跳转到外部页面再跳回来)。这类差异不能藏在抽象层里假装一致,必须在业务层显式处理,甚至设计两套不同的交互流程。「一套代码多端运行」是理想,实际是「大部分代码复用,关键差异正面处理」。
踩过的坑:分包后跨包引用导致体积没降。把页面移到分包了,但那些页面引用的组件还在主包,或者反过来主包的页面引用了分包的东西,构建工具会把被引用的部分打进主包。结果是「看起来分包了,主包却没小多少」。修法是用构建分析看主包的实际构成,逐个排查跨包引用,把只被分包使用的组件移进分包。这个坑很典型:分包不是简单移动文件,要理清依赖关系。
踩过的坑二:条件编译在样式里也要处理。只关注了 JS 的条件编译,忽略了不同端的样式差异——安全区(刘海屏、底部手势条)、状态栏高度、滚动条行为在各端表现不同。做法是把这些差异做成 CSS 变量或者统一的布局组件,而不是在每个页面写条件编译的样式。
没做的部分:没做按端裁剪构建产物。理论上编译到微信小程序时,App 端专用的代码应该被完全剔除。条件编译能做到一部分,但抽象层里的各端实现如果写在同一个文件里,可能会有残留。更彻底的做法是按端拆成不同文件(pay.mp.js / pay.app.js),由构建时选择。
主包体积:用小程序开发者工具的「代码依赖分析」或者构建产物大小直接看。要说清是压缩后(上传时的实际体积)还是源码体积——小程序平台限制的是压缩后的体积,说源码体积没有意义。改造前约 2.3MB,改造后 1.4MB 上下。
「低于平台限制且留有余量」比单纯的数字更重要。因为主包体积的意义是「能不能发版」,不是「快多少」。所以描述里应该体现相对平台限制的余量,这才是这个优化的真实价值。
条件编译的数量:可以补充这个指标——改造前散落在业务代码里的条件编译有多少处,改造后收口到抽象层的有多少个文件、业务代码里还剩多少处。这是个能直接数出来的结构性指标,比「代码更好维护了」这种空话有说服力。
不要报「启动速度提升 X%」。主包变小对启动确实有帮助,但启动耗时受平台、机型、网络影响极大,而且分包预下载又会带来新的开销。硬报一个启动提速的百分比很容易被追问到答不上来。
没有匹配的内容,换个关键词试试。
项目拆解 · 跨境商城(uni-app)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据