南京一个会员小程序:从 3.6 秒到 1.4 秒的加载提速复盘
上篇《微信小程序性能优化实战:setData、渲染与包体积三板斧》发出去后,有同行问:方法都懂,真到了项目里先动哪块?这篇不聊虚的,拿我们交付过的一个南京本地商超会员小程序,把整个过程摊开写:3.6 秒到底花在哪、每一刀砍下去省了多少、哪些"最佳实践"我们主动没做。
利益相关:我在南京易企达做交付,项目是团队做的。机型、版本、业务细节做了脱敏,代码是示意,逻辑和取舍是原样的。
一、项目背景:先把"快"定义清楚
会员积分、优惠券、线上商城、到店核销,日活数千,SKU 数千。用户是门店周边居民,千元安卓机占比不低。
这个用户结构决定了优化目标:不是高端机上的"丝滑",是千元机、4G 弱网下能不能打开。
"打开"也要定义清楚。我们盯的是首屏可见,也就是首个业务内容(不是白屏,也不是骨架屏)出现在屏幕上的时间,从点击小程序图标开始算。不是 onLaunch 完成,也不是页面 onReady——这三个时间点能差出好几秒,口径不统一,后面的优化全是糊涂账。
立项时定了三条预算:
- 首屏可见 < 2s(千元机 + 4G,冷启动)
- 主包 < 1.8MB(平台硬限 2MB,留 200KB 余量)
- 列表滚动无明显掉帧
最后:主包 2.3MB → 1.6MB,首屏 3.6s → 1.4s(同一台千元机、同一网络环境、5 次冷启动取中位数)。
预算不写进文档,后期每加一个活动页就吃掉一点,最后一定回原点。
二、先量后调:把 3.6 秒拆成三段
小程序冷启动大致分三段:
- 下载代码包;
- 注入并执行逻辑层代码(主包越大,这一段越久);
- 首屏渲染。
三段混在一起看,永远不知道该先动哪。我们的做法是先拿平台性能条目,再自己补打点:
// app.js
onLaunch() {
const perf = wx.getPerformance()
perf.getEntries().forEach((e) => {
console.log(e.entryType, e.name, Math.round(e.startTime), Math.round(e.duration))
})
this.launchTime = Date.now()
}
navigation 对应启动链路,script 对应代码注入执行,render 对应渲染。具体字段随基础库版本有差异,别背 API,打印出来看自己这版有什么。
再在首屏 onReady 里补一刀,记录首个业务内容出现的时间:
// pages/index/index.js
onReady() {
const firstScreen = Date.now() - getApp().launchTime
console.log('[perf] first-screen:', firstScreen, 'ms')
// 上报到自己的监控接口
}
拆出来大概是这个分布(同一台千元机、4G、5 次冷启动中位数):
| 阶段 | 优化前 | 优化后 |
|---|---|---|
| 下载代码包 | 0.8s | 0.5s |
| 代码注入与执行 | 1.2s | 0.6s |
| 首屏渲染 | 1.6s | 0.3s |
| 合计 | 3.6s | 1.4s |
这组数做了脱敏,梯度比绝对值更值得看。
结论很直接:下载和注入占了 2 秒,首屏渲染 1.6 秒。一上来抠 setData,最多影响第三段,前两段不动,收益天花板很低。所以顺序是:包体积 → 启动链路 → setData → 渲染。
三、第一刀:包体积,2.3MB 到 1.6MB
3.1 为什么包体积影响的不只是下载
很多人以为包大小只影响下载时间。其实代码包下载完还要注入、编译、执行,主包越大,这一步越久。弱网下下载是大头,中端机上注入是大头,两段都吃包体积。
平台的硬限制是主包 2MB、分包总计 20MB。当时主包 2.3MB,已经压线,加个活动页就超,提审都过不了。
3.2 拆:分包策略
原则就一句:启动必需的在主包,访问路径深的进分包。
- tabBar 四个页面必须在主包,这是平台要求;
- 商城详情、搜索、活动、订单这些低频页进分包;
- 首页配
preloadRule,wifi 下预下载商城分包。
{
"pages": ["pages/index/index", "pages/category/index", "pages/cart/index", "pages/mine/index"],
"subPackages": [
{ "root": "packageMall", "pages": ["pages/detail/index", "pages/search/index"] },
{ "root": "packageOrder", "pages": ["pages/list/index", "pages/detail/index"] }
],
"preloadRule": {
"pages/index/index": { "network": "wifi", "packages": ["packageMall"] }
}
}
四个坑:
- 公共依赖重复打包。分包之间的 npm 依赖、公共组件会被各自打一份。拆之前先看依赖图,公共部分抽到主包或独立分包,否则总量反涨。
- 拆太碎。每个分包都有固定开销,三个以内比较舒服,十几个分包光调度就够呛。
- preloadRule 别写 all。弱网下预下载会跟首屏请求抢带宽,我们只对 wifi 开。
- 独立分包和分包异步化都有边界。独立分包能脱离主包运行,适合分享落地页;异步化能跨分包调 JS 和组件,但有加载等待,别放在首屏路径上。
3.3 压和删
- 商品图全走 CDN,按展示尺寸出图,包里只留 logo 和占位图;
packOptions.ignore把 mock、md、设计稿剔出包;- 代码依赖分析揪出两个没用上的组件库和一份重复 polyfill;
- moment 换 dayjs,能按需引入的依赖不整包引。
{
"packOptions": {
"ignore": [
{ "type": "folder", "value": "mock" },
{ "type": "suffix", "value": ".md" }
]
}
}
结果:主包 2.3 → 1.6MB,代码注入从 1.2s 降到 0.6s。这一刀的收益比后面三刀加起来都大。
四、第二刀:启动链路,别在 onLaunch 里排队
4.1 串行改并行,还要隔离失败
优化前的 onLaunch 是串行的:
async onLaunch() {
const session = await login() // 约 300ms
const config = await fetchConfig() // 约 200ms
await initTracker(session) // 约 150ms
}
三个请求互相不依赖,硬排成队,首屏白等 650ms。改成并行,并且每个请求单独兜底:
onLaunch() {
Promise.all([
login().catch((e) => ({ error: e })),
fetchConfig().catch(() => ({}))
]).then(([session, config]) => {
this.globalData.session = session
this.globalData.config = config
})
// 埋点初始化挪到首页 onReady 之后,首屏不陪它等
}
别用裸的 Promise.all:一个接口挂了会拖垮整组,首屏直接白屏。每个请求单独 catch,失败走降级或重试。
4.2 首屏依赖分级
把首屏要用的数据列出来,分三级:
- 不拿到就不能渲染的(用户身份、门店信息):并行拉,超时降级;
- 可以先用缓存顶着的(商品列表、活动配置):缓存优先渲染,再拉最新;
- 完全不影响首屏的(埋点、推荐位、消息数):首屏渲染之后再拉。
onLoad() {
const cached = wx.getStorageSync('home:feed')
if (cached && cached.length) {
this.setData({ list: cached, loading: false })
}
this.fetchFeed()
}
缓存要带版本号和过期时间,结构一变就丢弃,不然线上会出现"老数据渲染新组件"的诡异问题。
4.3 骨架屏和接口瘦身
骨架屏只解决感知,不解决真实耗时。尺寸必须和真实卡片一致,否则数据回来一切换,页面跳一下,比不做得还难看。
接口也顺手瘦身:getSystemInfoSync 换成分拆后的 getWindowInfo 等按需接口;首屏列表只拉 10 条;后端返回 40 个字段、首屏只用 6 个,不如让后端单独给个精简版。
结果:首屏从 2.2s 降到 1.6s,给后面的 setData 和渲染留出空间。
五、第三刀:setData,从 180KB 到 18KB
5.1 先搞清它贵在哪
setData 是逻辑层往渲染层传数据:序列化 → 传输 → 渲染层 diff → 更新节点。数据量越大,序列化和传输越贵;调用越频繁,通信队列越堵。单次数据超过 1024KB 会直接报错。
先装个统计器,只在开发环境开:
// app.js
const rawSetData = Page.prototype.setData
Page.prototype.setData = function (data, callback) {
const size = JSON.stringify(data).length
if (size > 64 * 1024) console.warn('[setData] big payload', size)
console.log('[setData]', size, Object.keys(data).join(','))
return rawSetData.call(this, data, callback)
}
JSON.stringify().length 是字符数不是字节数,中文会偏小,但做相对比较够用。
5.2 合、减、局部
合:一轮逻辑里的多次调用合并成一次。
// 优化前:三次通信
this.setData({ loading: true })
this.setData({ list: pageList })
this.setData({ loading: false })
// 优化后:一次
this.setData({ loading: false, list: pageList })
减:只传渲染用得到的字段。
// 优化前:接口返回什么就往 data 里扔什么
this.setData({ goodsList: res.data })
// 优化后:只留渲染用得到的字段
const pick = ({ id, title, cover, price, stock }) => ({ id, title, cover, price, stock })
this.setData({
goodsList: res.data.list.map(pick),
total: res.data.total
})
局部:路径更新。
this.setData({ [`goodsList[${index}].selected`]: true })
路径更新有三个边界,踩过才知道:
- 数组重排或插入后,索引会错位,按 index 更新就更新到别人头上了;
- 深层对象依然要做序列化和路径解析,不是零成本;
- 列表超过一屏,路径更新也救不了,分页才是根治。
5.3 onPageScroll 是重灾区
滚动事件每秒触发几十次,每 setData 一次就触发一轮渲染,必卡。原则很粗暴:滚动回调里只记状态,不碰 setData。
onPageScroll(e) {
this._scrollTop = e.scrollTop // 只记录,不 setData
}
返回顶部按钮的显隐用 IntersectionObserver,只在越过阈值那一刻触发一次:
Page({
data: { showBackTop: false },
onReady() {
this._observer = wx.createIntersectionObserver(this, { thresholds: [0] })
this._observer
.relativeToViewport({ top: -300 })
.observe('#top-anchor', (res) => {
this.setData({ showBackTop: res.intersectionRatio === 0 })
})
},
onUnload() {
this._observer && this._observer.disconnect()
}
})
补一句:节流不是解药。100ms 节流看着少了,滚动时依然每 100ms 渲染一次,低端机上该掉帧还是掉帧。要么不 setData,要么交给 CSS。
5.4 长列表:分页还是虚拟列表
判断标准:
- 单页 200 条以内、卡片结构中等:分页 + 触底加载足够;
- 上千条、卡片轻:上虚拟列表;
- 卡片里还有大图和复杂组件:先优化卡片,再谈虚拟列表。
虚拟列表不是免费的:快速滚动白屏、动态高度、组件复用都是坑,维护成本要算进去。
结果:单次 setData 峰值从 180KB 降到 18KB 以内,滚动基本不掉帧。
六、第四刀:渲染细节
- 节点数量:渲染层要创建节点树,节点越多越慢。用开发者工具 WXML 面板数节点、看层级。我们的商品卡片原本 11 层嵌套,合并布局后降到 6 层。
- wx:key 用稳定 id,别用 index。插入或排序时,index 会让渲染层复用错节点,表现就是"数据变了,界面没变"。
- wx:if 和 hidden 按场景选:频繁切换用 hidden,不销毁节点;一次性的用 wx:if,不占内存。
- 图片定宽高 + lazy-load + CDN 尺寸,别在 image 外面套一层不定高的容器。
- 骨架屏尺寸和真实卡片一致,避免切换闪烁。
首屏渲染从 1.6s 降到 0.3s,一部分来自启动链路和缓存,剩下的来自节点精简和图片定宽。
七、主动没做的三件事
复盘如果只写做了的,就成宣传稿了。这三刀我们商量后主动没砍:
- 没做虚拟列表重构。单页上限 200 条,分页够用,虚拟列表的维护成本不值。等数据量真上来了再说。
- 没上 Skyline。那时候客户低端机占比高,兼容性评估没底,收益又不确定,不上。
- 没全量转 WebP。CDN 转码和老机型兼容成本摆在那,最后只对详情页大图做了。
"最佳实践"从来不是 checklist。性能优化是投入产出比的取舍,项目约束不同,答案就不同。这节我反而觉得比代码更值得写。
八、上线后才暴露的坑
性能只是一部分,下面这些坑花的时间一点不少:
- 隐私协议:手机号、位置等接口要配用户隐私保护指引、处理授权弹窗,没配接口直接失败。
- 支付进件与类目:线上商城涉及类目资质,微信支付商户进件资料提前准备;进件审核比开发慢是常态,千万别排在上线前一周。
- 提审:首版因为"功能不完整"被拒了一次,测试入口和 demo 数据提审前必须清干净。
- 性能回归:活动页最容易把主包吃回去。我们现在的做法是每个活动页上线前跑一次代码依赖分析,主包超过 1.8MB 直接打回;新依赖进包前先问一句:能不能走 CDN、能不能分包。
九、复盘数据
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 主包体积 | 2.3MB | 1.6MB |
| 下载代码包 | 0.8s | 0.5s |
| 代码注入与执行 | 1.2s | 0.6s |
| 首屏可见(千元安卓 / 4G,5 次冷启动中位数) | 3.6s | 1.4s |
| setData 单次峰值数据量 | 180KB | 18KB |
| 列表滚动 | 明显掉帧 | 肉眼无明显掉帧 |
十、小结
- 先把"快"定义清楚,首屏可见不等于 onReady;
- 拆时间段再动手,别一上来抠 setData;
- 包体积是最大杠杆,拆包前先看依赖图,别把公共依赖拆重了;
- 启动链路分级:必须的并行拉,可缓存的先渲染,不关键的往后放;
- 滚动回调里不 setData,用 IntersectionObserver 替代;
- 性能预算写进立项和上线检查单,不然一定反弹。
后面想写写上线后的性能监控:怎么用埋点把这几个指标长期盯住,以及怎么在发版前拦住体积回归。有踩过同类坑的,评论区聊聊。
关于作者:南京易企达网络科技有限公司,核心团队自 2010 年起从事软件定制开发,公司 2020 年注册于南京,专注微信小程序及配套管理后台定制。本文项目信息已脱敏。
浙公网安备 33010602011771号