小程序首屏从 3.2s 压到 1.1s:我们在这个订货商城项目上做的 7 个改动与 3 个踩坑

一、先别动手改代码:把基线测出来

我们接手前,客户已经让外包做过一轮"优化",加了骨架屏、合并了几张图,但首屏没变化。原因很简单:没人测过到底慢在哪一段。小程序启动是一条链,从代码包下载、注入、首屏数据请求,到首次渲染,任何一段慢都会反映在"首屏时间"这一个数字上。不拆开看,就只能瞎猜。

我们用了四种测量手段,各有各的坑:

|-------------|--------------------------|-------------------------------|
| 测量手段 | 看什么 | 我们踩过的误判 |
| 小程序后台性能分析 | 启动耗时、首屏渲染耗时、各阶段分布 | 后台只给平均值,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 报表发到项目群。上线四个月,两次因为新增活动页把主包撑大,都在当天就被卡住了。

相关推荐
微擎应用市场2 小时前
二手闲置交易小程序管理系统
小程序
小小王app小程序开发2 小时前
潮玩抽赏小程序玩法开发分析
小程序
干啥啥不行,bug第一名2 小时前
小程序里不要用 requestAnimationFrame
小程序
9624562 小时前
一次 Kubernetes 临时存储压力引发的小程序授权雪崩:从 Pod 驱逐到定时任务漏执行
容器·小程序·kubernetes
imperialeast19 小时前
WinAXP 2.4.0.830正式版
小程序·个人开发
CRMEB系统商城20 小时前
鲜花零售的预售与积分营销
开发语言·人工智能·科技·小程序·零售
镭封1 天前
AI文字转语音能制作多人对白吗?角色分配操作指南
人工智能·小程序·音视频·语音识别·媒体
zhongben_keji1 天前
批发商城APP怎么做:价格引擎、授信台账与资金合规的设计要点
ios·小程序·web app
2501_915921431 天前
iOS 实时日志怎么看?KeyMob 的日志过滤、崩溃报告与符号化
android·ios·小程序·https·uni-app·iphone·webview