Skip to content
概述
uni-app 在编译微信小程序时,会根据引用关系将模块分配到主包或分包。模块提升规则本身合理:分包之间不能直接访问对方的模块,共享代码必须放入主包。但提升会改变模块的物理位置,原本隔离在不同包中的引用可能被集中到同一个作用域。一旦这些引用构成反向依赖,就可能在依赖图中闭合为环。
环一旦形成,构建行为就不再稳定。vendor.js 可能异常膨胀,编译时可能报告循环依赖警告,运行时也可能出现模块未定义。要理解这类问题的根因,需要先掌握模块提升的具体规则与构建链路。
基本概念
分包与主包
微信小程序将源码划分为主包和若干分包。主包包含启动阶段所需的页面与公共代码,分包则按需加载。小程序运行环境强制要求:分包之间不能通过 import 直接引用对方的模块。这个约束直接决定了编译阶段模块归属的判断逻辑。
模块归属规则
uni-app 底层基于 webpack 构建。对于一个 JS 模块——无论是组件、工具函数、mixin 还是 store——最终放到哪个包,遵循以下决策链:
- 仅被主包页面引用 → 留在主包。
- 仅被某个分包页面引用 → 留在该分包。
- 被主包和至少一个分包引用 → 提升到主包。
- 被多个分包引用(无论主包是否也引用) → 提升到主包。
规则 3 和 4 称为模块提升(module hoisting)。被提升的模块最终会打包进主包的 common/vendor.js。
工作原理
提升过程的递归性质
模块提升不是只分析直接的 import 语句,它会沿着完整的依赖链递归处理。考虑这样一条引用:
pages-sub/order/util/format.js ← 原本属于 order 分包
→ 引用了 @/utils/request.js ← 主包模块
pages/main/home/index.vue ← 主包页面
→ 引用了 pages-sub/order/util/format.js构建时分析的过程大致如下:
home/index.vue(主包)引用了format.js(分包资源),于是format.js满足“被主包引用”的条件,被提升到主包。format.js内部引用了request.js,而后者已经在主包,此时不触发额外提升。format.js进入主包后,与request.js处于同一作用域。- 如果
request.js自己或者通过其他模块间接依赖了format.js的某个导出,就会形成request.js ↔ format.js的循环。
从单条 import 看,每个引用都合法。但提升会将跨包边界的引用收拢到同一个作用域,原本隔离的交叉引用就有可能闭合为环。
构建产物的差异
是否存在循环依赖,直接影响 webpack 的模块分配结果:
- 无循环依赖:被提升的模块只出现在主包的
common/vendor.js,分包内通过运行时的模块引用指向该文件,不会重复打包。 - 存在循环依赖:webpack 可能采取以下行为之一:
- 将环中某个模块复制到多个位置,破坏模块单例,运行时可能出现状态不一致。
- 将整条环上的模块全部提升到主包,reuslt 是 vendor.js 体积显著膨胀。
- 在 splitChunks 配置较严格时直接报错。
这些行为的不确定性来自于 webpack 的依赖解析和分包优化策略在不同版本、不同配置下的差异。单次构建通过不代表没有问题。
示例
以下示例基于 uni-app 默认的项目结构,其中 pages/、components/、utils/、store/ 通常处于主包,pages-sub/ 下的各子目录对应不同的分包。
1. 公共组件引用分包模块
javascript
// components/UserCard.vue(主包公共组件)
import { formatUser } from '@/pages-sub/user/util/user-helper';javascript
// pages-sub/user/pages/profile.vue(user 分包页面)
import UserCard from '@/components/UserCard.vue';UserCard 在主包,profile.vue 在分包。编译阶段:
user-helper.js被主包组件引用,提升到主包。- 如果
user-helper.js内部还引用了UserCard或其他主包模块,而这些模块又引回了user-helper.js,就形成跨模块的环。
修正方式:将 formatUser 这类被主包组件依赖的函数移动到主包 utils/ 下,或改为通过 prop 接收格式化后的数据,让组件对分包无感知。
2. mixin 或组合式函数的跨包引用
javascript
// mixins/auth-mixin.js(主包)
import { checkLogin } from '@/pages-sub/user/api/login';javascript
// pages-sub/user/pages/settings.vue(分包)
import authMixin from '@/mixins/auth-mixin';mixin 本身在主包,却引用了分包的 API;分包的页面又引用了这个 mixin。这种情况比组件的引用更隐蔽,因为 mixin 的使用方可能散布在多个页面。
修正方式:将 checkLogin 这类被主包模块 import 的 API 函数提取到主包的 api/ 或 services/ 目录。基本规则是:凡被主包模块 import 的代码,物理上必须位于主包。
3. 状态管理模块引用分包逻辑
javascript
// store/modules/cart.js(主包,Vuex/Pinia store)
import { applyPromo } from '@/pages-sub/promo/util/calculator';javascript
// pages-sub/promo/pages/index.vue(分包)
import { useCartStore } from '@/store/modules/cart';store 放在主包是常见选择,但如果 store 模块内部引用了分包模块,则所有使用该 store 的分包页面都会触发相应分包模块的提升。
修正方式:store 模块只应引用主包模块。如果 store 确实需要分包中的计算逻辑,可以将核心计算函数提取到主包,或通过 action 的 payload 传入数据,避免在 store 内直接 import 分包代码。
4. 工具函数的单向引用错觉
javascript
// pages-sub/order/util/price.js(分包工具模块)
import { request } from '@/utils/request'; // 分包 → 主包,方向合法
export function calcTotal(items) { /* ... */ }javascript
// pages/main/home/index.vue(主包页面)
import { calcTotal } from '@/pages-sub/order/util/price'; // 主包 → 分包,触发提升这里没有明确的环:price.js 被提升到主包,request.js 原本就在主包,两者共存,暂时没有循环。但在多次迭代后,如果某次重构将 request.js 中的部分逻辑移动到 price.js,而 request.js 又通过其他路径依赖了 price.js 中的某个导出,webpack 静态分析仍然可能识别出循环。
5. 动态导入引入的隐式循环
javascript
// 主包页面
const Comp = () => import('@/pages-sub/order/components/OrderDetail.vue');动态 import() 在运行时加载模块,webpack 会为其输出独立的 chunk(对应微信小程序中分包下的文件)。但这并不打断编译阶段的依赖解析:webpack 仍然需要分析被动态导入模块的完整依赖链。如果该模块内部通过静态 import 引用了主包模块,而主包模块又经由其他路径引用了已提升到主包的分包模块,编译时仍可能报告循环依赖。
关键点在于,动态 import 只改变加载时机,不改变依赖图的拓扑结构。
6. 请求拦截器引用分包逻辑
javascript
// utils/request.js(主包)
import { refreshToken } from '@/pages-sub/user/api/auth';
// 在 401 响应时自动调用 refreshTokenjavascript
// pages-sub/user/pages/login.vue(分包)
import request from '@/utils/request';请求拦截器作为主包的基础设施,依赖了分包中的 token 刷新逻辑。构建时 auth.js 会被提升到主包,如果 auth.js 中又引用了 store 或其他主包模块,且这些模块反过来依赖 request 的方法,环就会形成。
修正方式:不移动 request 拦截器的位置(它必须留在主包),但将 token 刷新逻辑移动到主包:
javascript
// utils/request.js(主包)
// 401 时触发刷新,刷新逻辑由 utils/auth-refresh.js 提供javascript
// utils/auth-refresh.js(主包,从分包移出)
// 包含 refreshToken 函数,内部调用 request 的原始方法(注意避免循环)基础设施层的代码不应依赖业务层的模块。
命令
查看构建警告
uni-app 编译时,webpack 可能在控制台输出类似下面的警告:
WARNING in Circular dependency detected:
src/utils/request.js -> src/pages-sub/order/util/price.js -> src/utils/request.js警告中的路径链直接反映了依赖环的结构。从链的末端开始逐段回溯,就能定位引入环的那条 import 语句。
使用 madge 分析依赖图
madge 可以静态分析项目中的循环依赖:
bash
npx madge --circular --extensions js,vue src/输出会列出所有检测到的环。排查时应重点关注跨越 pages-sub/ 与主包目录(如 components/、utils/)的环。
分析 vendor.js 体积
bash
ls -lh dist/build/mp-weixin/common/vendor.jsvendor.js 超过 500 KB 时,值得进一步拆解内容。对 uni-app 的小程序构建产物,可以在 vue.config.js 中引入 webpack-bundle-analyzer:
javascript
// vue.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
configureWebpack: {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
})
]
}
};vendor.js 中体积异常的大型模块通常就是被过度提升的模块。通过追溯它被哪些入口引用,可以找到触发提升的跨包引用链路。
在依赖图中标注分包边界
建立清晰的目录边界有助于判断引用是否合法:
主包 = pages/ + components/ + utils/ + store/ + api/
分包 A = pages-sub/A/ + ...
分包 B = pages-sub/B/ + ...允许的 import 方向:
- 分包 → 主包
- 主包 → 主包
- 分包 A → 分包 B(仅限于动态 import,且运行时各自独立)
应避免的方向:
- 主包 → 分包(会触发提升,可能引入环)
注意点
- 主包文件不得引用
pages-sub/路径。 如果在主包文件中写出import ... from '@/pages-sub/...',意味着基础设施层引用了业务层,应立刻更正。 - 分包间共用的模块应明确归属主包。 一段代码如果被两个分包同时需要,它就不属于任何一个分包,应直接放在
utils/或common/下。 - 公共组件不硬编码分包路径。 通过 props、slots、provide/inject 传递差异部分,使组件对分包无感知。
- 在持续集成中加入循环依赖检测。bash
npx madge --circular --extensions js,vue src/ && echo "No circular dependencies" || exit 1 - 定期审查 vendor.js 体积。 vendor.js 单次增长超过 20 KB 时,应复查新增模块的引用关系——往往是因为某次提交引入了跨包引用。
限制
- 动态 import 虽然将模块输出为独立文件,但编译阶段的依赖分析仍然会追踪其完整的依赖链,无法绕过循环依赖检测。
- webpack 的模块分配策略在不同版本和配置下可能变化,不能仅凭单次构建通过来保证不同环境下的产物一致性。
- madge 的分析基于静态 import/export 语法,无法识别通过
require或运行时动态拼接的模块路径。
参考链接
- uni-app 官方文档:小程序分包
- madge: https://github.com/pahen/madge
