1. 背景:小程序主包体积的挑战
为保证小程序的快速启动和流畅体验,微信对主包体积有严格限制(不得超过 2MB)。随着业务功能的不断迭代,开发者必须合理规划代码分包,以在满足平台限制的同时,承载日益复杂的业务需求。
2. 项目分包的演进与阵痛
2.1 从无分包到"视觉 Tabbar"
项目初期(2021年9月),因规模较小,未进行分包。随着功能增加,我们于2022年4月开始采用分包策略,逐步将新页面和非核心代码迁移至分包。然而,主包体积依旧持续增长,逼近上限。
为解决此问题,我们在2024年1月采取了一种"取巧"方案:"视觉 Tabbar"。该方案仅保留首页在主包,通过自定义组件模拟原生 Tabbar 的外观和点击行为。每次切换实际上是执行页面跳转,甚至跨分包跳转。
优点:
-
极大压缩主包体积:为业务增长腾出了空间。
-
自定义灵活:支持动图、异形样式,且弹窗层级可正常覆盖 Tabbar,避免了隐藏原生 Tabbar 可能带来的显示问题。
缺点:
- 用户体验灾难:每次切换都涉及页面跳转,导致白屏时间长、页面状态丢失,与原生 Tabbar 的丝滑体验相去甚远。
2.2 用户体验告急
"视觉 Tabbar"方案虽然暂时解决了主包大小问题,但随着业务代码的累积,其体验弊端在2025年9月集中爆发。数据显示,页面白屏次数和用户占比急剧上升,优化迫在眉睫。


产品的要求明确:在不删减任何功能的前提下,恢复原生 Tabbar 的流畅体验。 这意味着必须将 Tabbar 页面移回主包,但主包早已没有足够空间。
3. 破局之路:分包异步化
3.1 寻找优化突破口
通过 Webpack 构建分析,我们发现 Vendor.js 中一个体积庞大的组件------ECharts 图表------占据了大量空间,即便它已经是按需构建的产物。

针对此问题,我们探讨了两个初步方案:
-
砍掉主包的图表需求:与业务方沟通后被否决,因其为核心展示功能。
-
图表服务端渲染:将图表转为静态图片,虽能彻底移除 ECharts 组件,但会增加服务器压力并牺牲交互性。
两个方案均不可行。此时,微信官方提供的 分包异步化 技术进入视野。该技术允许主包或分包引用其他分包中的组件,实现组件的按需加载,是解决大组件占用主包体积的理想方案。然而,我们项目使用的 uni-app Vue2 版本并不支持此特性。
3.2 组合优化与成果
为利用分包异步化,我们决定研究 uni-app 源码并进行适当修改。成功实现后,主包体积得到显著缩减。

这为我们恢复原生 Tabbar 提供了充足空间。在完成 Tabbar 页面回迁和部分非核心页面移出后,主包体积稳定在 1800KB 左右,为未来发展预留了容量。
新版本于2025年10月16日发布,并进行了短暂的灰度测试。WE平台数据显示,本次优化效果显著,用户体验指标全面向好:



4. uni-app Vue2 分包异步化实现方案
4.1 设计思路:魔法注释 (Magic Comments)
为降低开发者的使用成本,我们设计了一种基于"魔法注释"的方案。开发者只需在导入异步组件的 import 语句上方添加特定注释,即可声明一个异步组件及其元信息,无需改变现有开发习惯。
基础用法:
javascript
/* @uni-async-component */
import uniEcCanvas from '@/pages_common/uni-ec-canvas/uni-ec-canvas';
export default {
components: {
uniEcCanvas
},
}
高级用法:自定义元信息 通过在注释中添加 JSON 对象,可以更灵活地控制平台和占位组件。
-
默认配置 (不填写元信息时,等同于):
javascript/* @uni-async-component { "platform": "mp-weixin", "placeholder": "view" } */ -
自定义示例 (指定多平台和占位组件):
javascript
/* @uni-async-component { "platform": "mp-weixin,mp-alipay", "placeholder": "div" } */
说明:此代码仅为演示,支付宝小程序的支持需单独验证。
4.2 技术实现:三步走
我们的目标是在 uni-app 的编译流程中,解析这些魔法注释,并最终生成符合小程序规范的 componentPlaceholder 和 usingComponents 配置。
总体技术思路:
-
脚本解析阶段 (
**script-new.js**):-
扫描源码,通过正则表达式匹配"魔法注释 +
import语句"的组合。 -
解析注释中的元信息(平台、占位组件),并将其与组件路径、名称等存入一个临时数组
asyncCustomComponents。 -
从源码中移除魔法注释,以免干扰后续 Babel 解析。
-
-
缓存写入阶段 (
**cache.js**):- 扩展
updateUsingComponents方法,使其在写入页面或组件的缓存 JSON 时,能一并存入asyncCustomComponents数组。此字段仅在构建内部流转。
- 扩展
-
最终 JSON 生成阶段 (
**generate-json.js**):-
在生成最终的小程序
*.json文件时,读取缓存中的asyncCustomComponents数组。 -
将其转换为小程序官方要求的
componentPlaceholder对象。 -
将转换后的配置合并到最终的 JSON 对象中,并删除临时的
asyncCustomComponents字段,完成闭环。
-
通过以上修改,我们以最小的侵入性为 uni-app Vue2 框架赋予了分包异步化的能力。
5. 后续:回馈社区与更优方案
我们曾向 uni-app 官方提交了包含此修改的 PR,但未被接纳。官方给出的理由是,Vue2 和 Vue3 的功能设计应保持一致,而 Vue3 在生产环境打包时会移除所有注释,导致此方案不可行。
同时,官方推荐了一个更优雅的社区方案:@uni_toolkit/webpack-plugin-component-config。
这是一个 Webpack 插件,它通过 <component-config> 自定义块来提取配置,并在构建结束后(afterEmit 阶段)将其合并到产物 JSON 中。这种方式完全避免了对 uni-app 源码的侵入,思路更佳,值得借鉴。
源码细节
Magic Comment 语法形态
在 .vue 的 <script> 中,预期可以写出类似语法:
js
/* @uni-async-component {"placeholder":"view","platform":"mp-weixin,mp-qq"} */
import AsyncCard from '@/components/AsyncCard.vue'
-
@uni-async-component:标记该 import 对应的组件为「异步组件」。 -
JSON 配置(可选):
-
placeholder:占位组件名称,默认'view'。 -
platform:生效的平台列表,支持逗号分隔,如"mp-weixin,mp-qq"。
-
在 **script-new.js** 中新增的辅助方法
文件:packages/webpack-uni-mp-loader/lib/script-new.js
该 commit 在模块头部新增了一组与异步组件相关的工具函数:
-
**convertCamelCaseToKebabCase(str)**- 作用:把驼峰/帕斯卡命名的局部变量名(如
AsyncCard)转换为小程序标签形式(async-card),便于后续和模板标签、组件名对齐。
- 作用:把驼峰/帕斯卡命名的局部变量名(如
-
**parseAsyncComponentComment(commentContent)**-
作用:解析注释中的 JSON 配置。
-
行为:
-
成功解析时,返回:
-
placeholder:config.placeholder || 'view' -
platform:config.platform || 'mp-weixin'
-
-
解析失败(JSON 格式不合法)时,返回默认值:
-
placeholder: 'view' -
platform: 'mp-weixin'
-
-
-
说明:即便注释写错,也不会阻塞构建,而是退化为默认配置。
-
-
**isPlatformMatch(configPlatform, currentPlatform)**-
作用:判断当前构建平台(
process.env.UNI_PLATFORM)是否在注释中配置的platform列表内。 -
逻辑:
-
允许
configPlatform为"mp-weixin,mp-qq"这种逗号分隔形式。 -
通过
split(',')+trim()后,检查是否包含currentPlatform。
-
-
-
**processAsyncComponentImports(content)**-
这是整个 Magic Comment 能力的核心解析函数,主要做三件事:
-
使用正则查找形如「注释 + import」的模式:
-
正则:
/\/\*\s*@uni-async-component\s*({[^}]*})?\s*\*\/\s*\n\s*import\s+(\w+)\s+from\s+['"]([^'"`]+)['"`]/g` -
捕获内容:
-
configJson:注释里的 JSON 文本。 -
localName:import 的本地变量名(如AsyncCard)。 -
importPath:import 的路径(如'@/components/AsyncCard.vue')。
-
-
-
把每条匹配转换为一条异步组件记录:
-
解析注释配置:
const config = parseAsyncComponentComment(configJson || '{}') -
判断平台是否匹配:
isPlatformMatch(config.platform, process.env.UNI_PLATFORM) -
生成组件标签名:
const componentTagName = convertCamelCaseToKebabCase(localName) -
推入数组
-
name: 标签名(如async-card) -
value: 组件路径(importPath) -
placeholder: 占位组件名(来自配置或默认'view')
-
-
-
清理源码中的 Magic Comment:
-
用
processedContent.replace(fullMatch, newImportStatement)把整段「注释 + import」替换成单纯的import语句。 -
这样后续 Babel 解析不会看到注释,只看到普通的 ES import。
-
-
-
返回值:
-
content: 已移除 Magic Comment 的源码。 -
asyncCustomComponents: 从源码中收集到的异步组件元信息数组。
-
-
在 loader 主流程中接入解析逻辑
仍在 script-new.js 的导出函数中,加入了对上述解析函数的调用和数据传递:
-
在 Babel 解析前处理源码
-
位置:判断
type(App/Page/Component)之后。 -
行为:
-
调用:
const asyncComponentInfo = processAsyncComponentImports(content) -
更新源码:
content = asyncComponentInfo.content -
这样后续的
parser.parse(content, ...)已经是「无 Magic Comment、但仍然有 import」的 JS 源码。
-
-
-
将异步组件信息挂到 traverse 状态上
-
调用
asyncCustomComponents: asyncComponentInfo.asyncCustomComponents
-
目前的这次改动中,traverse 主要关心的是
components,但通过把asyncCustomComponents一并带入,为后续如有需要在 AST 遍历层使用这些信息预留了扩展点。
-
-
统一拿到异步组件数组
-
在
const asyncCustomComponents = asyncComponentInfo.asyncCustomComponents
-
后续使用都基于这个局部变量。
-
-
在「无任何组件」的场景下也要写入缓存
-
原逻辑:当
!components.length时,只要不是App,就调用updateUsingComponents(resourcePath, Object.create(null), type, content)。 -
新逻辑:条件改为
-
若真的没有同步组件,也没有异步组件:
- 行为同旧逻辑,直接写空
usingComponents到缓存。
- 行为同旧逻辑,直接写空
-
若没有同步组件,但有异步组件:
- 不会走这个「提前返回」分支,而是继续往下执行,确保异步组件信息也写入缓存。
-
-
-
在正常组件收集完毕后,写入 usingComponents 时携带异步信息
-
构造
updateUsingComponents(resourcePath, usingComponents, type, content, asyncCustomComponents)
-
对于「无组件但有异步组件」的情况,上面的提前返回分支也会执行:
updateUsingComponents(resourcePath, Object.create(null), type, content, asyncCustomComponents)
-
至此,异步组件信息第一次进入构建链路的公共缓存模块。
-
在缓存层记录异步自定义组件:扩展 **updateUsingComponents**
文件:packages/uni-cli-shared/lib/cache.js
函数签名变更
-
原先:
function updateUsingComponents (name, usingComponents, type, content = '') { ... }
-
现在:
function updateUsingComponents (name, usingComponents, type, content = '', asyncCustomComponents) { ... }
即多了一个可选参数 asyncCustomComponents,用于承接从 script-new.js 传入的异步组件数组。
将异步组件写入缓存 JSON
在 updateUsingComponents 内部,新增加了一段逻辑:
-
从缓存中取旧 JSON:
-
const oldJsonStr = getJsonFile(name) -
const jsonObj = oldJsonStr ? JSON.parse(oldJsonStr) : { usingComponents }
-
-
新增逻辑:
-
当
jsonObj.asyncCustomComponents = asyncCustomComponents
-
这一步有两个关键点:
-
字段名仍为
- 后续在
generate-json.js中会读取并转换成小程序真正认识的字段,然后删除。
- 后续在
-
与
-
usingComponents仍是页面/组件 JSON 的基础字段; -
asyncCustomComponents相当于对「异步跨分包自定义组件」的附加元信息。
-
函数末尾仍保留原有逻辑,根据 oldJsonStr 是否存在决定是更新 JSON 还是新建 JSON。
在 JSON 生成阶段注入 **componentPlaceholder**:完成小程序侧配置
文件:packages/webpack-uni-mp-loader/lib/plugin/generate-json.js
处理流程插入点
在 generateJson 主函数中,原先已经有对以下字段的归并和清理:
-
customUsingComponents -
usingGlobalComponents -
usingAutoImportComponents
本次改动在这些处理之后、其它平台相关处理(如百度、支付宝特殊逻辑)之前,插入了对 asyncCustomComponents 的处理。
将 **asyncCustomComponents** 转换为 **componentPlaceholder**
新增逻辑概要如下:
-
判断是否存在异步组件:
if (jsonObj.asyncCustomComponents && jsonObj.asyncCustomComponents.length) { ... }
-
使用
-
对每一项
-
key:
-
getComponentName(hyphenate(n.name)) -
先用
hyphenate再用getComponentName,确保组件名符合小程序端的命名规范(统一处理大小写、短横线等)。
-
-
value:
-
n.placeholder || 'view' -
即占位组件的标签名,若未配置则使用默认
'view'。
-
-
-
-
将结果合并到最终 JSON 对象上:
-
jsonObj.componentPlaceholder = Object.assign((jsonObj.componentPlaceholder || {}), componentPlaceholder) -
如果之前已经有
componentPlaceholder,则进行合并,不会覆盖已有配置。
-
-
清理临时字段:
delete jsonObj.asyncCustomComponents
这一步之后:
-
构建输出的小程序页面/组件 JSON 就已经包含了
**componentPlaceholder**配置。 -
小程序端即可根据
componentPlaceholder所描述的占位视图,在异步组件实际加载前,渲染相应的占位结构,实现更好的体验和跨分包引用的能力。
-
读取缓存 JSON:
-
先合并
customUsingComponents、usingGlobalComponents、usingAutoImportComponents等; -
再读取
asyncCustomComponents,转换为componentPlaceholder,并删除原字段。
-
-
最终写入每个页面/组件对应的
*.json文件。
-
@uni_toolkit/webpack-plugin-component-config思路赏析
前文提到社区方案思路更好,这是一个 webpack plugin,我们来赏析一下他的实现方案:
详细流程分析
该插件的工作流程分为两个主要阶段:解析(Process) 和 写入(Emit)。
1. 初始化与过滤
-
构造函数 (
-
接受
include和exclude参数,默认处理**/*.{vue,nvue,uvue}文件。 -
使用
@rollup/pluginutils的createFilter创建文件过滤器。
-
2. 环境检查 (apply)
-
小程序环境判断:
-
代码开头调用了
if (!isMiniProgram()) { return; }。 -
这说明该插件仅在编译为小程序(如微信小程序、支付宝小程序等)时生效。如果是编译 H5 或 App,插件会直接跳过。
-
3. 提取配置 (processModule)
-
钩子 :
compilation.hooks.succeedModule。在每个模块编译成功后触发。 -
去重 :使用
this.set防止对同一个文件重复处理。 -
读取源码:
-
通过
fs.readFileSync读取 Vue 文件的原始内容。 -
使用正则表达式
/<component-config>([\s\S]*?)<\/component-config>/g提取<component-config>标签内的内容。
-
-
解析 JSON:
- 使用
parseJson(来自@dcloudio/uni-cli-shared)将提取的内容解析为对象。
- 使用
-
计算输出路径:
-
使用
getOutputJsonPath(resource)计算该 Vue 文件在小程序dist目录下对应的.json文件路径。 -
将路径和配置对象存入
this.map中暂存。
-
4. 合并与写入 (closeBundle)
-
钩子 :
compiler.hooks.afterEmit。在 Webpack 完成所有文件输出到dist目录之后触发。插件选择在afterEmit阶段修改文件非常聪明。因为 UniApp 的主编译流程会先生成基础的.json文件。如果插件过早介入,生成的配置可能会被 UniApp 覆盖。这个插件选择在"最后一步"进行补丁式修改。 -
合并逻辑:
-
遍历
this.map中暂存的配置。 -
检查
dist目录下对应的.json文件是否存在。 -
关键步骤 :读取现有的 JSON 文件内容,使用
lodash-es的merge方法,将<component-config>中的配置深度合并到原有的 JSON 配置中。 -
最后将合并后的结果写回磁盘。
-