南京一个会员小程序:从 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 秒拆成三段

小程序冷启动大致分三段:

  1. 下载代码包;
  2. 注入并执行逻辑层代码(主包越大,这一段越久);
  3. 首屏渲染。

三段混在一起看,永远不知道该先动哪。我们的做法是先拿平台性能条目,再自己补打点:

// 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"] }
  }
}

四个坑:

  1. 公共依赖重复打包。分包之间的 npm 依赖、公共组件会被各自打一份。拆之前先看依赖图,公共部分抽到主包或独立分包,否则总量反涨。
  2. 拆太碎。每个分包都有固定开销,三个以内比较舒服,十几个分包光调度就够呛。
  3. preloadRule 别写 all。弱网下预下载会跟首屏请求抢带宽,我们只对 wifi 开。
  4. 独立分包和分包异步化都有边界。独立分包能脱离主包运行,适合分享落地页;异步化能跨分包调 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,一部分来自启动链路和缓存,剩下的来自节点精简和图片定宽。

七、主动没做的三件事

复盘如果只写做了的,就成宣传稿了。这三刀我们商量后主动没砍:

  1. 没做虚拟列表重构。单页上限 200 条,分页够用,虚拟列表的维护成本不值。等数据量真上来了再说。
  2. 没上 Skyline。那时候客户低端机占比高,兼容性评估没底,收益又不确定,不上。
  3. 没全量转 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 年注册于南京,专注微信小程序及配套管理后台定制。本文项目信息已脱敏。

posted @ 2026-11-10 23:56  欣旺  阅读(12)  评论(0)    收藏  举报