Appearance
分包循环依赖
结论先行
分包循环依赖的本质是:模块提升规则 + 交叉引用 = 依赖图出现环。
uni-app 构建时,被多个分包引用的模块会自动提升到主包。如果两个分包互相引用对方的模块,或者主包引用了分包中已经被提升的模块——依赖图里出现环——构建行为变得不可预测。轻则 vendor.js 异常膨胀,重则直接编译报错或运行时模块未定义。
解决不是"改一行配置",是切断依赖环。而切断的前提是先找到环。
模块提升机制:循环依赖的根源
uni-app 构建时的模块归属规则
uni-app 编译小程序时,内部是一套 webpack 构建链。模块归属遵循:
text
对于一个 JS 模块(组件 / util / mixin / store module):
1. 如果只被主包的页面引用 → 留在主包
2. 如果只被分包 A 的页面引用 → 留在分包 A
3. 如果被主包 + 分包 A 同时引用 → 提升到主包
4. 如果被分包 A + 分包 B 同时引用 → 提升到主包
5. 如果被主包 + 分包 A + 分包 B 同时引用 → 提升到主包规则 3-5 统称"模块提升"(module hoisting)。提升的模块被打进主包的 common/vendor.js。
这是合理的——微信小程序的分包之间不能直接引用对方的代码,浏览器的 require 在分包边界上是隔离的。所以共享代码必须放在主包。
提升链如何产生循环
问题出在:提升不是只看直接引用,而是看完整依赖链。
text
场景:
pages-sub/order/util/format.js ← 在 order 分包
→ 引用了 @/utils/request.js ← 在 main
pages/main/home/index.vue ← 在 main
→ 引用了 pages-sub/order/util/format.js ← 在 order 分包
构建分析:
home/index.vue(主包)引用了 format.js(分包)
→ format.js 满足"被主包引用" → 提升到主包
format.js 引用了 request.js(主包)
→ request.js 已经在主包 → 无影响
format.js 提升到主包后,主包的模块集合包含了 format.js
→ 如果 request.js 反过来也 import 了 format.js 中的某个导出
→ 循环依赖:request.js ↔ format.js单看每个 import 都是合理的。但提升改变了模块的物理位置,使得原本在不同包中的"交叉引用"变成了同一包中的"循环引用"。
五类常见循环依赖场景
场景 1:公共组件回引分包模块
text
components/UserCard.vue ← 主包公共组件
→ import { formatUser } from '@/pages-sub/user/util/user-helper'
↑ user 分包的模块
pages-sub/user/pages/profile.vue
→ import UserCard from '@/components/UserCard.vue'UserCard 在主包,引用了分包的 util。profile.vue 在分包,引用了主包的组件。构建时:
user-helper.js被主包组件引用 → 提升到主包UserCard.vue原本就在主包 → 无变化user-helper.js在分包中如果还引用了该分包的其他模块 → 那些模块也被递归提升- 如果
user-helper.js中还引用了@/utils/common.js,而这个common.js又间接引回了UserCard→ 形成环
修复:主包公共组件不应直接 import 分包模块。将 formatUser 移到主包的 utils/ 下,或将其逻辑内联到组件中。
场景 2:mixin / composable 的交叉依赖
text
mixins/auth-mixin.js ← 主包
→ import { checkLogin } from '@/pages-sub/user/api/login'
pages-sub/user/pages/settings.vue ← 分包
→ import authMixin from '@/mixins/auth-mixin'mixin 在主包,引用了分包的 API。分包的页面引用了 mixin。这个比场景 1 更隐蔽——mixin 通常被多处使用,修改 mixin 的 import 需要确认所有使用处的影响。
修复:将 checkLogin 等被主包 mixin 引用的 API 模块提取到主包的 api/ 或 services/ 目录下。原则:被主包模块引用的东西,物理上就必须在主包。
场景 3:store module 的跨包引用
text
store/modules/cart.js ← 主包(vuex/pinia store)
→ import { applyPromo } from '@/pages-sub/promo/util/calculator'
pages-sub/promo/pages/index.vue ← 分包
→ import { useCartStore } from '@/store/modules/cart'Store 在主包是一种常见架构选择(全局状态自然应该跟随主包)。但如果 store 引用了分包模块,每个引用该 store 的分包页面都会触发该分包模块的提升。
修复:store module 中只引用主包模块。如果 store 需要分包中的数据计算逻辑,将计算函数提取到主包,或通过 action 的 payload 传入数据而非直接在 store 内 import 分包代码。
场景 4:工具函数的单向引用错觉
text
// 分包 order 的 util
// pages-sub/order/util/price.js
import { request } from '@/utils/request' // 主包 ← 方向:分包 → 主包,OK
export function calcTotal(items) { /* ... */ }
// 主包页面
// pages/home/index.vue
import { calcTotal } from '@/pages-sub/order/util/price' // 主包 → 分包,触发提升初看没有循环:分包 util → 主包 request,主包页面 → 分包 util。这是一个简单的提升场景,不形成环——price.js 被提升到主包,request 本来就在主包,两者都在主包中,无环。
但是,如果 request.js(主包)反过来也依赖了已被提升到主包的模块,而这个模块又引用了 request 中的其他导出——就形成环。这种情况往往不是一次写出来的,是多次迭代后逐步形成的:某次重构把一段逻辑从 request.js 抽到了 price.js,导致 request.js 没有显式 import price.js,但逻辑上 price.js 在被提升后和 request.js 处于同一作用域。
修复:这是最隐蔽的一类。需要完整走一遍依赖图分析才能发现。可以用 madge 工具生成依赖图,查找环。
场景 5:动态 import 引入的隐式循环
js
// 主包页面
const Comp = () => import('@/pages-sub/order/components/OrderDetail.vue')动态 import 在运行时才加载,webpack 会将其打包为独立的 chunk(微信小程序中为独立的分包文件)。但如果这个动态 import 的组件内部通过静态 import 引用了主包模块,而那个主包模块又通过其他路径引用了分包的某个模块(已经被提升到主包)——构建时的依赖分析仍然可能在编译阶段报循环依赖警告。
关键点:动态 import 不会打破编译时的依赖分析。webpack 仍然需要分析被动态 import 模块的完整依赖链。动态 import 改变的只是加载时机,不是依赖图的拓扑结构。
如何排查和定位
1. 看构建警告
uni-app 编译微信小程序时,webpack 会输出循环依赖警告:
text
WARNING in Circular dependency detected:
src/utils/request.js -> src/pages-sub/order/util/price.js -> src/utils/request.js这个警告一定不要忽略。警告中给出的路径链就是依赖环。从路径链的最后一环往回追溯,找到引入环的那条 import 语句。
2. madge 分析依赖图
bash
npx madge --circular --extensions js,vue src/输出所有循环依赖的列表。对于分包场景,重点关注跨 pages-sub/ 和 pages/(或 components/、utils/ 等主包目录)之间的环。
3. 分析 vendor.js 体积构成
bash
# uni-app 构建后
ls -lh dist/build/mp-weixin/common/vendor.js
# 如果 vendor.js 超过 500KB,值得拆开看内容
# webpack-bundle-analyzer 对 uni-app 小程序构建产物不直接支持
# 替代方案:在 vue.config.js 中开启 webpack 的 stats 输出js
// vue.config.js
module.exports = {
configureWebpack: {
plugins: [
new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
})
]
}
}通常 vendor.js 中体积异常的模块就是被过度提升的模块——追踪它被哪些入口引用,找到跨包引用的那条链路。
4. 在依赖图中标注分包边界
维护一个依赖图的思维模型:
text
主包 = pages/ + components/ + utils/ + store/ + api/
分包A = pages-sub/A/ + ...
分包B = pages-sub/B/ + ...在这个图上,合法的 import 方向:
- 分包 → 主包 ✓
- 主包 → 主包 ✓
- 分包 A → 分包 B ✗(除非通过动态 import,且 runtime 层面各自独立)
- 主包 → 分包 ✗(触发提升,可能引入循环)
两个实际修复案例
案例 A:公共表单组件回引分包校验逻辑
问题:
text
components/FormValidator.vue → import { rules } from '@/pages-sub/order/validate/rules'
pages-sub/order/pages/checkout.vue → import FormValidator from '@/components/FormValidator.vue'
pages-sub/user/pages/address.vue → import FormValidator from '@/components/FormValidator.vue'rules 被主包组件引用 → 提升到主包。但 rules 本身依赖分包的多个常量定义文件 → 这些也被递归提升。3 个文件被从分包提升到主包,主包增加了约 8KB。虽然没有形成环(因为被提升的模块都留在主包了,没有回引分包的路径),但主包体积被悄悄增加。
修复方案:
text
components/FormValidator.vue
→ 改为接收 rules 作为 prop,不再内部 import 分包模块
pages-sub/order/pages/checkout.vue
→ import { rules } from './validate/rules'
→ <FormValidator :rules="rules" />原则:可配置的数据通过 prop 传入,不要在公共组件中硬引用分包模块。这既避免了不必要的模块提升,也让组件更可复用。
案例 B:请求拦截器中引用分包的 token 刷新逻辑
问题:
text
utils/request.js(主包)
→ import { refreshToken } from '@/pages-sub/user/api/auth'
→ 401 响应时自动调用 refreshToken 刷新
pages-sub/user/pages/login.vue
→ import request from '@/utils/request'这是最常见的一类。request 拦截器在主包,引用了分包的刷新 token 逻辑。构建时的处理:auth.js 被提升到主包。如果 auth.js 中还引用了 store 或其他主包模块,且这些模块又间接引用 request 中的方法(如 request.get)→ 形成环。
修复方案:不改变 request 拦截器的位置(它必须在主包),但将 token 刷新逻辑移到主包:
text
utils/request.js ← 主包
→ 401 → 触发刷新逻辑
utils/auth-refresh.js ← 主包(从分包移出来)
→ 包含 refreshToken 函数
→ 内部引用 request.js 的原始方法(避免循环)原则:request 拦截器是主包的基础设施,它依赖的任何模块都必须在主包。基础设施层不引用业务层代码。
构建产物的差异:有循环 vs 无循环
对于同一个模块,循环依赖存在与否影响构建产物:
text
无循环依赖(正常提升):
dist/build/mp-weixin/
common/vendor.js ← 被提升的 price.js 在这里
pages-sub/order/
list/index.js ← 引用 vendor.js 中的模块(不重复打包)
有循环依赖时,webpack 可能的行为:
1. 将环中的一个模块复制到两个位置(破坏单例,运行时可能行为异常)
2. 将整条环上的模块全部提升到主包(vendor.js 膨胀)
3. 直接报错(如果 webpack 的 splitChunks 配置严格)行为不确定是核心问题——同样的代码在不同 webpack 版本或配置下产出物可能不同。不要在"这次构建通过了就没事"上依赖。
预防规则
主包不 import 任何以
pages-sub/开头的路径。 如果发现自己在写import ... from '@/pages-sub/...'在一个主包文件中,停下来。这条 import 属于基础设施层引用业务层,方向反了。分包公共模块明确归属。 如果一段代码被两个分包都需要,它不属于任何一个分包——属于主包。直接把它放在
utils/或common/下。公共组件不硬编码分包路径。 通过 props、slots、provide/inject 传递差异部分。组件保持对分包的无感知。
CI 中加入循环依赖检测。
bashnpx madge --circular --extensions js,vue src/ && echo "No circular dependencies" || exit 1定期审查 vendor.js 体积。 vendor.js 单次增长超过 20KB 时,review 新增了哪些模块——通常是某次 PR 引入了跨包引用。
