uni-app 小程序首屏要等六秒:分包加载与启动性能优化的完整改造记录

2026-09-20 01:19:17 5 次浏览
微信小程序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 做转发。冷启动时发生的事情按顺序是:

  1. Native 拿到小程序信息,去 CDN 拉取主包代码包,这一步耗时和包体积近似线性,弱网下还会叠加 TLS 握手和重试;
  2. 代码注入。逻辑层要把主包里所有页面的 JS 全部执行一遍,页面越多、第三方库越大,这一段的 CPU 时间越长;
  3. 首屏渲染。执行 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.jsVue.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 出页面

调整方式很土但有效:

  1. packages 里只放用户最可能马上点进的 1 个包,我们选了订单;
  2. 首页的 onReady 之后延迟 1.5 秒再触发,避开首屏请求的高峰;
  3. 弱网信号差的场景用 "network": "wifi" 兜底,别拿用户的流量赌。

改完这三点,预下载的收益才转正:从首页点进订单列表,二次打开耗时从 900ms 降到 180ms 左右。

vendor.js 的三刀

分包解决的是「页面 JS 放哪」,vendor.js 解决的是「公共依赖有多大」。我们砍了三刀:

  • 组件库按需引入。uView 改成 easycom 自动按需,只把用到的 u-buttonu-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 踩坑

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。