小程序分包策略实战经验与体积优化技巧
项目初期的技术选型
去年年底接手一个内部运营后台,Vue 3 + Vite 构建,功能模块特别散:用户管理、权限配置、数据看板、内容审核、活动配置、报表导出……十几个一级菜单,每个菜单点进去还有三四层子路由。打包完首屏 JS 1.8MB,gzip 后 680KB。测试机上白屏 3 秒起步,热更新慢得像在编译 TypeScript 的祖源代码。
一开始没想分包,想着“先跑起来再说”,结果上线后运营同事直接在群里艾特我:“点个‘活动配置’要等五秒,我以为卡死了。”——行吧,该动刀了。
Vite 官方文档里提了 dynamic import() 和 build.rollupOptions.output.manualChunks 两种方式。我先试了 manualChunks,把 lodash-es、echarts、vue-i18n 单独拎出来,效果有,但不治本。首屏还是加载了所有路由组件,哪怕你只点“用户管理”,ReportChart.vue 和 ExportModal.vue 还是被打进 main chunk 里。
最后拍板:用路由级动态 import + 自定义 manualChunks 组合拳。不为多酷,就为让“用户管理”页面打开时,真·只加载它自己和它的依赖。
最大的坑:性能问题
你以为写个 import('./views/UserManage.vue') 就完事了?天真。
第一坑:Vite 默认对 import() 的路径处理很“智能”——它会自动把 ./views/UserManage.vue 解析成 ./views/UserManage.vue?import 这种带 query 的 URL,然后在 dev 模式下走 HMR,但 build 时又转成 hash 文件名。问题来了:我们用了微前端基座(qiankun),子应用独立部署,CDN 上的资源路径必须稳定可预测。结果上线后,路由懒加载的 chunk 名字天天变,CDN 缓存全失效,404 报警满天飞。
折腾了半天发现,得关掉 Vite 的 build.rollupOptions.output.inlineDynamicImports = false(默认 true),再配合 build.rollupOptions.output.entryFileNames 手动规范命名:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
inlineDynamicImports: false,
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/chunk-[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
}
}
}
})
第二坑更隐蔽:部分页面用了 defineAsyncComponent 做组件级懒加载,比如弹窗、表格列渲染器。但这些组件被多个路由共用,Vite 默认会把它们各自打成独立 chunk,导致相同逻辑重复打包了 4 次(UserManage、Audit、Report、Export 都 import 了同一个 PermissionTag.vue)。最后 chunk 数暴涨到 42 个,HTTP/1.1 下反而更慢。
解决方案是手动聚合:用 build.rollupOptions.output.manualChunks 把跨路由复用的组件收拢到一个公共 chunk:
// vite.config.ts
manualChunks: {
'shared-components': (id) => {
return id.includes('src/components/shared/') ||
id.includes('src/views/_shared/') ||
id.includes('PermissionTag') ||
id.includes('StatusBadge')
},
'charts': (id) => id.includes('echarts') || id.includes('src/utils/chart'),
'utils': (id) => id.includes('src/utils/') && !id.includes('chart')
}
这里注意我踩过好几次坑:id 是绝对路径,Windows 下是反斜杠,Linux 是正斜杠,所以判断必须用 includes 而不是 startsWith;另外 utils/chart 必须提前写,不然会被 utils 规则吃掉——顺序很重要。
最终的解决方案
现在我们的路由配置长这样(真实删减版):
// src/router/index.ts
const routes: RouteRecordRaw[] = [
{
path: '/user',
name: 'UserManage',
component: () => import('@/views/UserManage.vue'),
meta: { title: '用户管理' }
},
{
path: '/audit',
name: 'ContentAudit',
component: () => import('@/views/ContentAudit.vue'),
meta: { title: '内容审核' }
},
{
path: '/report',
name: 'DataReport',
component: () => import('@/views/DataReport.vue'),
meta: { title: '数据报表' },
children: [
{
path: 'detail/:id',
name: 'ReportDetail',
component: () => import('@/views/ReportDetail.vue')
}
]
}
]
同时,我们加了一层“预加载保护”——避免用户点击后才开始下载 chunk,体验断层。在路由守卫里加了简单预加载逻辑(非强制,失败也不报错):
// src/router/guards.ts
router.beforeEach((to, from, next) => {
if (to.name && typeof to.name === 'string') {
const preloadMap: Record<string, () => Promise<any>> = {
UserManage: () => import('@/views/UserManage.vue'),
ContentAudit: () => import('@/views/ContentAudit.vue'),
DataReport: () => import('@/views/DataReport.vue')
}
if (preloadMap[to.name]) {
preloadMap[to.name]()
.catch(() => {}) // 忽略预加载失败
}
}
next()
})
这个逻辑只在浏览器空闲时触发,用 requestIdleCallback 包了一层,不影响主线程。亲测有效,首屏之后的二级页面打开速度从 1200ms 降到 350ms 左右(实测 iPhone 8)。
回顾与反思
改完后整体效果是:首屏 JS 从 680KB gzip 降到 290KB,首次有意义绘制(FMP)从 3.2s 缩短到 1.4s。最夸张的是“活动配置”页,原来加载 4 个图表库 + 2 个富文本编辑器,现在只加载它自己 + shared-components,chunk 大小从 1.1MB 降到 320KB。
不过也有没搞定的地方:比如某些页面用了 import.meta.glob 动态导入大量 SVG 图标,这部分没法被 manualChunks 收敛,目前还是按文件生成了 17 个图标 chunk。不是不能解决,但成本太高(得写插件重写 import 表达式),权衡后决定先放着——反正这些图标都是缓存强、体积小(单个不到 2KB),影响不大。
还有个遗留问题:qiankun 子应用的 getPublicPath() 在分包后偶尔取错 publicPath,导致 chunk 404。目前靠在 index.html 里硬编码 __webpack_public_path__ 临时绕过,后续计划升级 qiankun 到 2.12+ 看是否修复。
总的来说,这次分包不是一步到位的设计,而是边测边调、边上线边优化的过程。没有银弹,只有 trade-off。如果你也在用 Vite + 微前端 + 多模块后台,别怕动 config,rollupOptions 虽然看着吓人,但改对三四个 key,就能省下一半首屏时间。
以上是我踩坑后的总结,希望对你有帮助。这个方案不是最优的,但最简单、最可控、最贴近我们团队的交付节奏。有更优的实现方式欢迎评论区交流,尤其是图标聚合和 publicPath 动态修复这块,我也在蹲更好的解法。

暂无评论