uni-app跨分包自定义组件引用解决方案

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 图表------占据了大量空间,即便它已经是按需构建的产物。

针对此问题,我们探讨了两个初步方案:

  1. 砍掉主包的图表需求:与业务方沟通后被否决,因其为核心展示功能。

  2. 图表服务端渲染:将图表转为静态图片,虽能彻底移除 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 的编译流程中,解析这些魔法注释,并最终生成符合小程序规范的 componentPlaceholderusingComponents 配置。

总体技术思路:

  1. 脚本解析阶段 ( **script-new.js**)

    • 扫描源码,通过正则表达式匹配"魔法注释 + import 语句"的组合。

    • 解析注释中的元信息(平台、占位组件),并将其与组件路径、名称等存入一个临时数组 asyncCustomComponents

    • 从源码中移除魔法注释,以免干扰后续 Babel 解析。

  2. 缓存写入阶段 ( **cache.js**)

    • 扩展 updateUsingComponents 方法,使其在写入页面或组件的缓存 JSON 时,能一并存入 asyncCustomComponents 数组。此字段仅在构建内部流转。
  3. 最终 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 能力的核心解析函数,主要做三件事:

      1. 使用正则查找形如「注释 + 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')。

      2. 把每条匹配转换为一条异步组件记录:

        • 解析注释配置:const config = parseAsyncComponentComment(configJson || '{}')

        • 判断平台是否匹配:isPlatformMatch(config.platform, process.env.UNI_PLATFORM)

        • 生成组件标签名:const componentTagName = convertCamelCaseToKebabCase(localName)

        • 推入数组

          • name: 标签名(如 async-card

          • value: 组件路径(importPath

          • placeholder: 占位组件名(来自配置或默认 'view'

      3. 清理源码中的 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:

      • 先合并 customUsingComponentsusingGlobalComponentsusingAutoImportComponents 等;

      • 再读取 asyncCustomComponents,转换为 componentPlaceholder,并删除原字段。

    • 最终写入每个页面/组件对应的 *.json 文件。


@uni_toolkit/webpack-plugin-component-config思路赏析

前文提到社区方案思路更好,这是一个 webpack plugin,我们来赏析一下他的实现方案:

详细流程分析

该插件的工作流程分为两个主要阶段:解析(Process)写入(Emit)

1. 初始化与过滤

  • 构造函数 (

    • 接受 includeexclude 参数,默认处理 **/*.{vue,nvue,uvue} 文件。

    • 使用 @rollup/pluginutilscreateFilter 创建文件过滤器。

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-esmerge 方法,将 <component-config> 中的配置深度合并到原有的 JSON 配置中。

    • 最后将合并后的结果写回磁盘。

相关推荐
我的一行2 小时前
已有项目,接入pnpm + turbo
前端·vue.js
亮子AI2 小时前
【Svelte】怎样实现一个图片上传功能?
开发语言·前端·javascript·svelte
心.c2 小时前
为什么在 Vue 3 中 uni.createCanvasContext 画不出图?
前端·javascript·vue.js
咸鱼加辣2 小时前
【vue面试】ref和reactive
前端·javascript·vue.js
LYFlied2 小时前
【每日算法】LeetCode 104. 二叉树的最大深度
前端·算法·leetcode·面试·职场和发展
宁雨桥2 小时前
前端并发控制的多种实现方案与最佳实践
前端
KLW752 小时前
vue2 与vue3 中v-model的区别
前端·javascript·vue.js
李广山Samuel2 小时前
Node-OPCUA 入门(1)-创建一个简单的OPC UA服务器
javascript
zhongjiahao2 小时前
一文带你了解前端全局状态管理
前端