uni-app 小程序首屏要等六秒:分包加载与启动性能优化的完整改造记录
适用读者:用 uni-app 编译微信小程序、主包已经逼近 2MB 上限、手上又是一份不太好动的老工程的前端。文中的数字来自我们项目真机采样,机型是 2019 年的千元安卓机和一台 iPhone 11,换个设备肯定不一样,看趋势别盯着单个数字不放。
上线前最后一次真机验收,我拿那台千元安卓机点开自家小程序,白屏数到六才看到首页列表。同一台机器打开同类型的另一个小程序,两秒出头就出内容了。开发者工具跑了一次体验评分,启动耗时那栏写着 6124ms,评级 D。
主包体积 3.1MB。这个数字一出来,后面所有优化方向基本就定死了:先把包拆小,再谈渲染。
六秒都花在哪了
我先在开发者工具的性能面板上抓了一次冷启动,把耗时按阶段拆开。微信小程序官方把启动分成「代码包下载」「代码注入」「首屏渲染」三段,我们这个工程的分布是这样的:

| 阶段 | 耗时(ms) | 占比 | 主要瓶颈 |
|---|---|---|---|
| 代码包下载 | 2380 | 39% | 主包 3.1MB,4G 弱网下放大明显 |
| 代码注入 | 1970 | 32% | 所有页面 JS 一次性注入 |
| 首屏渲染 | 1240 | 20% | 首页 onLoad 里串行三个请求 |
| 其他(逻辑层初始化等) | 534 | 9% | App.onLaunch 同步逻辑 |
三个数字里最能改的是前两个。下载靠分包,注入靠按需注入(lazyCodeLoading)。第三项属于业务代码问题,得单独收拾。
用图把这条链路画出来更直观:
flowchart TD
A[用户点击图标] --> B[下载主包代码包]
B --> C{主包是否超过 2MB}
C -- 是 --> D[下载耗时被网络带宽放大]
C -- 否 --> E[进入代码注入]
D --> E
E --> F[逻辑层执行 App.onLaunch]
F --> G[首页 Page.onLoad 发起请求]
G --> H[首次 setData 传给视图层]
H --> I[视图层完成首次绘制 TTI]
I --> J[后台按 preloadRule 拉取分包]
启动链路的原理剖析:主包体积为什么直接压着 TTI
小程序是双线程模型,逻辑层跑在 AppService 里,视图层是一个 Webview,两边通过 setData 传数据,中间隔着一层 Native 做转发。冷启动时发生的事情按顺序是:
- Native 拿到小程序信息,去 CDN 拉取主包代码包,这一步耗时和包体积近似线性,弱网下还会叠加 TLS 握手和重试;
- 代码注入。逻辑层要把主包里所有页面的 JS 全部执行一遍,页面越多、第三方库越大,这一段的 CPU 时间越长;
- 首屏渲染。执行
App.onLaunch、首页Page.onLoad,业务逻辑发请求,拿到数据后setData,视图层才画出第一屏。
关键点在于第二步和第三步都必须等第一步跑完。主包是串行阻塞的,它没有下载完,注入和渲染一行都执行不了。所以主包每多 1MB,TTI 不是「多加一点」,而是把后面所有阶段整体往后推。
官方文档里给的约束也印证了这点:单个分包/主包不超过 2MB,整个小程序所有分包总和不超过 20MB。我们主包 3.1MB 是历史遗留——早期没做分包,页面全堆在主包,构建工具也没报错,就一路拖到了验收。
按需注入(lazyCodeLoading)解决的是第二步。默认情况下,小程序启动时会把主包内所有页面的代码都注入一遍,哪怕用户这辈子都不会点开「关于我们」。开启 "lazyCodeLoading": "requiredComponents" 之后,注入范围收敛到「当前页面实际用到的自定义组件和页面代码」,没被访问的页面代码不执行。注意它的判据是组件依赖,不是路由——某个页面被首页的组件引用了,照样会被注入,这一点后面在 vendor.js 治理里还会碰到。
主包是怎么长到 3MB 的
拆包之前得先知道里面装了什么。uni-app 编译到微信小程序后,产物在 dist/build/mp-weixin,我用 source-map-explorer 和微信开发者工具的「代码依赖分析」各跑了一遍,主包构成大致是这样:
| 内容 | 体积(KB) | 成因 |
|---|---|---|
| vendor.js(含组件库全量) | 1120 | uView 全量引入,echarts 全量打进来 |
| 业务页面 JS | 780 | 32 个页面全在主包 |
| 静态图片与字体 | 640 | 首页 banner、图标字体未走 CDN |
| app.wxss 与公共样式 | 320 | 组件库样式全量 |
| app.js 与配置文件 | 240 | 全局混入、request 封装 |
最大的一块是 vendor.js。uView 在 main.js 里 Vue.use(uView) 全量注册,echarts 为了一个订单趋势图整包引入,moment 只用来格式化两处日期。这些依赖的共同特点是:只有少数页面用得到,却塞进了每个用户都必须下载的主包。
分包怎么切:主包只留 tabBar 页面
分包加载(subpackages)的规划原则我们定了两条,执行起来很快:
- 主包只放 tabBar 页面 + 公共组件 + 全局样式,其余按业务域拆包;
- 拆出来的包单个控制在 1.5MB 以内,避免预下载时反噬首屏带宽。
我们按业务域拆成四个:订单(order)、商品(goods)、用户中心(user)、营销活动(activity)。改造后的 pages.json 是这样:
// 环境:uni-app 3.8.12(Vue 3 + Vite),微信基础库 3.5.5,开发者工具 1.06.2407120
// pages.json 在 uni-app 中支持注释,这里用 jsonc 标注每一处改动意图
{
// 主包只保留四个 tabBar 页面,其余全部下沉到分包
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } },
{ "path": "pages/category/category", "style": { "navigationBarTitleText": "分类" } },
{ "path": "pages/cart/cart", "style": { "navigationBarTitleText": "购物车" } },
{ "path": "pages/mine/mine", "style": { "navigationBarTitleText": "我的" } }
],
// 按业务域划分四个分包,root 目录与业务目录一一对应
// 每个分包的 root 目录必须在磁盘上真实存在,否则编译报路径找不到
"subPackages": [
// 订单域:趋势图依赖 echarts,随分包一起打包,不进主包
{ "root": "pagesOrder", "pages": [{ "path": "list/index" }, { "path": "detail/index" }] },
{ "root": "pagesGoods", "pages": [{ "path": "detail/index" }, { "path": "search/index" }] },
{ "root": "pagesUser", "pages": [{ "path": "setting/index" }, { "path": "address/index" }] },
// 营销活动页只在活动期间访问,单独拆包避免污染主包
{ "root": "pagesActivity", "pages": [{ "path": "seckill/index" }] }
],
// 按需注入:只注入当前页面用到的组件与页面代码
"lazyCodeLoading": "requiredComponents",
// 进入首页后,网络空闲时后台预下载订单与商品分包
"preloadRule": {
"pages/index/index": { "network": "all", "packages": ["pagesOrder", "pagesGoods"] }
},
"globalStyle": { "navigationBarTextStyle": "black" }
}
这里有个坑值得单独说:subPackages 里的页面路径必须放在 root 对应的目录下,而且跨分包跳转要写全路径 /pagesOrder/list/index。我们项目里有一百多处 uni.navigateTo 写的是相对的老路径,拆完包之后全部 404。最后是写了个脚本扫 pages.json 生成路径映射表,配合编辑器的全局替换一次性改完的,手动改必漏。
preloadRule 用不好会帮倒忙
预下载(preloadRule)的逻辑是:进入某个页面后,框架在后台悄悄把指定分包拉下来,用户真点进去时就没有下载等待了。听起来很美,但它是和首屏抢带宽的。
我第一次配的时候图省事写了 "network": "all",packages 里塞了三个分包。结果真机一测,首屏反而慢了 400ms——用户在 4G 下打开首页,主包还没渲完,后台已经开始拖 1.2MB 的分包,首屏的请求被挤到后面排队。
改造前后的时序差别,画出来是这样:
sequenceDiagram
participant N as 微信CDN
participant L as 逻辑层 AppService
participant V as 视图层 Webview
participant U as 用户
U->>N: 冷启动拉取主包
N-->>L: 主包 1.42MB(改造前 3.1MB)
L->>L: 按需注入首页所需组件
L->>V: 首屏 setData(裁剪字段后 22KB)
V-->>U: 骨架屏占位,1.1s 出现
L-->>U: 数据到位,替换骨架
Note over L,N: 首屏完成后延迟 1.5s 再触发预下载
L->>N: preloadRule 拉取 pagesOrder 分包
N-->>L: 后台写入本地缓存
U->>L: 点击订单入口
L-->>U: 直接注入本地代码,180ms 出页面
调整方式很土但有效:
packages里只放用户最可能马上点进的 1 个包,我们选了订单;- 首页的
onReady之后延迟 1.5 秒再触发,避开首屏请求的高峰; - 弱网信号差的场景用
"network": "wifi"兜底,别拿用户的流量赌。
改完这三点,预下载的收益才转正:从首页点进订单列表,二次打开耗时从 900ms 降到 180ms 左右。
vendor.js 的三刀
分包解决的是「页面 JS 放哪」,vendor.js 解决的是「公共依赖有多大」。我们砍了三刀:
- 组件库按需引入。uView 改成 easycom 自动按需,只把用到的
u-button、u-empty之类的组件打进去,样式也跟着走按需; - 大依赖移入分包。echarts 只有订单详情的趋势图用,直接把
ec-canvas和 echarts 源码挪到pagesOrder目录下,构建时它自然被归到分包产物里; - 替换掉过重的小库。moment 换成
dayjs,dayjs 的 locale 再单独按需引。
// 环境:uni-app 3.8.12(Vue 3 + Vite),微信基础库 3.5.5
// 文件:src/main.js —— 组件库按需引入改造
import { createSSRApp } from 'vue'
import App from './App.vue'
// 不再 Vue.use(全量组件库),全量注册会把所有组件打进主包 vendor.js
// easycom 会在编译期按模板里真实用到的标签引入对应组件
import uButton from 'uview-plus/components/u-button/u-button.vue'
import uEmpty from 'uview-plus/components/u-empty/u-empty.vue'
// dayjs 替代 moment:体积从 68KB 降到 6KB,locale 单独按需加载
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
export function createApp() {
const app = createSSRApp(App)
// 只注册首页与 tabBar 页真正用到的组件
app.component('u-button', uButton)
app.component('u-empty', uEmpty)
// 挂载全局属性,替代原先在每个页面重复 import 的写法
app.config.globalProperties.$dayjs = dayjs
return { app }
}
# 环境:Node 18.19.0,uni-app 3.8.12 构建产物目录 dist/build/mp-weixin
# 1) 构建发行版小程序(开启压缩与 tree shaking)
npm run build:mp-weixin
# 2) 用 source-map-explorer 看 vendor.js 里谁占地方
npx source-map-explorer dist/build/mp-weixin/vendor.js --html > vendor-report.html
# 3) 只看主包体积,确认是否降到 2MB 以下
du -sh dist/build/mp-weixin
# 4) 逐个分包核对,单个分包不建议超过 1.5MB
du -sh dist/build/mp-weixin/pagesOrder
第三刀执行完,vendor.js 从 1120KB 掉到 386KB。这块的收益比预期大,因为组件库全量注册本来就是历史的偷懒写法,跟分包本身没关系,纯粹是欠账。
剩下的一秒:setData 和骨架屏
包拆完之后,注入耗时从 1970ms 降到 810ms,但首屏渲染那 1240ms 基本没动。问题有两个。
第一个是 setData 一次传了太多数据。首页列表接口返回 20 条记录,每条 14 个字段,其中 description 字段平均 300 多个字符,列表渲染根本用不到。我在 onLoad 里做了一层字段裁剪,只保留列表需要的 6 个字段,同时把 20 条改成首屏 8 条 + 上拉加载。这次 setData 的数据量从 180KB 降到 22KB,逻辑层到视图层的传输时间从 310ms 降到 40ms 上下。
第二个是请求串行。首页原来依次发了 banner、分类、列表三个请求,后一个等前一个。改成 Promise.all 并发之后,等待时间取决于最慢的那个而不是三者之和,省了大约 380ms。
骨架屏(skeleton)是最后加的。它不改变真实耗时,但把「白屏」变成「有结构感的占位」,用户感知上的等待短了一大截。我们在首页放了一个与真实列表结构一致的骨架组件,数据到位后直接替换。
改造前后对照
真机采样 10 次取中位数,同一台千元安卓机、同一个 4G 网络环境:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 主包体积 | 3.1MB | 1.42MB | -54% |
| 分包总大小 | 无 | 2.3MB(4 个包) | — |
| 代码包下载 | 2380ms | 1020ms | -57% |
| 代码注入 | 1970ms | 810ms | -59% |
| 首屏渲染 | 1240ms | 620ms | -50% |
| 冷启动总耗时 | 6124ms | 2510ms | -59% |
| 体验评分 | D | B | 提升两档 |
冷启动没做到业内常说的 2 秒以内,主要卡在首屏那三个请求上,下一步打算做接口合并和本地缓存兜底。但 6.1 秒到 2.5 秒这个跨度,够用户从「想关掉」变成「愿意等一下」。
容易踩的几个认知误区
分包不是越多越好。 分包数量上去之后,跨包跳转的路径管理、公共依赖的重复打包都会变麻烦。我们中途试过拆成 9 个包,结果公共工具函数在每个包里各存一份,总体积反而涨了 200KB,最后收回 4 个。
预下载不等于提前加载业务逻辑。 preloadRule 只下载代码,不执行。页面代码依然要等真正跳转时才注入,所以「预下载完再点进去还是有一小段空白」是正常的,别拿这个去质疑配置没生效。
按需注入救不了 vendor.js。 lazyCodeLoading 的判据是组件是否被引用,vendor.js 属于公共模块,只要首页间接引用了就会被注入。这也就是为什么我们把它和分包拆成两件事来做:一个管页面,一个管依赖。
往后看,小程序启动性能的优化空间会越来越集中在首屏数据链路上,包体积这块能挤的水分其实就那么多。基础库新版本对代码注入做了不少底层优化,把基础库最低版本往上抬一抬,往往比自己折腾半天收益更大。
另外提醒一句:这些数字记得在开发者工具里用「真机调试」抓,本地模拟器的网络和 CPU 跟真机完全不是一回事,我一开始在本地跑出来的启动耗时只有 1.8 秒,差点以为问题不存在。
你们项目里主包最大的那一块是什么?评论区聊聊,我看到都会回。
参考与延伸
- 微信开放文档 · 分包加载:https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic
- 微信开放文档 · 性能优化与体验评分:https://developers.weixin.qq.com/miniprogram/dev/framework/performance/report
- 微信开放文档 · 按需注入与用时注入:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/lazyload.html
- uni-app 官方文档 · pages.json 页面路由:https://uniapp.dcloud.net.cn/collocation/pages.html
微信小程序开发 / 小程序性能优化 / 分包加载 / lazyCodeLoading / 首屏加载 / 启动耗时 / uni-app 踩坑