上周四准备发版,预览码生成到一半微信弹出提示,主包体积 2357KB 超过 2MB 限制,错误码 80051。当时有点懵,因为上一个版本还是 1.9M,中间只加了一个订单详情页和几个图标,怎么会一下多出 400 多 KB。翻了下提交记录,把一个 UI 组件库整包引入的改动,确实是那次合进去的。
这篇文章就是把后面折腾的两天记下来,包括怎么定位、怎么拆、拆完之后又踩了哪些坑。如果你手上的小程序也快顶到 2M 了,照着做一遍基本能腾出不少空间。
第一步:先搞清楚主包里到底装了什么
很多人一上来就想着怎么分包,其实应该先做体积分析。微信开发者工具自带的”代码依赖分析”就够用,入口在”详情 → 本地设置 → 代码依赖分析”,点一下会生成一张依赖树。
我们那次分析出来的结果是:
- 主包 JS 代码 1.1M,其中第三方库占了 780KB
- 静态资源 890KB,主要是 static 目录下的图片和图标
- 其他(配置、样式)约 360KB
看到这个数字其实心里就有数了。第三方库是大头,静态资源第二,业务代码反而只占两三成。所以后面所有动作都是围绕这两块做。
顺便说一句,如果你用的是 uni-app 的 vue3 + vite 版本,还可以在 package.json 的构建脚本里加上 --report,会在 dist 目录生成一个体积报告页面,粒度比微信的更细,能看到每个模块的压缩前后大小。
第三方库:按需引入而不是整包
那次真正的元凶是一个 UI 库。我们原来在 main.js 里是这么写的:
import uView from 'uview-plus'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.use(uView)
return { app }
}
app.use(uView) 这一句会把整个库注册到全局,无论你用不用到里面的组件,打包的时候都会被算进去。我们那个版本实际上只用了六七个组件,却把整个库背了进来。
改造成按需引入,有两种做法。
一种是配合 easycom 规则。uni-app 的 easycom 会自动扫描组件路径,哪个页面用了哪个组件才编译进对应分包。配置文件写在 pages.json 里:
{
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
}
}
然后在页面里写 <u-button> 就能直接用,不需要 import。关键是 autoscan 这一层只对用到的组件生效,没用到的不会被打包。
另一种是在具体页面里显式 import:
<script setup>
import { UButton, UInput } from 'uview-plus'
</script>
这种写法更直观,缺点是每个页面都要写一遍。我们最后两个方案混用了,公共页面用 easycom,只在一处出现的地方就单独 import。
改完之后第三方库从 780KB 降到了 320KB,这一刀砍掉了大概 460KB,主包立刻退到了 1.9M 以内。不过这只是回到了原来水平,还是要继续往下降。
静态资源:能挪走的都挪走
主包里 890KB 的静态资源,仔细一看基本全是 static 目录下的图。这里面其实混了两类东西。
一类是图标,几十个小 PNG,加起来 200 多 KB。这类东西最适合用字体图标或者 SVG 替代。我们换成了 iconfont 的一个子集,只保留项目里用到的 40 个图标,体积降到了 12KB。
另一类是页面插图,比如活动页的背景图、详情页的占位图,一张就 100 多 KB。这类图片有两个处理方式:
一是丢到 CDN,代码里直接引用 https 链接。微信小程序对 CDN 图片有白名单要求,需要在 project.config.json 里 urlCheck 关掉或者在后台配置合法域名。
二是如果图片小于 4KB,可以考虑转 base64 内联。但要注意 base64 会让体积膨胀约 33%,太小的图片(比如 loading 动图)不适合单独发一个请求,转 base64 反而合算。
我们最后是这么处理的:插图和背景图全部走 CDN,图标换成字体,只留下一些兜底用的占位图在 static 里。主包资源部分从 890KB 降到了 180KB。
分包:该切出去的业务页面
资源和第三方库这两块理清楚之后,主包已经降到 1.4M 左右,但还不够安全。接下来要把一些业务页面切到分包里。
分包设计的第一步是想清楚哪些放主包。经验是:tabBar 页面必须放主包,这是微信的硬性限制;启动页和登录页也必须放主包,因为这是用户进入小程序的第一跳。其余的都可以放。
我们的小程序有四个 tab:首页、分类、购物车、我的。这四个必须留在主包。剩下的订单模块、设置模块、活动模块全部切到分包。
pages.json 改成这样:
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } },
{ "path": "pages/category/category", "style": { "navigationBarTitleText": "分类" } },
{ "path": "pages/cart/cart", "style": { "navigationBarTitleText": "购物车" } },
{ "path": "pages/user/user", "style": { "navigationBarTitleText": "我的" } }
],
"subPackages": [
{
"root": "subpkg-order",
"pages": [
{ "path": "list/list", "style": { "navigationBarTitleText": "订单列表" } },
{ "path": "detail/detail", "style": { "navigationBarTitleText": "订单详情" } },
{ "path": "after-sale/after-sale", "style": { "navigationBarTitleText": "售后" } }
]
},
{
"root": "subpkg-settings",
"pages": [
{ "path": "profile/profile" },
{ "path": "address/address" },
{ "path": "about/about" }
]
}
]
}
分包目录的命名建议用 subpkg- 前缀,一眼能看出来这是分包,不然页面多了很容易搞混。
跳转写法跟主包没区别,uni-app 会用完整路径解析:
uni.navigateTo({
url: '/subpkg-order/list/list?status=unpaid'
})
有一个坑要注意:分包间不能相互引用组件和 JS。如果 subpkg-order 和 subpkg-settings 都要用同一个组件,这个组件只能放在主包,或者每个分包各复制一份。前者会让主包变大,后者会让代码重复。我们最后是把这个公共组件做得尽量小,放在主包,两边的引用都不影响。
独立分包:首页里的活动入口
有一种情况值得单独拿出来说:首页上有一个固定入口跳到活动页,但活动页本身就很大,经常单独更新。这种用独立分包最划算。
独立分包的特点是它可以不依赖主包直接启动,比如用户从朋友圈分享的活动页二维码扫码进来,微信会直接加载这个独立分包,跳过主包。这对于营销活动页来说体验提升很明显。
配置就是在 subPackages 的某一项里加 "independent": true:
{
"root": "subpkg-activity",
"independent": true,
"pages": [
{ "path": "index/index" }
]
}
但是独立分包有个很麻烦的限制:它不能用主包的 app.wxss 全局样式,也不能用主包里注册的全局组件和 store。所以独立分包的页面基本要自给自足,所有样式和依赖都得自己带进来。
我们最后只在那个活动页上用了独立分包,并且给它的样式单独写了一份精简版的全局声明。面积上它确实省了不少,但增加了一点维护成本,所以没有大规模铺开用。
预下载:让分包在需要之前就准备好
分包切出去之后会有一个新问题:用户第一次进订单页的时候会有几百毫秒的空白,因为分包正在下载。这体验不好。
微信提供了预下载机制,你可以在主包某个页面加载完成后,主动去把这个分包提前下载下来。配置写在 pages.json 里:
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["subpkg-order"]
},
"pages/user/user": {
"network": "wifi",
"packages": ["subpkg-settings"]
}
}
意思是:进入首页后,不限网络类型,提前把 subpkg-order 下载好;进入”我的”页面后,如果在 WiFi 下,把 subpkg-settings 下载好。
有几个点得注意。预下载不是立刻执行,而是进入页面后的空闲时机才触发,所以你没法靠它在启动阶段就把包准备好。同一个分包只能被一个页面预下载,如果两个页面都写了同一个分包,后面那个配置会被忽略。
另外预下载本身会消耗流量,虽然是异步的,但在弱网环境下还是可能影响当前请求。所以我们把订单模块的预下载放在 WiFi 环境下才触发,只在 4G 下让用户接受第一次进入时的下载等待——订单页的用户通常是有明确目的进来的,几百毫秒的空白可以接受。
主包里的”隐形负担”
上面几步做完,我们已经降到 1.15M 了。这时候继续分析,发现主包里还有一些常被忽略的东西。
moment.js。这个库本身 200 多 KB,我们只用了格式化一个功能。换成 dayjs 之后立刻降到 8KB,而且是同一个风格,迁移成本接近零。
lodash。整包引入是 500 多 KB,但我们实际只用了 debounce、throttle、cloneDeep 三个方法。改成单独 import 具体方法就行:
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
import cloneDeep from 'lodash/cloneDeep'
如果不想用 lodash,也可以自己写这几行,debounce 和 throttle 都不复杂,cloneDeep 用 structuredClone 就能替代,浏览器和小程序环境都支持。
多余的 polyfill。uni-app 有时候会为了一些老环境塞进去一堆 polyfill。在 manifest.json 里可以把不需要的目标平台关掉。我们只发微信和支付宝小程序,就把 H5、App 相关的构建分支去掉了,主包又少了大概 60KB。
跳转路径的坑
分包改造过程中,最容易被忽略的是路径。分完包之后,原来所有页面路径都会变,如果项目里有硬编码的字符串,就很容易漏掉。
我们做法是把所有路由集中到一个 routes.js 里:
export const ROUTES = {
HOME: '/pages/index/index',
ORDER_LIST: '/subpkg-order/list/list',
ORDER_DETAIL: '/subpkg-order/detail/detail'
}
export function goOrderList(status) {
uni.navigateTo({
url: `${ROUTES.ORDER_LIST}?status=${status}`
})
}
这样路径只在一个文件里维护,下次再调整分包的时候,改一处就行。
还有几个地方特别容易出问题:
分享卡片的 path。分享给好友的链接里带的是具体路径,分完包之后必须同步更新,否则用户点进去就是 404。
二维码/小程序码里的 path。如果二维码是后端生成的,那后端也得跟着改。我们那次差点漏掉了一处,是测试环境扫码才发现。
wx.navigateBack 的 delta。分包页面栈的返回逻辑和主包没什么区别,但如果中间跳过了独立分包,返回栈的层数可能会跟你预期的不一样,最好手动数一下。
最终效果和几个事后想法
发布之后的数字:主包从 2357KB 降到 1113KB,减少了约 53%。加上分包之后小程序总体积是 3.4M,没有超限。冷启动时间从 1.8 秒降到了 1.2 秒左右,主要是主包少了差不多一半资源需要解析。
事后复盘的几条经验,给还在挣扎的同行参考。
第一,体积优化是持续的事,不是版本发版前临时救火的。我们的教训是上游开发经常随手引一个大库,PR 评审的时候没人看体积报告。后来加了一条 CI 规则,主包超过 1.5M 直接构建失败,比事后救火省事得多。
第二,分包不是越细越好。一开始我们分了五个分包,结果发现两个小组件被复制了四次,维护起来特别难受。后来合并成两个,代码复用反而更好。分包的核心目的是减少主包,不是减少代码重复,这两个目标有时候是对立的,得取舍。
第三,真的要上独立分包之前,先做个试验页面测一下。独立分包对所有公共资源的限制比较大,有些组件搬进去就报找不到样式,前期投入的时间和实际收益要评估清楚。
第四,微信开发者工具的体积分析只是一个参考,上传的时候实际压缩效果可能不一样。我们本地分析显示 1.13M,上传之后微信后台报的是 1.10M,因为上传时会再做一次 gzip。但这个差异是往好的方向的,不用担心。
如果项目还没开始分包,建议先把 tabBar 页面以外的页面尽量往后放,一上来就把分包结构设计好,比后面再拆要轻松很多。尤其是那些大页面(详情页、活动页),虽然只有一两个,但占体积很凶,早点切出去省心。

