一、先别动手改代码:把基线测出来
我们接手前,客户已经让外包做过一轮"优化",加了骨架屏、合并了几张图,但首屏没变化。原因很简单:没人测过到底慢在哪一段。小程序启动是一条链,从代码包下载、注入、首屏数据请求,到首次渲染,任何一段慢都会反映在"首屏时间"这一个数字上。不拆开看,就只能瞎猜。
我们用了四种测量手段,各有各的坑:
|-------------|--------------------------|-------------------------------|
| 测量手段 | 看什么 | 我们踩过的误判 |
| 小程序后台性能分析 | 启动耗时、首屏渲染耗时、各阶段分布 | 后台只给平均值,P90 才代表经销商的真实体验 |
| 开发者工具 Trace | 逻辑层/渲染层耗时、setData 次数与数据量 | PC 上跑,比中低端安卓机快 3 到 5 倍,不能当结论 |
| 真机性能面板 | 内存占用、帧率、CPU | 只在 iPhone 上测,而客户 70% 的机型是千元安卓 |
| 自定义埋点上报 | 真实用户耗时分布 | 只统计加载成功的,超时退出的用户根本没上报 |
补上自定义埋点后,问题立刻清楚了。埋点要注意别影响主流程,也别把日志打满:
```js
// app.js ------ 首屏耗时上报(采样 20%,失败静默)
const START = Date.now()
App({
onLaunch() {
this.report('launch', Date.now() - START)
},
report(stage, cost) {
if (Math.random() > 0.2) return // 采样,别打满日志
wx.request({
url: 'https://api.example.com/perf',
method: 'POST',
data: { stage, cost, model: wx.getSystemInfoSync().model },
fail: () => {} // 埋点失败绝不能影响主流程
})
}
})
```
```js
// pages/index/index.js
Page({
onLoad() { this.t0 = Date.now() },
onReady() { getApp().report('first_screen', Date.now() - this.t0) }
})
```
基线数据(P90,安卓千元机,20 台真机采样):启动耗时 1.9s、首屏渲染 3.2s、首屏请求 6 个、主包 1.78MB、单次 setData 最大 218KB。看到 218KB 那个数字,基本就知道大头在哪了。
二、7 个改动点
改动 1:主包瘦身 + 分包,但一定要配预下载
主包 1.78MB 里,将近 900KB 是商品详情、订单、个人中心这些非首屏代码,还有一批没压缩的活动页图片。做法是把非首屏页面拆成两个分包,静态资源全部挪到 CDN。
```json
{
"pages": "pages/index/index", "pages/cart/cart",
"subpackages": [
{ "root": "pkg-order", "pages": "list/list", "detail/detail" },
{ "root": "pkg-user", "pages": "center/center" }
],
"preloadRule": {
"pages/index/index": { "network": "all", "packages": "pkg-order" }
},
"lazyCodeLoading": "requiredComponents"
}
```
说句实在的,preloadRule 这一项比分包本身更重要。只分包不预下载,用户点分类时才开始下分包,反而比原来慢。preloadRule 让小程序在首页空闲时就把 pkg-order 拉下来。
改动 2:首屏三个请求合成一个
原来首页要依次请求 banner、商品列表、优惠券,串行等待,总共 800 多毫秒。改成一次拿首页快照:
```js
// 改前:三次请求串行等待
const banner = await getBanner()
const goods = await getGoods()
const coupon = await getCoupon()
// 改后:一次拿聚合快照
const home = await request('/api/home/snapshot', { dealerId })
```
服务端做聚合并发,顺手加个短缓存:
```js
async function homeSnapshot(ctx) {
const banner, goods, coupon = await Promise.all([
getBanner(ctx.dealerId),
getGoods(ctx.dealerId),
getCoupon(ctx.dealerId)
])
ctx.body = { banner, goods, coupon, ts: Date.now() }
}
```
客户端再套一层 60 秒本地快照,经销商来回切页面时基本不用等网络:
```js
const KEY = 'home_snapshot_v1'
const cache = wx.getStorageSync(KEY)
if (cache && Date.now() - cache.ts < 60 * 1000) {
this.render(cache.data) // 先渲染旧数据,体验优先
}
request('/api/home/snapshot').then(data => {
this.render(data)
wx.setStorageSync(KEY, { ts: Date.now(), data })
})
```
改动 3:setData 从 218KB 砍到 12KB
这是收益最大的一处。原代码每次刷新都把整个商品数组回传,218KB 的数据要在逻辑层和渲染层之间序列化传输,中低端机上单次就要 300 毫秒以上。
```js
// 改前:整对象回传
this.setData({ goodsList: this.data.goodsList })
// 改后:路径更新,只传变化的字段
this.setData({ \`goodsList\[${index}.stock`]: newStock })
// 长列表分片渲染,先出首屏再补全
const first = list.slice(0, 20)
this.setData({ list: first })
setTimeout(() => this.setData({ list }), 50)
```
还有个容易忽略的点:不要把不参与渲染的字段放进 data。我们把商品对象里的原始数据备份、埋点字段挪到页面实例的普通属性上,data 只留视图需要的东西。
改动 4:图片走 CDN 缩放 + WebP
商品图原图平均 860KB,列表页一屏 6 张,光图片就 5MB。改成按显示尺寸拉 CDN 缩略图:
```text
原图:https://cdn.example.com/goods/1024.jpg 860KB
拉图:?imageMogr2/thumbnail/375x/format/webp/quality/75 约 38KB
```
```html
<image
src="{{item.cover}}?imageMogr2/thumbnail/375x/format/webp/quality/75"
lazy-load
mode="aspectFill" />
```
注意 webp 在极老的安卓 WebView 上有兼容问题,我们加了 fail 回调兜底回 jpg,实测命中率不到 1%,但兜底必须写。
改动 5:骨架屏,但要放在对的位置
骨架屏不改变真实耗时,改变的是用户感知。关键是骨架屏必须和数据加载同屏渲染,不能等请求回来才切换,否则等于多了一次渲染开销。
```html
<view class="skeleton" wx:if="{{!ready}}">
<view class="sk-banner"></view>
<view class="sk-row" wx:for="{{1,2,3,4}}" wx:key="*this"></view>
</view>
<view wx:else>
<!-- 真实内容 -->
</view>
```
改动 6:按需注入
app.json 里一行配置,让小程序只注入当前页面用到的自定义组件:
```json
{ "lazyCodeLoading": "requiredComponents" }
```
这行在组件多的项目上效果很明显,客户的首页注入耗时从 420ms 降到 160ms。代价是首次使用某个组件时要临时注入,页面切换偶有卡顿------我们用预下载 + 关键组件前置注入规避了。
改动 7:长列表用虚拟滚动
商品列表原本一次性渲染 200 条,DOM 节点数超过 5000。改成 recycle-view 后只保留可视区域的十几条:
```html
<recycle-view batch="{{batchSetRecycleData}}" id="recycleId">
<recycle-item wx:for="{{recycleList}}" wx:key="id">
<goods-card item="{{item}}" />
</recycle-item>
</recycle-view>
```
三、改动前后数据对比
|-------------------|--------|--------|-----------------|
| 指标(P90,安卓千元机) | 改前 | 改后 | 主要贡献 |
| 启动耗时 | 1.9s | 1.2s | 主包瘦身、按需注入 |
| 首屏渲染耗时 | 3.2s | 1.1s | 请求合并、setData 瘦身 |
| 首屏请求数 | 6 | 2 | 接口聚合、本地快照 |
| 主包体积 | 1.78MB | 0.94MB | 分包 + 静态资源上 CDN |
| 单次 setData 最大体积 | 218KB | 12KB | 路径更新、分片渲染 |
| 首屏超 3 秒的跳失率 | 41% | 13% | 以上综合 |
四、3 个试过但没生效的招
这几条在不少优化文章里被反复推荐,但我们实测没有收益,甚至有反效果。写出来是希望大家先测再抄。
· 把商城拆成 6 个分包:分包越细,跨包跳转的首次下载越多。经销商从首页点分类反而多等 0.8 秒,最后收敛回 2 个分包 + 预下载。
· 把整份商品库存缓存到本地:10MB 存储上限不说,同步写入会阻塞渲染。改成快照 + ETag 增量更新,既省请求又不卡。
· 数据预拉取:它只对从聊天会话、扫码等特定场景进入的小程序生效。我们主要入口是"我的小程序"和历史列表,命中率不到 5%,投入产出不划算,直接放弃。
五、一套可以直接抄的排查顺序
· 第一步:加 P90 埋点,先确认慢在启动、请求还是渲染,别凭感觉。
· 第二步:看主包体积,超过 1.5MB 先瘦身,这是最容易拿的分。
· 第三步:抓 setData 次数与单次体积,超过 64KB 的都该改。
· 第四步:合并首屏接口,串行改并行,能聚合就聚合。
· 第五步:图片走 CDN 尺寸裁剪,别在客户端用 CSS 缩放大图。
· 第六步:上骨架屏和虚拟滚动,处理感知与长列表。
· 第七步:把 P90 报表做成每周自动导出,性能回退能第一时间发现。
性能不是一次性的活。交付时我们给客户留了两个自动卡口:CI 里加主包体积检查,超过 1.5MB 直接告警;每周一自动导出 P90 报表发到项目群。上线四个月,两次因为新增活动页把主包撑大,都在当天就被卡住了。